# Débitos técnicos e de segurança

Registro dos itens conhecidos que foram deliberadamente adiados, com o motivo e
o momento previsto para tratá-los. Um item só sai desta lista quando resolvido
em produção — não quando corrigido no código.

Atualizado em 16/08/2026.

> ## DECISÃO PERMANENTE de 11/09/2026: nada mais é feito em produção até a migração final
>
> *"nao faremos nenhuma modificação no servidor de produção, pule estas etapas e
> faremos apenas na mingração final, apos todas as validações"*
>
> Isto responde, de uma vez, a **todo** item deste registro cuja única pendência é
> "falta aplicar em produção" — são **doze**, e nenhum deles precisa ser trazido
> de volta como pergunta. Eles ficam prontos no código, validados aqui, e entram
> **juntos** na migração final.
>
> **A ordem é essa de propósito:** validar primeiro, aplicar depois. Produção está
> congelada desde 23/08/2026, e o que mudou hoje é que o congelamento deixou de
> ser "até alguém decidir" e passou a ter um destino — a janela da migração.
>
> **O que isso NÃO muda:** o que está ativo em produção continua ativo, e o
> registro continua dizendo qual é. Em particular a rota
> `/autenticacao/migrar-senhas`, as oito credenciais em texto claro no código
> implantado, e o `local.php` inexistente. Adiar com data não é resolver, e este
> parágrafo não existe para fazer parecer que resolveu.
>
> **Para quem for executar a janela:** `docs/dia0-remediacao-producao.md` tem os
> passos 3 a 7, na ordem, com a dependência `local.php` antes do deploy. Os passos
> 1 e 2 já foram executados em 21/08/2026.

---

## Adiados para a Fase 1 (migração MySQL → PostgreSQL)

Decisão do responsável pelo projeto em 15/08/2026: tratar junto com a migração
de banco, em vez de numa janela separada agora.

### D1 — Arquivos com dados pessoais expostos no webroot — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: o deploy do passo 4 do dia 0 na migração final; até lá, a próxima exportação do Relatório de Clientes recria o arquivo em public/.

`public/clientes.txt` (nomes e telefones de clientes), `public/log_scripts.txt`,
`public/logs/` e `public/phpinfofile.php` ficam dentro do webroot e são
alcançáveis por URL pública no servidor de produção.

*Correção de 15/08/2026:* a primeira versão deste registro atribuía a exposição
ao `RewriteCond %{REQUEST_FILENAME} -s` do `public/.htaccess`. Está errado — a
produção usa **nginx**, que não lê `.htaccess`. O mecanismo real é o
`try_files $uri ... /index.php` da configuração do nginx, que serve qualquer
arquivo existente antes de cair no PHP. A conclusão não muda: os arquivos são
públicos.

**Situação:** corrigido no código (as exportações não gravam mais em disco e os
logs foram para `data/logs/`) **e a exposição foi estancada em produção em
21/08/2026** — os quatro arquivos estão em `~/quarentena-dia0/20260821-141226`, as
URLs devolvem 404, e a apuração do passo 2 achou **zero acesso externo** no
período retido pelos logs (14 dias).

> **Correção de 11/09/2026:** esta linha dizia *"pendente em produção — o deploy
> ainda não foi feito, então os arquivos continuam no servidor"*, e isso deixou de
> ser verdade em 21/08. Duas fontes independentes registram a remoção com data:
> o quadro de estado de `docs/dia0-remediacao-producao.md` e a lista
> `$explicadas` de `scripts/conferir-referencias-docs.php`, que diz *"removido da
> producao (D1)"* para os quatro caminhos. Só este parágrafo não tinha sido
> atualizado.

**E por isso ele não fecha:** a limpeza foi de uma vez, e o código **implantado**
continua sendo o antigo — aquele que grava a exportação em `public/`. Enquanto o
passo 4 não acontecer, a próxima exportação do Relatório de Clientes recria o
arquivo. A quarentena de 21/08 estancou o que estava exposto; ela não torna a
correção durável.

**Ao resolver:** os passos 1 e 2 de `docs/dia0-remediacao-producao.md` estão
feitos. O que falta é o deploy do passo 4, e ele entra na migração final por
decisão de 11/09/2026 — ver o aviso no topo deste documento.

**Ao resolver:** seguir `docs/dia0-remediacao-producao.md`, passos 1 e 2. O passo
2 apura nos logs do nginx se houve acesso externo, o que define se existe dever
de notificação à ANPD.

### D2 — Endpoint público de migração de senhas — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: o deploy na migração final, com o bloqueio de nginx do passo 6 do dia 0; até lá, a rota continua ativa em produção.

`/autenticacao/migrar-senhas` responde sem autenticação e sobrescreve o hash de
senha de todos os usuários ativos de uma vez. Era um script de uso único da
migração para PHP 8.3 que ficou publicado.

**Situação:** rota e ação removidas do código (commit `3a07943`), **ativa em
produção** até o deploy.

**Ao resolver:** o deploy já elimina. Há um bloqueio de nginx pronto no passo 6 do
documento de remediação, e ele entra na **migração final** por decisão de
11/09/2026 — até lá a rota continua ativa, e este débito é o registro disso. Ver o
aviso no topo deste documento.

### D3 — Rotação das oito credenciais — PARCIAL em 03/10/2026: a chave do Asaas saiu do código; a rotação das oito continua sendo do dono — PREPARADO, lido em 05/10/2026 — PREPARADO em 10/10/2026: o OneSignal já usa a chave nova quando ela vier

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como parcial, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: rotacionar as credenciais no passo 5 do dia 0 e pôr a chave nova do Asaas no local.php de produção; Yungas, OneSignal e SMS adiados.

Estavam em texto claro no código e portanto existem em todo backup e snapshot
anterior a 15/08/2026. Movê-las para `config/autoload/local.php` não as torna
secretas retroativamente.

| Credencial | Observação ao rotacionar |
|---|---|
| Senha MySQL (`franquiabv`) | Mapear consumidores antes (`SHOW PROCESSLIST`): o sistema de BI acessa o banco direto e vai quebrar |
| OneSignal — app mobile | |
| OneSignal — web push | |
| SMTP `franquiabv@barbeariavip.com.br` | |
| Bearer token Zoomtec | O JWT não tem `exp`; não expira sozinho, precisa ser reemitido pelo fornecedor |
| Usuário/senha da API iAgente SMS | Trafegam na query string da URL, então estão também nos logs do provedor |
| **Chave de API do Asaas** | `module/ISBase/src/Util/Asaas.php`, no construtor -- até 03/10/2026; desde então, o `asaas.api_key` do `local.php` (ver "O conserto", abaixo). É a de **produção** (`api.asaas.com`), não sandbox — move dinheiro |
| **API key, e-mail e senha do Yungas** | `module/ISApi/src/Service/CronJobService.php`, em `yungasAutenticacao`. A senha está em texto puro, ao lado de um e-mail nominal do fornecedor |

**Eram seis até 31/08/2026, e são oito.** As duas últimas apareceram ao aplicar
os tempos limite do **D67** — não por uma varredura de segredos, mas por abrir
os arquivos por outro motivo. Nenhuma das duas estava no passo 5 do documento
de remediação, que agora as traz.

Vale a diferença de natureza: a do Asaas é a de **produção de um meio de
pagamento**, e a do Yungas inclui **uma senha**, não só um token — se ela for
reaproveitada em outro lugar pelo fornecedor, o estrago passa do Yungas.

#### A do Yungas: DECIDIDO em 03/09/2026 — adiada, e talvez desnecessária

> *"colocar yungas como débito técnico, não é prioritário e talvez seja
> descontinuado"*

A oitava linha sai da fila do que precisa acontecer antes do corte. A integração
pode ser descontinuada, e trocar credencial de um serviço que vai sair é
trabalho jogado fora.

**O código já foi limpo no mesmo dia** (**D89**): os três valores saíram do
`CronJobService` para `config/autoload/local.php`, e a rotina passou a registrar
no log quando não há credencial — em vez de rodar em silêncio sobre nenhuma
unidade.

*Isso impede cópias novas; não desfaz as antigas.* Os valores continuam válidos
no fornecedor e continuam no histórico do git. **Se a integração for
descontinuada, o certo é revogar a conta** — melhor que rotacionar, porque
credencial revogada não vale nem no histórico.

As outras sete continuam como estavam.

E há uma discrepância que fica registrada em vez de corrigida em silêncio: a
tabela do passo 5 do dia 0 tinha **cinco** linhas contra as seis daqui — faltava
lá a do iAgente SMS. Pode ser deliberado, já que o SMS está congelado por
instrução sua; não presumi, e a acrescentei dizendo de onde veio.

#### O conserto, em 03/10/2026: a chave do Asaas saiu do código (SEGREDOS)

`ISBase\Util\Asaas` passou a receber a url e a chave no construtor, e quem as
passa é a fábrica do `FranquiaService`, lendo a seção `asaas` do `local.php`
(`api_key`, e `url`, que vazia vale a de produção, `https://api.asaas.com/v3/`).
As duas rotinas de faturas constroem o cliente num lugar só,
`FranquiaService::asaas()`. **Sem chave, a construção lança** -- antes de
qualquer `curl`, com a mensagem dizendo qual chave falta. Era a última das oito
ainda escrita no código: o portão de segredos, agora sem diferenciar maiúscula,
não acha outra.

**O efeito em cada ambiente:** a máquina de desenvolvimento, o CI e o ambiente
de teste deixaram de falar com a conta de produção do Asaas -- nenhum
`local.php` deles tem a chave, e nenhum deve ter. Em produção, o `local.php`
precisa dela, e é a **rotacionada**: o passo 3 do dia 0 e o passo 7 do corte
dizem como, e o `verificar-local-php.php` a acusa como `grave` quando falta.

**Provado:** `SegredosForaDoCodigoTest`, nos dois bancos -- a construção sem
chave (vazia e só de espaços) lança; a fábrica passa a url e a chave da
configuração; a url vazia vale a de produção; sem a seção, o serviço não
constrói o cliente. Três mutantes, todos mortos: a construção sem a exceção, a
fábrica sem a seção e o serviço ignorando a url. **As rotinas de faturas não
foram executadas em caso nenhum** -- a prova para no `asaas()`, e é por isso
que ela não alcança o laço das rotinas.

**O que continua:** o literal está no histórico do git e vale no Asaas até a
rotação, que é sua (a linha 7 do passo 5 do dia 0). E os dois portões de
segredo não viam esta chave -- o nome em camelCase escapava, e o valor começa
com `$`, que o padrão excluía de qualquer aspa. O conserto do portão está no D4;
desde a revisão de 03/10 ele procura esta chave também **pelo valor** (o prefixo
`$aact_`), em qualquer forma. E o token das rotinas, que esta tabela nunca
listou, entrou na rotação do dia 0 como **linha 9** (ver o D4).

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 2): a opção (a), "Deixar como está até
a migração" -- o `VisagismoController` fica com o valor escrito, e na rotação o token das rotinas ganha
um valor novo: o valor escrito passa a abrir só o visagismo. A rotação é a linha 9 do passo 5 do dia 0,
e no corte ela acontece nos passos 7 e 9 da janela (o 7 sorteia o valor no `local.php`, o 9 refaz o
crontab com ele; a pergunta 5, no D331). Nada muda no código; o que este débito espera continua sendo a
rotação das credenciais na janela.

#### O OneSignal, em 10/10/2026 (PUSH-FCM-ENVIO)

O dono decidiu trocar o OneSignal pelo Firebase (a opção A de `docs/notificacoes-push-comparativo.md`), e a ordem
de ignorar o OneSignal cedeu para esta migração. Duas linhas da tabela mudam:

- **O app mobile.** O envio (`ISBase\Push\OneSignalEnviador`) lê `onesignal.app.app_api_key`, a App API Key nova:
  com ela no `local.php`, o pedido vai a `api.onesignal.com/notifications?c=push` com o cabeçalho `Key`; sem ela,
  segue a legada, como hoje. O OneSignal não cria mais chave legada, então a rotação desta linha é **criar a App API
  Key, pô-la no `local.php` de produção e revogar a legada**, e não trocar uma legada por outra.
- **O web push** saiu do código (era código morto, e o `verificar-local-php.php` não o pede mais): a chave dele não
  é lida por nada, e o certo é **revogá-la**, e não rotacioná-la.

**Provado** no `EnvioDePushTest`: com a chave nova, o endereço e o cabeçalho novos e o mesmo corpo; sem ela (ou só
de espaços), o pedido byte a byte da classe velha. **Não provado contra o OneSignal de verdade**: nenhum teste fala
com ele, e a API nova só foi lida na documentação. O que segue do dono: criar a chave nova, revogar as duas antigas
e, depois, apagar o backup do D457. A migração inteira está no D461.

#### A conta de serviço do Firebase de teste, em 11/10/2026 (PUSH-TERCEIRA-RODADA: a resposta 74)

O servidor de teste foi preparado para falar com o Google, com um projeto Firebase **só de teste** (o D461). A
credencial nova é a conta de serviço desse projeto, e **é do dono**: ela vai do console do Firebase direto ao BVIP,
em `/etc/franquiabv-teste/firebase-conta-de-servico.json` (0640, dono root, grupo do php-fpm), montada só para
leitura no app pelo `docker-compose.teste-firebase.yml`, e o `local.php` do teste guarda só o caminho. Nada dela entra
no repositório, e o `ContaDeServico` recusa o arquivo de dentro dele. Não é uma das oito desta tabela: é de um projeto
de teste, que não manda a cliente de verdade, e se vazar o certo é revogá-la no console e gerar outra. A conta de
serviço do projeto da produção, quando vier, segue o mesmo caminho em `/etc/franquiabv/` (o "Como ligar" do D461).

### D4 — Autenticação fraca nos endpoints de cron — CORRIGIDO em 03/10/2026 no código: o token saiu do código, a recusa é 401 e o token vale pelo cabeçalho; a produção recebe na migração final, e o crontab passar ao cabeçalho é pergunta do lote (D331) — PARCIAL, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: a escolha do dono sobre as três unidadeVendas*, que recusam com 200; o cron.token no local.php no corte; o crontab no cabeçalho é o D331.
>
> **06/10/2026:** o dono respondeu (lote de perguntas, perguntas 5 e 6): as três `unidadeVendas*` ficam como estão até se saber quem as chama (a 6), e o crontab passa ao cabeçalho no passo 9 do corte (a 5, o D331). Falta: o `cron.token` no `local.php` e o passo 9, na janela; e a conta de quem chama as `unidadeVendas*`, que a resposta 6 pressupõe e nenhum roteiro faz (pergunta no lote) -- ver o fim deste registro.
>
> **08/10/2026:** a conta de quem chama as `unidadeVendas*` existe (pergunta 33 da página do lote: `scripts/medir-chamadas-unidade-vendas.sh`, o 9 de "O que hoje bloqueia" do procedimento de corte). Falta: rodá-la no dia 0 e levar o número ao dono, o `cron.token` no `local.php` e o passo 9 -- ver o fim deste registro.

*Corrigido em 16/08/2026.* O registro anterior dizia que estas rotas não tinham
proteção nenhuma. **Estava errado** — a conclusão vinha de as rotas responderem
200 sem credencial, mas o 200 é a resposta fixa `{"sucesso": true}`; o trabalho
em si só executa com a credencial correta. A auditoria das 29 ações mostrou:

| Ações | Proteção |
|---|---|
| 26 rotas `/api/cronjobs/*` | Token estático na query string, comparado com `$tokenPadrao` |
| 3 rotas `/api/unidade/vendas*` | `hash` por unidade, conferido contra `unidades.hash` |

Ou seja, há autenticação. O que resta é a **fragilidade** dela:

1. O token está **fixo no código** (`CronJobController:18`) e é o mesmo do
   `AplicativoController`. Não é rotacionável sem alterar o código.
2. Viaja na **query string**, então aparece nos logs de acesso do nginx, em
   proxies e no histórico de quem já o usou.
3. Sem token, a rota devolve `{"sucesso": true}` — resposta de sucesso para uma
   requisição que não fez nada. Um cron mal configurado silenciosamente nunca
   executa, e ninguém percebe.

**Ao resolver:** mover o token para `local.php`, enviá-lo por cabeçalho em vez de
query string, e responder 401 quando ausente. O item 3 é o mais barato e o de
maior retorno — é o que impede um cron quebrado de passar despercebido.

#### Medido em 30/08/2026, e o item 3 tem data

São **30 ações** hoje, não 29: **27 respondem `{"sucesso": true}` com o token
errado**, e 3 se autenticam por `hash` da unidade.

**Nenhuma das 27 recusa.** Algumas por deixarem o `return` fora do `if`; outras
de forma explícita, como a de royalties:

```php
if ($token != $this->tokenPadrao) {
    return new JsonModel(['sucesso' => true]);
}
```

**As 3 exceções são as únicas honestas, e pelo mecanismo mais tosco:**
`unidadeVendas`, `V2` e `V3` terminam em `die("Acesso negado.")`.

**O item 3 deixou de ser hipotético, porque a rotação de credenciais está
planejada** — é o passo 5 da remediação dia 0. Rotacionar o token sem atualizar o
crontab **para as 27 rotinas de uma vez** — royalties, dashboards, NPS, envios de
marketing — e todas continuam respondendo sucesso. Qualquer monitoramento que
olhe a resposta HTTP vê verde para sempre.

#### O defeito se espalhou, e pela nossa mão

A auditoria de 16/08 contou 29 ações. A 30ª é `conveniosAtualizarVencimentoAction`,
criada por **este projeto** em 28/08 (`50cde07`) — e ela **copiou o padrão das
vizinhas**, porque copiar a vizinha é o que se faz ao acrescentar uma rotina.

Por isso existe `scripts/conferir-resposta-cron.php`. Ele **não exige o conserto
das 27**: consertar é mudar o contrato de 27 rotas em produção de uma vez, e
isso é decisão de quem opera. As 27 estão numa linha de base; **uma ação nova com
o defeito faz o roteiro sair com código 1**.

Provado que acusa, em 30/08: uma ação forjada com o padrão apareceu como "nova",
e o arquivo foi restaurado em seguida. O roteiro também avisa quando uma das 27
é **consertada**, para que saia da linha de base — a lista encolhe por conserto,
nunca por conveniência.

#### O conserto, em 03/10/2026: o token saiu do código (SEGREDOS) -- os itens 2 e 3 restam

O item 1 do "Ao resolver" está feito: a fábrica lê `cron.token` do `local.php` e
o passa ao controlador, e o literal saiu do construtor. **E a decisão mora num
método só**, `CronJobController::tokenConfere($token)`, chamado pelas 26 ações
-- eram 26 cópias da mesma comparação.

**O vazio recusa antes de comparar, e não é detalhe.** `hash_equals('', (string)
null)` é verdadeiro: com o token vindo da configuração, um `local.php` sem
`cron.token` abriria as 26 rotinas para quem chamasse **sem token nenhum** -- um
defeito que o literal no código tornava impossível e que a mudança criaria. A
recusa responde o que a do token errado já respondia, `{"sucesso": true}`.
Responder 401 (o item 3) e mandar o token por cabeçalho (o item 2) são o próximo
trabalho: mudam o contrato com o crontab.

**O que isso exige da produção:** `cron.token` com o mesmo valor que o crontab
manda -- o de hoje, ou o novo, se a **linha 9 do passo 5 do dia 0** já o
rotacionou (crontab e `local.php` no mesmo passo) --, ou as 26 param **em
silêncio**. O valor de hoje está no repositório (no histórico do controlador e
escrito no `VisagismoController`), e é por isso que ele entrou na rotação.
Está no passo 3 do dia 0 e no passo 7 do corte, e o `verificar-local-php.php` acusa a ausência como `grave` (junto com
`asaas.api_key` e as três do Yungas, do D89, que ele também não conhecia --
falsificado com as cinco vazias, que aparecem, e preenchidas, que não).

**Provado:** `SegredosForaDoCodigoTest`, nos dois bancos (11 casos, 349
asserções; com os vizinhos que tocam o `CronJobService`, o controlador e o
`FranquiaService` -- doze arquivos --, 224 casos, 2.344 asserções e 1 pulado
declarado, iguais nos dois, rodados de novo depois da queda da máquina e da
revisão). O
controlador nasce pela **fábrica de verdade**, com um contêiner dublê cujo
serviço e gerenciador são espiões: todas as ações do controlador -- menos as três
do `hash`, lidas do código -- recusam com `cron.token` vazio e sem a seção, com
o pedido sem token e com token vazio; o token configurado abre a rotina, e outro
valor não abre. **Nenhuma rotina de verdade roda, nem sob mutante**, e por isso
os casos podem despachar as 26. Quatro mutantes, todos mortos: sem a recusa do
vazio (os dois casos de recusa vermelhos), a fábrica sem o token, a comparação
que sempre confere, e uma ação que volta a comparar por conta própria.

**A revisão de 03/10 achou duas lacunas, e as fechou.** (1) Um **valor-padrão**
na fábrica -- `['cron']['token'] ?? '<valor>'`, ou `?: '<valor>'` -- passava
pelos casos: o pedido deles é nulo, vazio ou o token de teste, todos diferentes
do valor-padrão, e todos recusavam igual. É a regressão mais provável (as 26
paradas em silêncio, e alguém "conserta" a fábrica com o token antigo, que o
`VisagismoController` ainda traz). O caso novo pergunta o que o controlador
**guardou** sem a seção, por reflexão: a cadeia vazia, e nada mais. (2) O token
**só de espaços** abria as 26 para `?token=%20`, e o verificador o dava como
preenchido -- o `Asaas` já recusava a chave só de espaços. Agora o
`tokenConfere` e o `verificar-local-php.php` também recusam, e um caso roda o
verificador de verdade sobre um `local.php` escrito pelo teste: as cinco chaves
(Asaas, cron e as três do Yungas) aparecem como `grave`, o valor só de espaços
como ausente, e o valor nunca é impresso. Cinco mutantes, todos mortos, cada um
só pelo caso novo: a fábrica com `?? '<valor inventado>'` e com `?:`; o
`tokenConfere` sem o `trim`; o verificador sem o `trim`; o verificador sem o
`cron.token` na lista.

**Os dois roteiros que dependiam da forma antiga.** `scripts/medir-cron-no-postgres.php`
tirava o token do controlador por expressão regular sobre o literal; agora lê
`CRON_TOKEN` ou o `cron.token` do `local.php`, e o CI sorteia um token só do job
e o grava no `local.php` dele (o passo simulado numa cópia: o token entra uma
vez, e os dois hosts continuam trocados). Numa árvore de rascunho com um
`cron.token`, contra uma porta morta, o medidor achou o token e seguiu para as 20
rotinas; sem o token, sai com 2. `scripts/conferir-resposta-cron.php` procurava
`tokenPadrao` no corpo de cada ação -- com a chamada nova, **leu as 29 como
honestas e saiu com 0, calado**, porque a ação conhecida que caía naquele ramo
não entrava nas consertadas. Agora ele reconhece as duas formas, e a conhecida
que deixar de mencionar o token sai com 1. Falsificado numa cópia: só com
`tokenPadrao` na primeira busca, sai 1 apontando as 26; sem o ramo novo também,
sai 0 com "0 mentem" -- a cegueira de antes; só com `tokenPadrao` na condição,
sai 2; com uma ação forjada com o defeito, sai 1 com o nome dela. A saída de
hoje é idêntica, byte a byte, à de antes do conserto.

**E a revisão achou mais dois.** O medidor das rotinas, com o token vindo do
ambiente, **não distinguia a recusa da execução**: com um `CRON_TOKEN` diferente
do `cron.token` que a aplicação lê, as 20 respondiam `{"sucesso": true}` sem
rodar, e ele saía 0 com "Nenhuma rotina quebrou". Agora sai 2 quando os dois
divergem, e sai 2 quando **nenhuma** rotina registra nada. E o
`medir-telas-no-postgres.sh` -- logo o `comparar-relatorios-nos-dois-bancos.sh`,
que o chama duas vezes -- **morria no passo do cron**: nenhum dos dois
`local.php` que ele monta tem o token, o medidor saía 2 e o `set -e` derrubava a
corrida antes de `/adm/api` e dos relatórios. Agora ele sorteia um token só da
corrida (`scripts/sortear-token-cron.sh`) e o grava no `local.php` que o `trap`
devolve no fim. **Os dois foram provados contra um dublê**, dentro do
contêiner, que responde como o controlador -- `{"sucesso": true}` rodando ou
recusando, e uma linha no log só quando o token confere --, porque o medidor
das rotinas chama `relatoriosRoyaltiesAtualizar`, que não se roda aqui: com o
medidor de antes, o token divergente e o configurado vazio saíam 0 com "0 de
20"; com o de agora, saem 2, e o token certo sai 0 com "20 de 20". Cada
conferência desligada sozinha deixa passar o seu cenário. Com o `local.php` como
o medir-telas o monta (dos dois jeitos), o medidor sai 2 ("NAO ACHEI O
TOKEN"); depois do sorteio, `php -l` passa, o resto do array fica igual, e ele
roda as 20 e sai 0.

#### O portão de segredos não via nenhuma das duas, e agora vê (SEGREDOS)

Medido com os dois literais no HEAD: o passo do CI e o `pre-push` saíam com
**0**. O nome `apiKey` não casa `api_?key` sem ignorar maiúscula, o
`tokenPadrao` não estava na lista, e o valor do Asaas começa com `$`, que o
padrão excluía de qualquer aspa. E as duas cópias já tinham divergido: o
conserto do **D89** nunca chegou ao `pre-push` -- o único dos dois rodando, com o
CI parado desde 13/09.

O padrão passou a morar em `scripts/conferir-segredos.sh`, que o CI, o
`pre-push` (sobre cada commit que o push leva) e o `conferir-tudo.sh` chamam. Ignora maiúscula,
conhece `tokenPadrao` e a chave `token` entre aspas (a do `cron.token`), aceita
`$` dentro de aspa simples, lê também os arquivos ainda não adicionados, e
**imprime só `arquivo:linha`** -- o passo antigo imprimia a linha inteira no log
do CI, isto é, a credencial que acabava de achar.

**Medido antes de ligar:** 7 linhas no HEAD -- os 3 literais (Asaas, cron e
visagismo) e 4 falsos positivos, que viraram 3 exceções declaradas com o
motivo: o `PASSWORD` do psql em `postgres/papeis.sql.modelo` (uma variável), um
ternário de mensagem em `scripts/ensaiar-sessao-do-erp.php`, e a constante
`SENHA` inventada de dois testes (a convenção da casa). O `token` solto ficou de
fora: acrescentava duas constantes de teste.

**A quarta exceção é a do visagismo, e é a que importa.** O
`VisagismoController` fica como está, porque o visagismo é futuro por decisão
sua -- e **o valor do token dele é o mesmo do token das rotinas**. O token saiu
do `CronJobController`, mas o valor continua no código por ali, e no histórico
do git. A rotação não o tira do repositório: faz esse valor deixar de abrir as
rotinas -- e por isso o token entrou na rotação do dia 0 como **linha 9**, que
nenhuma lista tinha. O que fazer com o visagismo ficou como pergunta no lote de
03/10.

**Pelo valor, e não só pelo nome (revisão de 03/10).** O padrão pega a
atribuição a um nome conhecido. Medido com linhas inventadas, deixava passar as
formas que uma regressão de verdade usaria: o valor-padrão na fábrica
(`?? '...'`, `?: '...'`), o literal como argumento (`new Asaas('...')`,
`hash_equals('...', $t)`), uma constante de outro nome, o cabeçalho escrito
inline, `.env`, JSON e YAML. Para os dois segredos que estavam no código o
valor é conhecido, e o portão passou a procurá-los por ele: a chave do Asaas
pelo prefixo fixo (`$aact_` seguido de 20 ou mais caracteres, que não casa quem
só menciona o prefixo), e o token das rotinas pelo valor lido **em memória** do
`VisagismoController` da mesma árvore ou revisão, que não pode aparecer em
nenhum outro arquivo (`git grep -F`). Sobre as 13 formas, num repositório de
rascunho com valores inventados: só pelo nome, 0 de 13; com os dois, 13 de 13;
sem o prefixo do Asaas, escapam as 8 do Asaas; sem a busca pelo valor, escapam
as 5 do token. Na árvore de hoje, nenhuma linha a mais; sobre o HEAD, as
mesmas duas. O que nenhum portão textual pega continua fora: um segredo NOVO,
de valor desconhecido, num nome que não está na lista.

**O `pre-push` passou a conferir o que o push leva, e não o HEAD.** Um commit
que põe o literal e outro que o tira iam juntos, o HEAD estava limpo, e o
segredo chegava ao histórico do remoto; o mesmo com `git push origin
<outro-ramo>`. Agora ele lê do git as referências empurradas e confere **cada**
commit que o remoto ainda não tem (cerca de um segundo por commit); chamado a
mão, sem stdin, confere o HEAD como antes. Falsificado num repositório de
rascunho: o literal posto e tirado, o ramo novo com os dois commits e o
um `.txt` em `public/` posto e tirado passavam com o hook de antes e bloqueiam com o de
agora, citando o commit; nada novo, apagar ramo e um commit limpo passam.

**Falsificado:** com cada literal devolvido ao arquivo novo, o roteiro sai 1
apontando o arquivo, e 0 depois de restaurar; sobre o HEAD de hoje, sai 1 com
os dois. Cada peça arrancada numa cópia do roteiro deixa passar o que ela pega:
sem `-i`, o HEAD sai 0; sem `token_?padrao`, escapa o cron; sem o `$` na aspa
simples, escapa o Asaas; sem o colchete do D89, escapa uma atribuição a índice
forjada num arquivo de rascunho; sem cada exceção, o arquivo dela aparece; e a
exceção que não perdoa nada é avisada. O `pre-push` chamado a mão sobre o HEAD
de hoje **bloqueia** -- o HEAD ainda tem os literais, e a busca pelo valor
aponta o `CronJobController` também --; o push do commit deste trabalho leva
só ele, que não os tem.

**O que ficou para depois da cópia do teste:** o `conferir` do deploy de teste
não exige as duas chaves vazias, e três comentários do ambiente de teste ainda
as dão como escritas no código. Os arquivos estão congelados enquanto a cópia
sobe no BVIP: é o **D330**.

#### O conserto, em 03/10/2026: a recusa é 401, e o token vale pelo cabeçalho (itens 2 e 3)

**O item 3.** Toda recusa -- token ausente, token que não confere, `cron.token`
vazio ou só de espaços no servidor -- responde **401** em JSON, na forma da
convenção (`{"erro": {"codigo": "nao_autenticado", "mensagem": ...}}`, o
`erroAutenticacao` do `RespostaApi`, o mesmo do `/api/v4`), com
`WWW-Authenticate: Bearer realm="rotinas"`. **Uma recusa só para as três
causas:** dizer qual seria contar a quem não apresentou credencial como o
servidor está configurado; o `verificar-local-php.php` já acusa o vazio. **O
sucesso não mudou** -- `{"sucesso": true}` com 200, ou o que a rotina devolve.
Em cada uma das 26 ações a guarda é a primeira coisa
(`if (!$this->tokenConfere()) { return $this->tokenRecusado(); }`), e a rotina
só vem depois -- antes, 25 delas deixavam o `return` de sucesso fora do `if`.

**O item 2.** O token vale por `Authorization: Bearer` -- o cabeçalho que o
`/api/v4` já lê e que a convenção decidiu para quem fala por token, e não um
nome inventado -- e **continua valendo por `?token=`**, que é o que as 25
linhas do crontab de produção mandam hoje: tirar a query junto pararia as
rotinas no corte. **Uma credencial por pedido:** com `Bearer`, a query nem é
lida, e `Bearer` errado com a query certa recusa; outro esquema no cabeçalho
(`Basic`, de um proxy) não é credencial das rotinas, e a query vale. **O
cabeçalho ilegível recusa com 401**: o `getHeader` do Laminas lança exceção para
byte de controle ou fora do ASCII (medido no `Request` puro e no do ambiente), e
sem a captura seria 500. A query que não é cadeia (`?token[]=`) é ausência.

**O portão (`scripts/conferir-resposta-cron.php`) perdeu a linha de base** e
passou a exigir a forma: a guarda como primeira coisa, o ramo da recusa só com
`return $this->tokenRecusado();`, nada de comparar `tokenPadrao` no corpo, e o
`tokenRecusado` começando pelo `erroAutenticacao`. Ação que não confere token
nem hash **reprova** -- até hoje ela passava como "honesta", e era justamente a
rotina nova que esquecesse a guarda. Lê o código **sem comentários** (pelo
tokenizador). Falsificado numa árvore de rascunho: o controlador de antes sai 1
com as 26 e o "a recusa não é 401"; cada um destes sai 1 com a ação dele -- a
guarda arrancada, arrancada com um comentário que a cita, a recusa com sucesso,
a rotina antes da guarda, a comparação por conta própria, a ação nova sem
guarda --; o `tokenRecusado` devolvendo sucesso, antes ou no lugar do
`erroAutenticacao`, sai 1; o arquivo vazio sai 1. E a leitura sem comentários é
carregante: uma ação sem guarda com um comentário citando `unidadeAutorizada`
sai 1 com ela e sai **0** com o portão que lê comentário.

**O medidor das rotinas** (`scripts/medir-cron-no-postgres.php`) manda o token no
cabeçalho -- na URL ele ia para o log de acesso, uma linha por rotina -- e lê o
401: na primeira recusa ele para e sai 2 dizendo que a aplicação recusou o
token. Provado contra um dublê da aplicação dentro do contêiner, que responde
como o controlador (401, ou sucesso e uma linha no log): token certo, 20 de 20 e
saída 0, com **0 de 20** pedidos com o token na URL (o de antes: 20 de 20); a
aplicação com outro token, ou sem `cron.token`, sai 2 no **primeiro** pedido (o
de antes fazia os 20 e saía 2 por "nenhuma registrou nada"); e uma rotina
recusada no meio das outras sai 2 -- **o de antes saía 0** com "19 de 20", e o
de agora sem a leitura do 401 também. O valor do token não aparece na saída em
cenário nenhum.

**E os irmãos.** `scripts/coletar-producao.sh` redigia só o `?token=` do
crontab: uma linha com `-H "Authorization: Bearer ..."` sairia na coleta em
claro (medido com valores inventados: o sed de antes deixa o valor visível; o
de agora redige os 4 formatos e deixa intacta a linha sem segredo). A
`rotas-publicas.php` dizia das 26 "token fixo no código -- D5"; agora diz o que
as guarda. O `verificar-local-php.php` dizia que, sem `cron.token`, as rotinas
"respondem sucesso".

**Provado:** `SegredosForaDoCodigoTest`, **16 casos e 5.016 asserções, iguais nos
dois bancos** (eram 11): as 26 ações, despachadas pela fábrica de verdade contra
espiões, recusam com 401 em dez formas de não trazer o token certo e com o
configurado vazio, ausente ou só de espaços, sem rodar nada; o `Bearer` certo
abre (também `bearer` e com espaços a mais), tem precedência sobre a query, e a
query certa segue abrindo -- com `{"sucesso": true}` como sempre. **As que
cobram ou enviam vêm por último** em toda volta pelas ações: sob um mutante que
abra todas, o caso para na primeira, que é inofensiva. **Oito mutantes, todos
mortos, cada um aplicado sozinho e restaurado** (o controlador conferido byte a
byte com a cópia limpa depois de cada um): a guarda arrancada de uma ação; a
recusa de volta a `{"sucesso": true}`; a query ganhando do cabeçalho; sem a
captura do cabeçalho ilegível (o caso dele estoura com a exceção do Laminas);
o cabeçalho não lido; o token nu no cabeçalho aceito; sem o
`WWW-Authenticate`; a comparação que sempre confere -- nenhuma das que cobram ou
enviam apareceu na saída de mutante nenhum. **Por HTTP, na aplicação de pé**, só
pedidos sem token ou com token errado, e só contra o controlador limpo (conferido
igual à cópia limpa, com as 26 guardas, antes dos pedidos). **O que os tornava
seguros era isso, e não a falta de `cron.token` no `local.php` de
desenvolvimento** -- esta frase dizia o contrário até a revisão de 03/10/2026, e
estava invertida: com a guarda arrancada a ação roda sem consultar token nenhum;
e sem a recusa do vazio no `tokenConfere` é justamente a falta de `cron.token`
que abre as 26 ao pedido sem token (`hash_equals('', '')` é verdadeiro). Sem
token, com a query errada,
com `Bearer` errado e com o cabeçalho ilegível, 401 em JSON com o
`WWW-Authenticate`, também numa rota-filha (`/api/cupons/cuponsValidarVencimento`);
o `log_scripts.txt` não ganhou linha; e `/api/unidade/vendas` sem hash segue
respondendo "Acesso negado.". Que o nginx desta pilha passa o `Authorization` ao
PHP já era provado pelo `ApiV4Test`; o `php -S` do CI também passa (medido).

**O que isto exige da produção, e o que fica:** o crontab tem de mandar o token
certo -- o mesmo `cron.token` do `local.php` --, ou as rotinas **param com 401**.
Hoje ele o manda na query, e a query continua valendo: **nada no crontab precisa
mudar para o corte**. Está no passo 7 do dia 0 e nos passos 7 e 9 do corte. O
token segue na URL -- e no log do nginx -- até alguém trocar as 25 linhas para o
cabeçalho; e o crontab chama por `curl -s` e não lê a resposta (a linha modelo
do `crontab-a-aplicar.md` a joga em `/dev/null`), então o 401 só aparece no log
de acesso. As duas coisas são da produção e suas: o **D331**, com
a pergunta no lote. As três `unidadeVendas*` (o `die("Acesso negado.")` com 200)
ficaram como estavam: são o contrato com quem consome o faturamento (D9), e
mudá-las é escolha sua -- também no lote.

#### A revisão do conserto, em 03/10/2026: sete achados, todos consertados

Uma revisão adversarial do conserto acima achou sete coisas verdadeiras (nove
relatos, dois em dobro). Nenhuma abria rotina a quem não tinha o token; duas
eram a **conferência do corte** dizendo "tudo bem" com as rotinas paradas.

- **A conta de 401 no log, que o passo 9 do corte e o passo 7 do dia 0 usavam
  como prova, dava 0 com as 25 rotinas recusadas.** Lia só
  `/var/log/nginx/access.log`, e o site tem log próprio por vhost (o D36: a
  coleta que olhou só `access.log*` achou zero pedidos a `/api/versao/app`); e
  não tinha controle positivo -- o 0 do sucesso e o 0 do arquivo errado eram o
  mesmo número. Também somava os 401 de antes de um conserto. **Agora** o passo
  9 marca quantas linhas cada `*.log` do nginx tem **antes** de religar o cron
  (`wc -l`, só contagens) e, 10 minutos depois, mostra o código HTTP de cada
  pedido do crontab feito **depois da marca**, em todos os logs: tem de mostrar
  só `" 200 "`, e mais de zero; nada impresso quer dizer "não vi o crontab --
  não siga". O passo 7 do dia 0 aponta para lá, sem segunda cópia. Ensaiado com
  logs inventados: token certo em dois logs de vhost, `3 " 200 "`; errado,
  `2 " 401 "`; sem pedido, nada; a conta antiga dava 0 no `access.log` com o
  token errado no log do vhost.
- **`Authorization: Bearer` vazio (ou `Bearer<TAB>valor`) caía para a query**,
  contra o "uma credencial por pedido" escrito. O `tokenDoPedido` procurava o
  prefixo `Bearer ` com espaço, e o `trim` o comia. **Agora** o esquema é a
  primeira palavra do valor: `Bearer` sozinho é credencial vazia e recusa, e o
  TAB separa como o espaço. Não abria nada a mais (a query certa já abria), mas
  o contrato não era o que o código fazia.
- **O portão `conferir-resposta-cron.php` não via ação fora do molde, e isentava
  quem só CITASSE `unidadeAutorizada`.** O recorte textual perdia `public static
  function`, `final public function`, `function` sem visibilidade, `xAction ()` e
  o recuo de 8 espaços, e o portão saía 0; e a ação copiada da `unidadeVendas`
  sem o `die()` passava como guardada. **Agora** os métodos vêm do tokenizador
  (do `{` ao `}` que o fecha), a leitura é conferida pela contagem textual das
  declarações, "ação" é todo método terminado em `Action` em qualquer caixa, e
  pela unidade só passa quem começa pela guarda inteira. Falsificado com 20
  variantes, o portão recebendo o caminho da variante: o novo sai 1 nas 18
  defeituosas e 0 nos 2 controles; o de antes saía **0 em 9 delas**. O teste
  ganhou a mesma regra na sua exclusão (`GUARDA_DA_UNIDADE`): com a exclusão de
  antes, a ação que só cita o nome passava verde; com a de agora, fica vermelha.
- **A página `/api/docs/cronjobs` só falava do `?token=` e do
  `{"sucesso": true}`** -- as 26 entradas. **Agora** cada uma traz o
  `Authorization: Bearer` e a query como "um dos dois", o exemplo de envio das
  duas formas, e o 401 com `nao_autenticado` no exemplo de retorno; a rota já não
  ensina o token na URL. Um caso novo confere cada entrada **e** que toda rota de
  rotina do roteador (lida do `router` da configuração) tem página.
- **A abertura com o token certo só era provada em 1 das 26 ações.** O caminho de
  sucesso das 26 foi reescrito por roteiro, e uma ação que devolvesse a recusa
  de novo depois da guarda, ou perdesse a chamada ao serviço, passava no teste e
  no portão -- e pararia com 401 na produção, com o token certo. **Agora** um
  caso abre as 22 inofensivas (lidas do código, as quatro que cobram ou enviam
  de fora e nunca despachadas) pela query **e** pelo `Bearer`, e confere que
  cada uma chamou o serviço dela.
- **A frase "o `local.php` de desenvolvimento não tem `cron.token`, então nem a
  guarda caída abriria"**, no parágrafo "Provado" acima, estava invertida -- foi
  reescrita ali mesmo.

**Provado:** `SegredosForaDoCodigoTest`, **18 casos e 6.256 asserções, iguais
nos dois bancos**; com os 10 vizinhos, **225 casos e 8.240 asserções**, iguais
nos dois. **Seis mutantes novos, todos mortos**, cada um aplicado sozinho e
restaurado (controlador e página conferidos byte a byte com as cópias limpas):
o esquema de volta ao prefixo `Bearer ` (morre no "Bearer vazio com a query
certa", na primeira ação, que é inofensiva, e no `Bearer<TAB>`); uma ação que
recusa de novo depois da guarda; uma que perde a chamada ao serviço; a página
de volta ao `?token=` só; uma rotina sem página; e a ação nova que só cita
`unidadeAutorizada` (e, com a exclusão de antes no teste, esse mesmo mutante
passa verde). Por HTTP, só sem token ou com token errado, contra o controlador
limpo: `Bearer` sem credencial com a query errada e `Bearer<TAB>` errado dão
401 em JSON com o `WWW-Authenticate`; o `log_scripts.txt` não ganhou linha. A
página das rotinas fica atrás do login do ERP neste ambiente: foi renderizada em
processo, com o template de verdade.

**Achado de passagem, fora do D4:** o mesmo `access.log*` está na apuração do
incidente LGPD (passo 2 do dia 0 e `scripts/remediacao-dia0.sh`) -- o **D332**.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, perguntas 2, 5 e 6): na 2, a opção (a), "Deixar
como está até a migração" -- o visagismo guarda o valor escrito, e a rotação (a linha 9 do passo 5 do
dia 0) faz esse valor deixar de abrir as rotinas; na 5, a opção (b), "curl -fsS com o token no
cabeçalho, e MAILTO" -- é o D331, e já está no passo 9 do corte; na 6, a opção (a), "Não mexer até o
inventário do D9 dizer quem as chama" -- as três `unidadeVendas*` continuam recusando com `Acesso
negado.` e 200. **Uma ressalva sobre a 6:** o inventário do D9 era o das consultas do Power BI, e morreu
com ele em 09/09/2026; o consumidor das três rotas por `hash` é desconhecido (`docs/f3-relatorios.md`),
e só o log de acesso da produção diz quem as chama -- conta que nenhum roteiro do repositório faz
(procurado por `unidade/vendas` em `scripts/` e `docs/`). Foi ao lote como pergunta, no item
DECISOES-NO-REGISTRO-E-NO-CORTE. O que resta deste débito: o `cron.token` no `local.php` da janela (o
passo 7) e as três `unidadeVendas*`.

**Decidido pelo dono 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; a sua 6 volta a
você com o número." Escrita em 08/10/2026: `scripts/medir-chamadas-unidade-vendas.sh` lê os logs de
acesso do nginx (os arquivos dados, `.gz` também, ou a entrada padrão) e conta, por dia, os pedidos a
`/api/unidade/vendas`, `vendasV2` e `vendasV3` -- de qualquer método, com a query cortada antes de
qualquer conta, e também com o `/index.php/` na frente e com a URL inteira no pedido, que chegam à
exportação --, os "parecidos" (a barra no fim, a barra dobrada, outra caixa, outra versão, contados e
não impressos) e
o controle `/api/versao/app` (o D36): com o controle em zero, sai com 2 e diz que não mediu, porque o zero
das três num log que não é o do site não quer dizer nada. Não imprime IP, `hash`, query string,
user-agent nem referer. É o 9 de "O que hoje bloqueia" do `docs/procedimento-de-corte.md`: no dia 0, pelo
`ssh` e sem pôr arquivo no servidor (o roteiro vai pela entrada do `sh -s`), sobre todos os `*.log` e
`*.log.*` de `/var/log/nginx/`, com a tabela do que fazer com o número -- ele vem para este registro e
para o D9, e a pergunta 6 volta ao dono.

**Provado** com um log inventado (IPs dos blocos de documentação, um `hash` que diz que é inventado) por
`sh scripts/ensaiar-medir-chamadas-unidade-vendas.sh`, no Git Bash (bash e gawk) e no contêiner da
aplicação (dash e mawk): as contagens de quatro dias em dois arquivos, um deles `.gz`, e pela entrada
padrão; ficam de fora a rota dentro do referer, do user-agent e da query de outra rota, a `vendedores`, a
linha do log de erro e o pedido "-"; o dia sem o controle sai marcado; nenhum IP, `hash`, user-agent ou
valor de query na saída; saída 2 sem o controle, só com o log de erro, com o glob sem arquivo e com um
`.gz` quebrado; e o procedimento ainda manda rodar a conta. A forma do `ssh` (o roteiro pela entrada do
`sh -s`, os logs pelos argumentos, os girados e o `.gz` junto) rodou no contêiner, com um log inventado.
O ensaio roda no `conferir-tudo.sh` e no CI.

**A revisão de 08/10/2026 achou dois buracos, e os dois estão fechados.** (1) O `/index.php/` na frente
e a URL inteira no pedido (`GET http://<host>/api/unidade/vendas`, a forma absoluta, que o nginx aceita
e grava assim) iam a "parecidos": as três saíam em zero com alguém chamando, e a pergunta 6 voltaria ao
dono com um zero falso. Medido no nginx daqui (o mesmo `docker/nginx/default.conf`), só com GET sem `id`
e sem `hash` -- o `unidadeAutorizada` devolve nulo antes de qualquer consulta: `/index.php/api/unidade/vendas`,
`/index.php/api/unidade/vendasV2`, `/index.php/api/versao/app` e a forma absoluta da `vendasV3` dão 200
(o "Acesso negado." da action: a exportação rodou); `/api/unidade/vendas/`, `//api/unidade/vendas` e
`/index.php/api/unidade/vendas/` dão 404. A conta tira o esquema e o host da forma absoluta e o
`/index.php` da frente antes de comparar, e o controle também. (2) O ensaio dizia provar que "o
procedimento manda rodar a conta" procurando o nome do roteiro na seção inteira, e a célula da tabela o
satisfazia: apagar o bloco do `ssh` não deixava nada vermelho. Agora ele tira a linha do `ssh` dos blocos
`bash` da seção, sem comentário e com a continuação junta, e a RODA: o que vai entre aspas para a
produção, num `sh -c`, com `/var/log/nginx/` trocado por um diretório com o log inventado, um girado em
texto e um `.gz`, tem de dar o mesmo que a conta chamada direto sobre os três (8 do controle, 5, 2 e 2
das três, 5 parecidos). **Falsificado**, um mutante por vez em cópias (os arquivos de verdade não
mudaram): a conta sem as duas formas, sem só o `/index.php/` e sem só a forma absoluta (5 vermelhos cada:
o dia 07, o total, a linha AS TRES, a entrada padrão e a conta direta do diretório); o procedimento sem o bloco do `ssh`,
com o `sh -s` sem os logs, sem os girados (`*.log.*`), e com o comando só num comentário HTML (1 cada);
e o controle, o procedimento sem a linha 9 da tabela e com o comando, verde. Verde no Git Bash e no
contêiner (dash e mawk). **Não provado:** o log da produção, que só o dia 0 lê -- os
nomes dos arquivos e o formato `combined` são deduzidos (o D332) --, e quem é o chamador, que a conta não
diz de propósito. O que resta deste débito: o `cron.token` no `local.php` da janela (o passo 7), e as
três `unidadeVendas*`, que esperam o número.

---

## Adiados para a migração do aplicativo móvel

### D5 — Token fixo da API do aplicativo

#### O parque de aplicativos, medido em 19/08/2026

Em 14 dias de log, **236.785 requisições** à API:

| Cliente | Requisições | O que é |
|---|---:|---|
| CFNetwork (várias versões) | ~110.000 | iOS |
| okhttp/4.12.0 | 24.582 | Android nativo |
| Dart/3.5 | 6.232 | Flutter |
| Mozilla/5.0 | 4.080 | navegador |

O iOS domina, com cerca de 78% das chamadas. E aparecem **três pilhas de cliente
distintas** — `okhttp` é Android nativo, `Dart` é Flutter. Ou existem dois
aplicativos Android em campo, ou há uma migração de tecnologia em curso que não
está documentada neste repositório. **Vale esclarecer**, porque muda o que
significa "atualizar o aplicativo".

#### O User-Agent NÃO diz qual versão do app está em campo

Correção de uma expectativa minha. `CFNetwork/3860.600.12`, `okhttp/4.12.0` e
`Dart/3.5` são versões da **biblioteca HTTP** — do iOS, do Android e do Flutter.
Dizem o sistema operacional, não a geração do aplicativo.

Quem responde isso são as **rotas versionadas**, que existem por causa deste
próprio débito:

```
/api/agendas/agendar        <- geração mais antiga
/api/v2/agendas/agendar
/api/v3/agendas/agendar     <- mais recente
/api/v2/agendas/getAgenda
/api/v2/agendas/getUnidade/:id
```

Quem chama `/api/v3/...` roda a geração 3; quem chama a rota sem prefixo roda a
mais antiga. O `coletar-producao.sh` passa a contar cada uma — **é essa contagem
que decide qual contrato pode ser aposentado**, e ela ainda não foi coletada.

#### Hipótese em aberto sobre as três pilhas

Levantada pelo responsável em 19/08: seria versão antiga do aplicativo ainda
chamando, nunca bloqueada por estar instalada em celulares velhos.

**Dois números complicam essa leitura, sem derrubá-la.** As versões de biblioteca
são recentes — `Dart/3.5` é de meados de 2024 e `okhttp/4.12.0` do fim de 2023 —,
e um aplicativo parado há anos não carregaria nenhuma das duas. E o volume vai no
sentido contrário do esperado: `okhttp` tem 24.582 chamadas contra 6.232 de
`Dart`, ou seja, o suposto antigo com quatro vezes mais tráfego que o novo.

Duas explicações alternativas cabem igualmente bem:

1. **Um aplicativo Flutter só, com dois caminhos HTTP.** `dart:io` produz
   `Dart/3.5`; plugins que descem para a plataforma produzem `okhttp` no Android.
   Mesmo binário, duas assinaturas.
2. **Aplicativo Android nativo antigo convivendo com o Flutter novo** — a
   hipótese acima, com dependências atualizadas ao longo do tempo.

**O que decide:** tráfego relevante em `/api/agendas/agendar` sem prefixo
confirma a hipótese, e aquele contrato não pode ser aposentado. Tudo concentrado
em `/api/v3/...` significa que as gerações antigas já morreram, e o D5 encolhe
para o problema do token, sem o agravante do parque.

`AplicativoController` autentica com um token estático embutido no código. Não
foi rotatado de propósito: trocá-lo derruba o aplicativo na mão dos clientes.

**Ao resolver:** junto com a versão do app que adotar autenticação por token
rotacionável. Ver também o versionamento improvisado dos endpoints (`V2`, `V3`),
que indica versões antigas ainda ativas no parque.

---

## Débitos técnicos descobertos no levantamento

### D6 — Banco em utf8mb3 (não armazena emoji) — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: o corte para PostgreSQL, na migração final, e antes dele conferir se já existem dados corrompidos por truncamento.

As 82 tabelas usam `utf8mb3`, o charset de 3 bytes. **Emoji e vários caracteres
Unicode não cabem** — mensagens de WhatsApp e nomes com esses caracteres são
truncados ou rejeitados pelo MySQL hoje.

**Ao resolver:** a migração para PostgreSQL resolve naturalmente (UTF-8 completo),
mas é preciso verificar antes se existem dados já corrompidos por truncamento.
Se a migração demorar, `ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4` resolve
no MySQL.

#### O modelo que o setup instala nao tinha `charset` — corrigido em 02/09/2026

Descoberto por acidente, e por um erro meu. Em 01/09 eu perdi o `local.php` da
maquina de desenvolvimento (um `kill -9` impediu o `trap` de
`scripts/rodar-caracterizacao.sh` de restaura-lo) e o reconstrui a partir de
`docker/config/local.php.docker`, que e o que `scripts/setup-dev.sh:31` instala.
Ao comparar o arquivo reconstruido com o original, faltavam cinco linhas.

Eram estas:

```php
'charset' => 'utf8mb4',
```

**E elas nao estavam no modelo.** `config/autoload/local.php.dist` tem o
`charset`; `docker/config/local.php.docker` nao tinha. Quem montasse o ambiente
pelo caminho documentado ja recebia um `local.php` sem ele.

O cabecalho de `ISBase\Doctrine\Charset` avisa exatamente sobre isto:

> o `local.php` que vale em producao NAO e versionado: se ele subir sem
> `charset`, a conexao volta a nascer em utf8mb3 e os 18 pontos deixam de
> proteger justamente o texto do cliente.

**Nao houve corrupcao de dado.** A classe `Charset` continua elevando a conexao
nos 18 pontos que tocam texto de cliente — ela existe como rede para este caso.
O que faltava era a protecao de base, deixando tudo pendurado na rede.

Corrigido nos dois: no modelo versionado e no `local.php` desta maquina.
Conferido depois: `character_set_client = utf8mb4` na conexao.

**A licao nao e sobre charset.** Eu reconstrui um arquivo a partir de um modelo
e assumi que o modelo estava completo. O jeito de nao repetir e o que fiz aqui
por sorte: comparar o reconstruido com o que se sabe do original — no caso, os
numeros de linha que eu tinha visto antes de perde-lo.

### D7 — `usuarios.senha_php83` removida — e a remoção deixou dois estragos (ver D78 e D79)

*Resolvido em 17/08/2026, na F1.3.* A coluna saiu do schema PostgreSQL, e o
mapeamento e os dois acessores saíram da entidade `Usuario`. Nada fora da própria
entidade os chamava — só o proxy gerado pelo Doctrine, que se regenera. Os 67
testes de caracterização continuam passando.

**O que só apareceu ao remover, e vale registrar:** a coluna guardava senha com
criptografia **reversível e chave fixa no código**:

```php
openssl_encrypt($senha, 'AES-128-CBC', '0', 0, '0123456789101112')
```

Chave `'0'`, vetor de inicialização literal, ambos versionados. Qualquer pessoa
com o código e um dump recuperava as senhas em texto claro. Era pior que o D2, e
estava classificado aqui como faxina de coluna órfã.

Duas coisas atenuam: o `anonimizar.sql.modelo` já zerava a coluna, então os dumps que
circulam nunca a levaram; e o deploy da F1.1 a elimina de produção.

~~**Resta uma:** `usuarios.salt` usa a mesma chave e o mesmo vetor. Salt não é
segredo por definição, então o impacto é menor.~~

> ### DUAS CORREÇÕES, de 01/09/2026, e as duas são graves
>
> **A primeira: a frase riscada acima está errada, e errou pelo nome da
> coluna.** `usuarios.salt` **não guarda um salt** — guarda a **senha**, cifrada
> com a mesma chave `'0'` e o mesmo vetor. `getSalt()` chama `openssl_decrypt` e
> **devolve a senha em texto claro**, e é contra ela que `autenticacao()` compara
> o que o usuário digita.
>
> O impacto não é menor: é o mesmo do `senha_php83`, e essa coluna **não foi
> removida**. Virou o **D78**.
>
> **A segunda: a remoção quebrou a troca de senha, e está quebrada desde
> 17/08/2026.** Este débito afirma que *"nada fora da própria entidade os
> chamava"*. Fora, não — **dentro** chamava: `criptografarSenha()` faz
> `$this->setSenhaPhp83($senha)` na linha seguinte ao `setSalt()`. O commit
> `8f95f86` removeu o método e **manteve a chamada**.
>
> `setSenha()` lança `Error: Call to undefined method` — conferido executando.
> Virou o **D79**.
>
> **Os 67 testes continuaram passando** porque nenhum deles define uma senha. É o
> mesmo desenho de falha do D55: verde que não cobria o caminho.

### D8 — Proteção de branch indisponível no GitHub

Repositório privado em conta pessoal não permite proteção de branch sem plano
pago. Hoje a proteção equivalente roda no CI (servidor, não burlável) e no hook
`pre-push` (local, burlável com `--no-verify`).

**Ao resolver:** quando houver duas pessoas commitando — GitHub Pro ou uma
organização com plano Team.

### D9 — Credencial do sistema de BI — REDUZIDO A UM RESÍDUO em 09/09/2026 — PREPARADO, lido em 05/10/2026 — PREPARADO, e o papel do PostgreSQL local apagado em 12/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: revogar no MySQL de produção o usuário que servia ao BI e apagar a funcionalidade 61 e suas concessões, na janela da migração.

O sistema de BI externo acessa o MySQL de produção diretamente. Não se sabe qual
usuário utiliza nem se tem permissão de escrita.

**Ao resolver:** inventariar as queries, criar usuário **somente leitura** e,
na Fase 1, apontá-lo para uma réplica de leitura do PostgreSQL em vez do banco
de produção.

**Situação em 27/08/2026 (F1.9): metade feito.**

O usuário somente leitura existe e está provado — `bvip_bi`, sem permissão de
escrita (cinco tentativas recusadas), com escopo por inquilino (com contraprova)
e lendo de réplica (onde nem o superusuário escreve). Ver
`f1-9-bi-e-papeis.md`.

**A outra metade não avançou, e as duas partes dependem de produção:**

1. **O inventário das consultas.** Continua sem saber-se o que o Power BI lê nem
   com que usuário. Por isso `bvip_bi` recebe `SELECT` em **todas** as tabelas —
   que é o menor privilégio *possível sem o inventário*, e não o menor
   privilégio. A diferença importa: `clientes` tem 242.587 linhas com dado
   pessoal, e um painel de faturamento não precisa de nenhuma delas.
2. **Repontar o Power BI.** Os relatórios vivem no serviço da Microsoft e são
   embutidos por unidade (`unidades.iframe_bi`). Trocar a fonte exige abrir o
   modelo no Desktop, trocar o conector, instalar o provedor Npgsql no gateway,
   republicar e reconfigurar credenciais. Não é trabalho de banco.

#### O Power BI foi REMOVIDO em 09/09/2026

Por decisão do dono: *"Remover Power BI, não será mais utilizado."* Saiu em três
commits — a tela do painel e o item de menu, a coluna `unidades.iframe_bi`, e o
papel `bvip_bi` do modelo de papéis.

**As duas metades em aberto morreram com ele.** Não há consultas a inventariar
nem ferramenta a repontar. Inventariar o que ninguém vai executar seria produzir
documento para arquivar.

#### O que NÃO morreu, e é o resíduo deste débito

**A credencial em produção continua lá.** Remover o consumidor não revoga o
usuário: o MySQL de produção segue com o usuário que o Power BI usava — ainda
sem nome conhecido, ainda sem permissões conhecidas — e agora **ninguém o usa**.

Credencial que ninguém usa não é credencial que não existe. É a que menos chama
atenção no dia em que for usada: não há mais painel legítimo cujo tráfego se
pareça com o dela, e também não há mais ninguém que note se ela parar de
funcionar.

**Isso torna o débito menor e mais claro do que era.** Não é mais "inventariar e
repontar", que dependia da Microsoft e de produção descongelada. É **revogar**,
que é um `DROP USER` e depende só de produção.

**Ao resolver:** listar os usuários do MySQL de produção, identificar por `host`
de origem e por `SHOW GRANTS` qual servia ao BI, e revogá-lo. Enquanto a
produção estiver congelada por decisão de 23/08, este débito fica aberto **por
isto** — e não mais pelo inventário.

#### E um segundo resíduo, que eu mesmo deixei

Ao remover a tela do painel eu tirei do `SessaoAcl` as três menções a
`dashboardUnidades`. **A linha da tabela ficou.** A funcionalidade #61,
`Relatórios dashboard unidades`, segue com **145 concessões e 1 molde**,
apontando para uma tela que não existe mais — uma caixinha que o operador marca
e desmarca na tela de privilégios, e que não muda nada.

Isso não dá erro, não gera log, e ninguém notaria. Foi medido por
`scripts/medir-funcionalidades-orfas.php`, escrito por causa disto: das 63
funcionalidades, **esta é a única órfã, e não há nenhum fantasma** (nome citado
no código que a tabela não tenha). O resto da ACL está íntegro.

**A primeira versão daquele medidor mediu errado, e vale registrar por quê.** Ela
acusou 37 órfãs e 36 fantasmas — quase toda a ACL. O defeito era de
codificação: os nomes são acentuados, o fonte é UTF-8 e o cliente do MySQL
devolvia latin1, então tudo com acento "não casava". Faltava
`--default-character-set=utf8mb4`. *Medida que compara texto precisa comparar o
mesmo texto* — e um número absurdo é mais fácil de desconfiar que um plausível.

**Ao resolver:** `DELETE FROM privilegios_usuarios WHERE funcionalidade = 61;`,
o mesmo em `privilegios`, e então a linha de `funcionalidades`. É escrita em
produção, que está congelada — entra na mesma janela do resíduo acima.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 6): a opção (a), "Não mexer até o
inventário do D9 dizer quem as chama" -- as três exportações `unidadeVendas*`, que autenticam pelo
`hash` da unidade, ficam como estão (o D4). Este débito não tem mais inventário: o das consultas morreu
com o Power BI em 09/09/2026 (acima). O que a decisão espera é saber quem chama `/api/unidade/vendas*`,
e isso só o log de acesso da produção diz, em todos os logs de vhost (o D36); nenhum roteiro o conta, e
a pergunta de escrevê-lo está no lote. O resíduo deste débito continua o mesmo: revogar o usuário do BI
e apagar a funcionalidade 61, na janela.

**Decidido pelo dono 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; a sua 6 volta a
você com o número." A conta existe desde 08/10/2026, `scripts/medir-chamadas-unidade-vendas.sh`, e é o 9
de "O que hoje bloqueia" do procedimento de corte (o D4 diz o que ela conta e como foi provada). Com o
número do dia 0, a pergunta 6 volta ao dono. O resíduo deste débito não muda: revogar o usuário do BI e
apagar a funcionalidade 61, na janela.

#### E um terceiro resíduo, no PostgreSQL da cópia local (achado em 11/10/2026)

O papel `bvip_bi` saiu do modelo de papéis em 09/09/2026, mas **não saiu do PostgreSQL desta máquina**: ele segue lá,
com login (sem `BYPASSRLS`), e a cópia (`franquiabv_dev`, esquema `bvip`) ainda lhe dá `SELECT` nas 89 tabelas e no
privilégio padrão do esquema (`pg_default_acl` do `franquiabv`: `bvip_app=arwd`, `bvip_bi=r`) — o padrão de antes de
09/09, que a `clientes_aparelhos` herdou ao ser criada em 11/10/2026 (o **D461**, a verificação). Medido só com leitura
nos sete bancos do servidor local: só a cópia dá algo ao papel, e ele não é dono de objeto nenhum. A homologação, o
teste e o laboratório do BVIP não foram medidos daqui. Tirá-lo (os `REVOKE`, ou o `DROP OWNED BY` e o `DROP ROLE`) é
escrita na cópia: a pergunta foi ao lote (PUSH-TERCEIRA-RODADA, a verificação), e a recomendação é apagar o papel.

#### O terceiro resíduo, apagado em 12/10/2026 (a resposta 150, a opção (c); item ESQUEMA-NAS-COPIAS)

**A resposta do dono** (a 150 do lote, 12/10/2026, 02:30): a opção (c), apagar o papel `bvip_bi` do PostgreSQL desta
máquina.

**Conferido antes, só com leitura, às 05:35:** o papel com login, sem superusuário nem `BYPASSRLS`, **nenhuma sessão
aberta** (`pg_stat_activity`, em banco nenhum) e **nenhum objeto próprio** (nenhuma dependência de dono no
`pg_shdepend`); o que o servidor guardava dele era só privilégio, e só na `franquiabv_dev`: o `CONNECT` do banco, o
`USAGE` do esquema `bvip`, o `SELECT` em 89 tabelas e o privilégio padrão do `franquiabv` no esquema (`bvip_bi=r`), além
de uma configuração do próprio papel (`search_path`, inquilino 1, só leitura e tempo limite de 10 min), que o `DROP
ROLE` leva junto. Nenhum privilégio concedido POR ele a outro papel, e ele não era membro de papel nenhum nem tinha
membro. Os outros seis bancos do servidor (`postgres`, `franquiabv_pgtestes`, `franquiabv_pgcontrato`,
`franquiabv_pgwtd237`, `d45_refutacao`, `fx_teste`) não o citavam em lista de privilégio nenhuma.

**Os comandos**, na `franquiabv_dev`, como `franquiabv`, pelo `psql` do contêiner, numa transação só, com as três
conferências dentro dela -- sem sessão, sem objeto próprio, nada em outro banco; qualquer uma falsa e o roteiro desfaz e
para (`docker exec -i franquiabv-postgres-1 psql -U franquiabv -d franquiabv_dev < <o roteiro>`), começada às
05:40:18,88 e encerrada às 05:40:18,90:

```sql
DROP OWNED BY bvip_bi;
DROP ROLE bvip_bi;
```

Saída 0, e `pg_roles` sem o papel. Nada mais foi escrito: nem dado, nem esquema, nem `ANALYZE`.

**A conferência depois, só leitura:** a lista de privilégios inteira de cada um dos sete bancos -- o banco, os esquemas,
os privilégios padrão, toda tabela, sequência e visão, toda coluna com privilégio próprio, toda função e toda política --,
tirada antes e depois. Na `franquiabv_dev`, 92 linhas mudaram, **todas só pela saída do `bvip_bi`** (o banco, o esquema,
o padrão e as 89 tabelas); nos outros seis, nenhuma. Os privilégios do `bvip_app` ficaram os mesmos, e os da cópia ficaram
iguais, linha a linha, aos do banco da suíte (`franquiabv_pgtestes`), menos a lista do próprio banco (a cópia dá
`CONNECT` ao `bvip_app` explicitamente; o banco da suíte usa o padrão). A tabela nova que nascer na cópia não herda mais o
`SELECT` do papel aposentado, que foi o que a `clientes_aparelhos` herdou em 11/10/2026 (o **D461**).

**Nada no repositório usa o papel:** as citações são histórico (`docs/`) e os comentários de `scripts/criar-papeis.sh` e
`scripts/conferir-papeis.sh`, que dizem que ele saiu em 09/09/2026; o `criar-papeis.sh` só cria o `bvip_app` (de
`postgres/papeis.sql.modelo`), e não o recria. **O portão dos papéis**, `scripts/conferir-papeis.sh`, rodou depois, com
"TUDO CONFERE" (o `bvip_app` não é superusuário nem tem `BYPASSRLS`, o RLS vale para ele sem e com inquilino, e o dono o
atravessa) -- **num banco descartável**, `med_papeis_d9`, clonado do banco da suíte e apagado no fim (conferido: nenhum
`med_*`), porque o roteiro grava e apaga uma unidade e um inquilino de prova no banco que mede, e na `franquiabv_dev` isso
seria escrita fora da autorização. A propriedade do papel que ele confere é do servidor, e não do banco; o RLS, ele o
mediu no clone. Os casos da suíte no PostgreSQL rodam como `bvip_app`, e os dos cupons e das comandas ficaram verdes depois
(o **D271**).

**Refeito na verificação (12/10/2026), num clone da própria cópia.** O clone do banco da suíte media um estado que o
`DROP` não tocou: os privilégios só mudaram na `franquiabv_dev`. Então, sem sessão nenhuma aberta nela, a cópia foi
clonada por `CREATE DATABASE med_papeis_copia TEMPLATE franquiabv_dev STRATEGY FILE_COPY` (17 s, 2.862 MB), com o
`search_path` e o fuso dela, e o `CONNECT` do banco dado como ela o dá (`REVOKE ALL ... FROM PUBLIC` e `GRANT CONNECT
... TO bvip_app`, só no clone). A lista de privilégios inteira do clone, pela mesma consulta de antes, ficou **igual à da
cópia, linha a linha** (289 linhas, trocado só o nome do banco), sem nenhuma citação ao `bvip_bi` e com as 179 do
`bvip_app`; e a da cópia, igual à tirada depois do `DROP`. O `scripts/conferir-papeis.sh` contra o clone: **"TUDO
CONFERE"**, saída 0 (o `bvip_app` com inquilino lê as 64 unidades da cópia). O mutante, no clone: `REVOKE SELECT ON
bvip.unidades FROM bvip_app` -- saída **1**, com "COM inquilino declarado le" e "nao ve a linha do inquilino 2" em FALHOU:
o portão mede os privilégios do banco que lhe dão. O clone foi apagado no fim (nenhum `med_*` no servidor), e a cópia não
recebeu a unidade nem o inquilino de prova (0 linhas com o id 999001 e o inquilino 2).

**O que resta deste débito** é o de antes, e é de produção, na janela: revogar o usuário do MySQL de produção que servia
ao BI e apagar a funcionalidade 61 e as suas concessões. A homologação, o teste e o laboratório do BVIP não foram medidos
daqui (o terceiro resíduo era desta máquina); se algum deles tiver o papel, ele veio de lá, e não desta cópia.

### D11 — `laminas-cache` trava a atualização do PHP — PREPARADO em 08/11/2026: o lock aceita do 8.1 ao 8.3 e a suíte passou no 8.2; o `vendor/` novo sobe na janela, e trocar o PHP do servidor é do corte

O projeto está preso ao **PHP 8.1**: `laminas/laminas-cache` está fixado em
3.6.0 no `composer.lock`, versão que aceita no máximo PHP 8.1. O
`composer.json` declara aceitar até 8.3, mas o `composer install` falha em 8.2
e 8.3. Pelo mesmo motivo o Composer precisa ser da linha 2.2: o
`laminas-dependency-plugin` exige `plugin-api < 2.3`.

O PHP 8.1 saiu do suporte de segurança em dezembro de 2025.

**Ao resolver:** atualizar `laminas-cache` e `laminas-dependency-plugin` antes
de subir a versão do PHP. Como o backend continua em Laminas na estratégia
adotada, isso não é descartável pela migração para React — é manutenção que
segue necessária.

**Preparado em 08/11/2026 (a fatia 130, PREPARO-DO-PHP-E-DO-ARMAZENAMENTO).** O `composer.json` pede
`laminas/laminas-cache` `~3.13.0` (era `3.6.0` fixo), e o `composer update laminas/laminas-cache
laminas/laminas-cache-storage-adapter-filesystem`, rodado **dentro do contêiner** (o Composer 2.2 dele),
mudou só três pacotes no `composer.lock`: `laminas-cache` 3.6.0 → 3.13.0 (PHP 8.1 a 8.4),
`laminas-cache-storage-adapter-filesystem` 2.3.0 → 2.5.0 (8.1 a 8.4), e saiu o `stella-maris/clock` (o 3.13
usa o `psr/clock`, que já estava). O `laminas-cache-storage-adapter-memory` 2.4.0, o que o Doctrine usa
(`doctrine.cache.array`), já aceitava até o 8.4 e não mudou. O `vendor/` do host foi recopiado do
contêiner e conferido (os 4.901 arquivos com o mesmo sha1 dos dois lados). **O que prova que a trava
saiu:** `composer prohibits php 8.2.0` e `8.3.0` acusavam o `laminas-cache` 3.6.0 (e o adaptador 2.3.0,
no 8.3) e agora não acusam ninguém; o `composer install` do lock antigo numa imagem à parte com
`PHP_VERSION=8.2` recusa ("laminas/laminas-cache 3.6.0 requires php ~7.4.0 || ~8.0.0 || ~8.1.0"), e o do
lock novo instala, com o `check-platform-reqs` verde; e a suíte de caracterização inteira passou nessa
imagem (PHP 8.2.35), nos dois bancos: 7.211 casos no MySQL (24 pulados) e 7.211 no PostgreSQL (17
pulados), saída 0. **Um aviso do 8.2, e não é nosso:** uma depreciação no `doctrine/dbal` 2
(`PostgreSqlPlatform.php:874`, `${var}` em texto), num caso do PostgreSQL
(`AcertoDoConvenioDasVendasTest`); é aviso, não erro, e só pesa no PHP 9. O `RegrasDaCotaParteTest` (o
**D673**, o `round` do 8.1) passou no 8.2. A imagem, o contêiner e o volume do ensaio foram apagados no fim.

**O que fica para o corte:** o `vendor/` novo sobe na janela, no pacote da release
(`procedimento-de-corte-etapa-1.md`, a leitura 8 e o passo 2 de "A release, montada fora da produção"): o
da produção saiu do lock antigo e não serve ao código novo. Trocar o PHP do servidor (o 8.1 sai do
suporte de segurança) não é desta fatia nem da etapa 1. **Segue de pé:** o Composer 2.2, porque o
`laminas-dependency-plugin` (hoje 2.7.0) ainda exige `plugin-api < 2.3`; e o lock não aceita o 8.4 (o
`composer.json` e uma dúzia de pacotes param no 8.3). O padrão do `docker/php/Dockerfile` segue
`PHP_VERSION=8.1`, o da produção. PREPARADO.

*Na correção da fatia 130 (08/11/2026):* a prova de cima foi feita à mão, uma vez, e a suíte, o contrato, os ensaios e o CI
rodam todos no 8.1 -- o lock podia voltar ao `laminas-cache` 3.6.0 e tudo seguiria verde. **A guarda agora é um caso**, o
`D11LockAceitaOPhpNovoTest`: lê o `composer.json` e o `composer.lock` e confere, pelo `composer/semver`, que a restrição de PHP
do `composer.json`, da `platform` do lock e de cada pacote (os da aplicação e os de desenvolvimento) aceita o 8.1, o 8.2 e o
8.3 (o `.0` e o `.99` de cada linha); um segundo caso prova que o leitor nomeia o `laminas-cache` 3.6.0. Com o `composer.json`
e o `composer.lock` do commit `281c9c0a` no lugar, ele fica vermelho e nomeia o `laminas-cache` 3.6.0 e o adaptador 2.3.0. O
que ele não prova: que o código RODA no 8.2 ou no 8.3 -- isso segue pedindo a suíte numa imagem com `PHP_VERSION=8.2`, como
acima.

### D12 — Configuração do nginx em produção não verificada

O `public/.htaccess` presente no projeto é inerte, já que a produção usa nginx.
Toda a proteção de caminho depende da configuração do servidor, que ainda não
foi inspecionada. Duas perguntas em aberto, ambas com impacto de segurança:

1. **Execução de PHP em diretórios de upload.** O sistema grava arquivos
   enviados por usuários em `public/arquivos`, `public/contratos`,
   `public/banners`, `public/assistentes`, `public/vip-class` e
   `public/layout/img/avatar`. Se o nginx de produção não bloquear `.php`
   nesses caminhos, um arquivo enviado como "contrato" é executado pelo
   servidor — o que é execução remota de código.
2. **Arquivos de dado soltos na raiz do webroot.** Uma regra por categoria
   (`.txt`, `.log`, `.sql`) teria contido o problema D1 sozinha.

O `docker/nginx/default.conf` deste repositório implementa as duas e foi testado
(ver `ambiente-desenvolvimento.md`). Serve de referência para o servidor.

### D13 — API do aplicativo não tinha autenticação nenhuma — FECHADO em 04/09/2026

> **Leia junto com o D59**, que é o mesmo assunto medido depois e por outro
> caminho — lá está a demonstração com `curl` e o achado de que
> `getCliente?telefone=` devolve o CPF sem cabeçalho nenhum. Os dois ficam
> separados de propósito: este registra *por que* não há autenticação (o token
> declarado e nunca comparado); o D59 registra *o que se alcança* sem ela.
> Conferido em 30/08/2026 que não são duplicata.

`AplicativoController` declara `$tokenPadrao` no construtor, mas **o valor nunca
é lido**. O único listener de autenticação (`ISConfiguracao\Module::handleAuthRedirect`)
protege apenas rotas com `admin` no nome, a rota `home` e as rotas `isapi-docs-*`.
Verificado no ambiente: os 22 endpoints do aplicativo respondem sem credencial.

*Correção de 16/08/2026:* a versão anterior deste registro estendia a afirmação
às 29 rotas de cron. **Não vale para elas** — os crons verificam token ou hash;
ver D4. O problema é específico do `AplicativoController`, onde o token é
declarado e nunca comparado.

Isso é mais grave do que o token estar fixo no código (D5). Entre o que fica
aberto: `/api/cliente/agenda/:id` e `/api/agendas/getConsumoCliente` expõem
dados de clientes por enumeração de id, e `/api/agendas/cancelar` permite
cancelar agendamentos de terceiros.

**Ao resolver:** exige mudança coordenada com uma versão nova do aplicativo, já
que hoje ele não envia credencial alguma. Fecha junto com D5. O teste
`testApiRespondeSemAutenticacao` falha quando isso for corrigido — é o sinal de
que o app precisa sair na mesma entrega.

#### RESOLVIDO em 03 e 04/09/2026

**As duas rotas que este registro nomeia foram fechadas**, e mais sete:
`getConsumoCliente` e `agendas/cancelar` estão entre as nove ações que passaram a
exigir sessão. O detalhe do que cada uma expunha está no **D59** (as cinco de
leitura) e no **D91** (as quatro de escrita).

*E vale registrar como elas voltaram à tona:* eu as reencontrei varrendo o
código, e as reportei como achado — sem reler este débito, que as nomeia desde
16/08/2026. Aconteceu duas vezes no mesmo dia, e nas duas foi um portão que me
corrigiu. **Quem varre código sem reler o registro da área encontra o que já era
sabido e chama de novo.**

**O `$tokenPadrao` do `AplicativoController` saiu**, e ele era o "token declarado
e nunca comparado" que dá nome a este débito: 42 caracteres com cara de
credencial, atribuídos no construtor e **nunca lidos** em nenhuma das 22 ações.
Código morto com forma de segredo é pior que código morto — quem lê supõe que há
uma verificação em algum lugar.

**A autenticação veio por outro caminho**, e não por aquele campo: sessão do ERP
nas nove ações congeladas que expunham dado de pessoa, e `Authorization: Bearer`
no `/api/v4`, com prova de posse do telefone por código no WhatsApp.

**O `testApiRespondeSemAutenticacao` continua verde**, e está certo: ele
exercita `/api/agendas/getUnidades`, que é catálogo público e continua sem exigir
credencial. O sinal que este registro esperava veio por outros nove testes, que
congelam a recusa.

#### O que continua deste débito

O `$tokenPadrao` do `CronJobController` — **esse é usado**, em 26 ações, e é o
token fixo do **D5**. As comparações passaram a ser `hash_equals` em 04/09; o
valor continua no código, e rotacioná-lo exige coordenar com quem chama os crons.

### D14 — Rota V1 de agendamento aponta para ação inexistente — PARCIAL em 07/10/2026: a raiz `/mod-vendas` leva à lista de vendas; ficam a V1, que espera os logs, e 12 `/<apelido>/index` que caem no `indexAction` do Laminas — PARCIAL em 07/11/2026: os 12 `/<apelido>/index` dão 404 (a fatia 115); fica só a V1, que espera os logs da produção

`/api/agendas/agendar` está declarada em `module/ISApi/config/module.config.php`
apontando para a ação `agendaSalvar`, que **não existe** no `AplicativoController`
— só existem `agendaSalvarV2` e `agendaSalvarV3`. A rota retorna 404.

Ou seja: qualquer versão do aplicativo que ainda use o endpoint original **não
consegue agendar**. Como não há inventário das versões em uso (depende dos logs
do servidor), não dá para saber quantos clientes são afetados.

**Ao resolver:** decidir entre implementar a V1 de volta ou confirmar pelos logs
que ninguém mais a chama e remover a rota. O teste
`testAgendaSalvarV1EstaQuebrada` congela o 404 atual e falha quando o
comportamento mudar.

#### Não era uma. Eram três (medido em 30/08/2026)

A varredura de todas as rotas contra os controllers achou mais duas:

| Rota | Situação |
|---|---|
| `/api/agendas/agendar` → `agendaSalvar` | a deste débito; espera os logs |
| `/convenios/atualizarVencimento` → `ConveniosController::atualizarVencimento` | **órfã por nossa mão** — removida em 30/08 |
| `/mod-vendas` → `VendasController::index` | a raiz do módulo cai numa ação que nunca existiu |

**A do meio foi criada por este projeto.** Ao mover a rotina de vencimento de
convênio para o cron (**D50**), a ação de admin foi removida em 28/08 e **a rota
ficou para trás**, apontando para o nada por dois dias. Nada a referenciava —
conferido — e ela saiu.

**A de `/mod-vendas` é mais antiga e continua de pé:** a URL raiz do módulo de
vendas tem `'action' => 'index'` como padrão, e `VendasController` tem
`indexVendasAction` e `indexComandasAction`, mas nunca teve `indexAction`. Quem
digitar `/mod-vendas` sem caminho recebe erro; o menu sempre apontou para as
ações completas, então ninguém tropeçou.

`scripts/conferir-rotas-orfas.php` guarda as duas restantes numa linha de base e
acusa qualquer rota órfã nova.

#### Três armadilhas nesse roteiro, e as três me pegaram

1. **Nome curto não identifica controller.** Há dois `VendasController`, em
   `ISRelatorio` e em `ISVenda`. Indexar por nome curto faz um sobrescrever o
   outro. A primeira versão acusou **11**; resolvendo por módulo, são 3.

2. **PHP não diferencia caixa em nome de método.** A rota
   `'action' => 'cadastroconcluido'` alcança `cadastroConcluidoAction()` sem
   problema — verificado por execução. Comparar caixa exata acusava duas rotas
   perfeitamente sadias.

3. **Guardar o nome do método com o sufixo `Action`** e comparar com a ação sem
   ele fez a terceira versão acusar **90 de 92**. Número absurdo o bastante para
   se denunciar sozinho — e é por isso que o roteiro imprime a contagem antes do
   veredito.

#### O conserto da raiz `/mod-vendas` (07/10/2026, o item CODIGO-MORTO-E-DOCS)

**O "recebe erro" estava certo, e o "nunca teve `indexAction`" só pela metade.** O
controller nunca DECLAROU o seu, e o PHP achava o herdado: o
`AbstractActionController::indexAction` do Laminas, o de marcador, que devolve
"Placeholder page" para o molde `is-venda/vendas/index` -- que não existe. Medido em
processo: o resolvedor de moldes devolve `false` para ele, e desenhá-lo dá *Unable to
render template*. O roteiro das órfãs lê só o arquivo do controller, e por isso a
contava como órfã -- o que, para quem abria a URL, ela era.

**`VendasController::indexAction` leva à lista de vendas realizadas**
(`/mod-vendas/vendas/indexVendas`, o mesmo endereço do menu) e pede o mesmo privilégio
que ela, `vendas/realizadas/ler`. Sem ele, responde o 404 da lista, sem
redirecionamento: a raiz não abre nada que a lista não abra, e não manda ninguém por um
302 até um 404. Quem pode `comandasListar` e não `realizadas` recebe o 404 na raiz,
como recebia o erro antes -- não há uma raiz que sirva aos dois, e escolher outra seria
escolha de tela. Sem sessão, a raiz cai no login como o resto do ERP
(`RotaPublica::exigeSessao('isvenda-admin')`).

**Provado** por `tests/Caracterizacao/D14RaizDoModuloDeVendasTest.php` (4 casos, em
processo, nos dois bancos): o roteador leva `/mod-vendas` a uma ação **declarada** em
`VendasController` (e não à herdada -- `method_exists` dizia que sim antes do conserto,
e foi o primeiro mutante que mostrou isso); quem enxerga a lista recebe 302 para
`/mod-vendas/vendas/indexVendas`, que o roteador resolve para `indexVendas`; quem não
enxerga recebe 404 na raiz, sem `Location`, e 404 na lista pedida direto; e a raiz faz
à sessão exatamente a pergunta que a lista faz. **Mutantes, um de cada vez:** sem o
`indexAction`, 4 vermelhos e o `conferir-rotas-orfas.php` com saída 1; sem a guarda, 2
vermelhos e o `conferir-guarda-do-erp.php` com saída 1 (35 sem guarda, a ação nomeada);
com a guarda de `comandasListar`, 2 vermelhos. A entrada saiu de `$conhecidas` do
`conferir-rotas-orfas.php`, que fica com uma: a V1 de agendamento, que espera os logs.
Por HTTP, só o pedido anônimo: `GET /mod-vendas` sem sessão devolve o formulário de
entrada (200, com o campo `login` e o de senha). O caminho com sessão não foi aberto no
navegador.

#### Os irmãos da raiz ficam de pé (verificação de 07/10/2026)

**A raiz era um caso de uma classe de URL, e o conserto cobriu só ela.** As seis rotas
curinga do ERP -- `/mod-vendas/[:controller[/:action[/:id]]]` e as de `/mod-cadastros`,
`/mod-configuracoes`, `/mod-financeiro`, `/mod-marketing` e `/mod-relatorios` -- aceitam
no `:controller` **qualquer** apelido do `ControllerManager` (os apelidos são da
aplicação, e não do módulo), e `/<módulo>/<apelido>/index` despacha o `indexAction`.
Nos controllers que não declaram o seu, responde o de marcador do Laminas, com a mesma
falha ao desenhar que a raiz dava. **Medido em processo, pelo `ControllerManager`, e não
de memória: 12 apelidos** -- `aplicativo`, `appconfiguracoes`, `caixas`, `cronJob`,
`enderecos`, `fidelidade`, `pesquisaNps`, `rel-franquias`, `testeEstilo`,
`vendasPagamentos`, `vendasProdutos` e `whatsapp`. Três são do próprio módulo de vendas
(`/mod-vendas/caixas/index`, `/mod-vendas/vendasProdutos/index`,
`/mod-vendas/vendasPagamentos/index`: em `view/is-venda/caixas/` só há `abrir.phtml` e
`fechar.phtml`, e as pastas dos outros dois nem existem). Há 22 classes registradas que
herdam o de marcador; as outras 10 não têm apelido que caiba no `:controller`, e nenhuma
rota tem `:action` sem `:controller`: a elas só se chega pelas rotas que nomeiam a ação,
e essas o `conferir-rotas-orfas.php` lê.

Não é regressão, nada no menu aponta para essas URLs, e todas exigem sessão
(`RotaPublica::exigeSessao`). Nenhum dado sai: quem abre recebe o erro do molde. O
`conferir-rotas-orfas.php` não vê essa classe, porque lê só as rotas que declaram
`action` em `defaults`, e não o que a curinga alcança. **O que cada uma deveria mostrar
é escolha de tela** -- redirecionar para a lista do módulo, como a raiz, ou um 404 --, e
fica aqui: não foi decidido. **Fixado** por um quinto caso em
`D14RaizDoModuloDeVendasTest` (`testOsApelidosQueAindaCaemNoIndexDeMarcadorSaoOsRegistradosNoD14`),
que deriva a lista do `ControllerManager`, compara com os 12 e confere que o roteador
leva `/mod-vendas/<apelido>/index` a cada um, atrás da sessão. **Mutante:** com um
`indexAction` declarado em `CaixasController`, o caso fica vermelho (`caixas` sai da
lista medida); restaurado, o arquivo volta igual ao do HEAD. E com o `indexAction` da
raiz arrancado de novo, agora são os **cinco** casos vermelhos (eram 4 antes deste).
Verde nos dois bancos: a classe e as 14 vizinhas, 178 testes e 985 asserções. Um
apelido novo no de marcador também deixa o caso vermelho -- e quem consertar um destes
tira-o do caso e daqui.

#### O ciclo pegou o link morto do D105 redirecionando (07/10/2026)

**A primeira versão da raiz ressuscitou um link morto.** O apelido `vendas` é da
aplicação, e por isso `/mod-relatorios/vendas/index` -- a curinga dos relatórios -- também
chega a `VendasController::indexAction`. É justamente o link que o D105 tirou da tela
inicial do grupo 9, e o `ensaiar-links-consertados-do-erp.php` (caso 5) exige que ele dê
404. Com a raiz nova ele passou a responder **302 para a lista de vendas**: o ensaio saiu
com 1 no MySQL, no ciclo de 07/10/2026, enquanto os 2.532 testes, o contrato e os 59
portões estavam verdes. Nenhum caso do D14 o pegaria -- todos despachavam a ação sem
parâmetro de rota.

**O conserto:** a ação só redireciona quando o pedido chega pelas rotas `isvenda-admin`,
cujo default `module` é `ISVenda` (a curinga do módulo o herda); fora delas, o 404 de
sempre, **antes** de qualquer pergunta de privilégio. Dois casos novos: o primeiro caso
agora confere que o ROTEADOR põe `module => ISVenda` em `/mod-vendas` e em
`/mod-vendas/vendas/index` (os outros casos o injetam, e sem essa linha a raiz poderia dar
404 com tudo verde), e `testPelaCuringaDosRelatoriosARaizContinuaDando404` despacha a ação
com os parâmetros que o roteador põe em `/mod-relatorios/vendas/index`, por quem enxerga a
lista: 404, sem `Location`, sem pergunta de privilégio. **Mutante:** a guarda do `module`
trocada por `if (false)` deixa só esse caso vermelho; restaurada, verde. MySQL: 6 testes,
89 asserções; PostgreSQL, com as vizinhas de vendas: 40 testes, 258 asserções. A lição é a
do D14 inteiro: o apelido é da aplicação, e qualquer ação nova num controller com apelido
curto vale em seis curingas.

#### Os 12 apelidos dão 404 (07/11/2026, a fatia 115, ROTAS-404-E-401)

**O que faltava dos irmãos da raiz foi fechado junto com o D424.** O `CrudController` declara o seu `indexAction`, que é
`return $this->notFoundAction()->setTerminal(true)` -- o mesmo 404 da ação que não existe, e o mesmo que a raiz dá fora do
módulo de vendas --, sem redirecionar para lugar nenhum e antes de qualquer pergunta de privilégio. Os 12 controllers
(`aplicativo`, `appconfiguracoes`, `caixas`, `cronJob`, `enderecos`, `fidelidade`, `pesquisaNps`, `rel-franquias`,
`testeEstilo`, `vendasPagamentos`, `vendasProdutos` e `whatsapp`) herdam do `CrudController` -- conferido por classe, e não de
memória --, e `/<módulo>/<apelido>/index` deixa de ser o 500 de "Unable to render template". Responder 404 não era escolha
de tela (redirecionar seria): nada aponta para essas URLs.

**Provado** por `D14RaizDoModuloDeVendasTest::testNenhumApelidoCaiNoIndexDeMarcadorETodoIndexSemTelaE404`, que substitui o
caso que fixava os doze como estavam: deriva do `ControllerManager` todo nome que cabe no `:controller`, exige que nenhum caia
no `indexAction` do Laminas, que os doze respondam o do `CrudController`, e despacha cada um pelo `index` -- 404, sem
`Location`, com o modelo terminal e sem pergunta de privilégio. Os outros cinco casos (a raiz, o link do D105 pela curinga
dos relatórios) seguem como estavam. **Por HTTP**, o caso 6 novo do `ensaiar-links-consertados-do-erp.php`, com a sessão que
abre o relatório de vendas: `/mod-vendas/caixas/index`, `/mod-configuracoes/whatsapp/index`,
`/mod-relatorios/rel-franquias/index` e `/mod-cadastros/enderecos/index` dão 404 -- e o caso 5 passou a exigir o 404 do link
morto, sem aceitar mais o 500. **Mutante:** sem o `indexAction` do `CrudController`, o caso em processo fica vermelho nos dois
bancos, e o ensaio HTTP mede os quatro em **500**.

**Fica a V1 de agendamento** (`/api/agendas/agendar` -> `agendaSalvar`, a única órfã conhecida do
`conferir-rotas-orfas.php`, agora pelo nome da rota, `isapi-aplicativo-agenda-salvar`): continua esperando os logs da
produção, e isso é do corte.

### D15 — RESOLVIDO: o schema estava apenas desatualizado no meu lado

**Fechado em 16/08/2026** com o dump de estrutura tirado do servidor de produção.

O registro dizia que faltavam 2 tabelas e 4 colunas, e levantava a hipótese de o
dump entregue estar velho. A hipótese estava certa e o alarme era maior do que
precisava: o `mysql.sql` original era um retrato de **02/04/2026**, e entre abril
e agosto a produção recebeu a migração da funcionalidade de indicações.

O dump de 16/08 confirma que `indicacoes`, `clientes_validacoes_cadastrais` e as
quatro colunas (`clientes.data_ultimo_envio_nps`, `clientes.bloqueio_agendamento_app`,
`clientes.indicacao`, `unidades.indicacao_comissao`) **existem em produção**. Uma
tabela de backup, `pontuacoes_historicos_bkp`, foi removida no mesmo período.

`scripts/verificar-schema.php` agora reporta **zero divergências**, e o
`schema-patch-d15.sql` foi descartado por não ser mais necessário. Os 95 testes
passam contra o schema real.

**Fica a lição de método:** durante meses o repositório teria carregado um schema
que não correspondia à produção, e só um dump datado resolveu. Vale repetir a
verificação antes de iniciar a Fase 1, já que o schema pode evoluir de novo.

### D16 — Endpoints retornam 500 em vez de erro tratado — CORRIGIDO em 14/09/2026 — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: conferir na janela do corte o display_errors do PHP de produção e desligá-lo se estiver ligado, como item do procedimento.

Endpoints do aplicativo devolvem uma página de erro HTML com stack trace quando
a consulta falha, em vez de um JSON com `sucesso: false`. O aplicativo não tem
como distinguir "não encontrado" de "servidor quebrado".

#### Medido em 30/08/2026, e é pior do que estava escrito

**O texto dizia "os três endpoints acima", e a referência ficou órfã** quando o
D15 foi reescrito como resolvido. Os três eram `/api/cliente/agenda/:id`,
`/api/cliente/indicacoes/:id` e `/api/agendas/getServicos/:id`, e eles falhavam
por causa do schema desatualizado — problema que acabou. **Mas o defeito nunca
foi dos três**: é de toda rota `/api/*`.

| | |
|---|---:|
| Ações em `AplicativoController` | **20**, com **0** `try` |
| Ações em `CronJobController` | **29**, com **0** `try` |

**E a condição "se `display_errors` estiver ligado" está errada — não é
condicional.** Quem manda aqui é o `display_exceptions` do Laminas, e ele está
`true` em `module/ISBase/config/module.config.php`, que é config de módulo e
**carrega sempre**. Nenhum arquivo em `config/autoload/` o desliga. O
`development.local.php.dist` também o liga, mas esse só entra em modo de
desenvolvimento — ou seja, a página de erro mostra o rastro **por padrão, em
qualquer ambiente**.

`module/ISBase/view/error/index.phtml` imprime, nessa ordem: a classe da exceção,
`arquivo:linha`, a mensagem, o rastro completo, e repete tudo para a exceção
anterior.

**O que a mensagem carrega, verificado por execução e não por suposição:**

```
An exception occurred while executing
  'SELECT nome FROM ... WHERE telefone = ?'
  with params ["(11) 98765-4321"]
```

O Doctrine põe **o SQL e os parâmetros ligados** dentro de `getMessage()`, e o
template imprime `getMessage()`. Numa consulta de cliente que falhe, **o telefone
do cliente vai para a página de erro** — junto com o caminho absoluto do arquivo
e a pilha inteira.

**O único tratador de erro que existe não resolve isto.**
`ISConfiguracao\Module` escuta `EVENT_DISPATCH_ERROR`, mas só chama
`setTerminal(true)`: tira o layout, e continua renderizando a mesma página de
erro. Não há nada que faça `/api/*` responder JSON.

**Ao resolver:** três coisas, e a primeira é de uma linha.

1. **Tirar `display_exceptions` do config de módulo** e deixá-lo só no arquivo de
   desenvolvimento, que é onde ele já está. Desenvolvimento mantém o rastro;
   produção para de publicá-lo.
2. Um tratador de exceção que responda JSON nas rotas `/api/*`, junto com a
   reescrita da API.
3. Conferir o `display_errors` do PHP em produção — que é assunto separado, e
   continua valendo.

#### O passo 1 foi aplicado em 30/08/2026, por decisão sua

`display_exceptions` e `display_not_found_reason` saíram de
`module/ISBase/config/module.config.php` e passaram a viver em
`config/autoload/local.php`, **que está no `.gitignore`** — é arquivo por
máquina. Desenvolvimento liga; produção, que copia de `local.php.dist`,
simplesmente não liga.

**Verificado nos dois sentidos**, e é o que dá confiança na mudança:

| | `display_exceptions` |
|---|---|
| Com a chave do ambiente (desenvolvimento) | `true` |
| Sem ela (produção) | **AUSENTE** |

O template guarda com `if (! empty($this->display_exceptions))`, então ausente
não imprime nada. Medir só o lado ligado não provaria coisa alguma — foi preciso
remover a chave e conferir que o valor some.

`local.postgres.php.dist` recebeu o mesmo bloco, porque o ensaio de corte também
é ambiente local e continua precisando do rastro.

**O que continua pendente:** os passos 2 e 3 — o tratador que responde JSON nas
rotas `/api/*`, junto com a reescrita da API, e o `display_errors` do PHP em
produção, que é assunto separado e não se confere daqui.

#### O PASSO 2 SAIU EM 14/09/2026, e não esperou a reescrita da API

O registro previa o tratador *"junto com a reescrita da API"*. Não precisou: são
cinquenta linhas num ouvinte, e a reescrita pode levar meses durante os quais
aparelho instalado continua recebendo HTML.

**Medido antes**, contra o servidor local:

```
GET /api/agendas/getAgenda/999999999?data[]=1
→ HTTP 500, Content-Type: text/html
→ <h1>An error occurred</h1>
```

**Medido depois:**

```
→ HTTP 500, Content-Type: application/json
→ {"sucesso":false,"mensagem":"Não foi possível atender a este pedido."}
```

E a rota que não existe passou a responder **404** em JSON, e não 500 — a
diferença é o que um aplicativo velho usa para distinguir *"o endereço sumiu"* de
*"deu erro"*.

#### O QUE ELE NUNCA PÕE NO CORPO

**Nem `getMessage()`, nem o rastro.** É o mesmo motivo que tirou
`display_exceptions` do config de módulo em 30/08: o Doctrine põe o SQL **e os
parâmetros ligados** dentro da mensagem, e uma consulta de cliente que falhasse
publicaria o telefone dele. Pôr a mensagem aqui desfaria aquele conserto por
outra porta. A exceção vai para o log do servidor, que é onde quem opera a lê sem
que o cliente a receba.

#### MEIO ENSAIO AFIRMA QUE HTML CONTINUA HTML

Um ouvinte guloso demais transformaria em JSON a página de erro do ERP e a
documentação da própria API — entregando um objeto a um navegador. Por isso
metade dos dez casos de `ensaiar-erro-json-da-api.php` mede o outro lado.

**E a primeira versão desses casos era verde pelo motivo errado.** Eles pediam
`/api/docs` e `/mod-cadastros/agendas`, que respondem **200** — e o ouvinte só
dispara em **erro**. Ficavam verdes com o ouvinte, sem ele, e com o recorte
arrancado. Agora pedem `/api/docs/naoexiste` e `/rota/que/nao/existe`, que dão
404 de verdade.

#### FALSIFICADO EM QUATRO SENTIDOS

| o que se estraga | o que o ensaio faz |
|---|---|
| o ouvinte sai do bootstrap | vermelho nos casos **1 e 2** — volta o HTML |
| a documentação deixa de ser excluída | vermelho no **3** |
| o recorte `/api/` some, e ele pega o ERP | vermelho no **3** |
| a mensagem passa a carregar `getMessage()` | vermelho no **1** — é a asserção do vazamento |

#### UMA ARMADILHA QUE CUSTOU DUAS RODADAS, e vale para toda falsificação por HTTP

As duas primeiras tentativas deram resultados **contraditórios entre si**: a mesma
falsificação saiu vermelha numa rodada e verde na seguinte.

A causa é `opcache.revalidate_freq = 2` no PHP-FPM. Escrever o arquivo e pedir a
URL no mesmo segundo mede o **bytecode antigo**. Toda falsificação que atravessa
HTTP neste projeto precisa esperar mais de dois segundos entre a escrita e a
medição — senão ela mede o passado e chama isso de resultado.

#### E UM `attach` POR EVENTO

`EventManager::attach` desta versão do Laminas recusa lista de nomes e estoura em
`InvalidArgumentException`. Como o `onBootstrap` roda no arranque, o erro derruba
a **aplicação inteira**, não só a API — medido ao escrever o ouvinte, com o ERP
respondendo *Fatal error* em toda rota.

#### O QUE CONTINUA ABERTO: o passo 3

**O `display_errors` do PHP em produção não se confere daqui.** No contêiner de
desenvolvimento ele está ligado (`docker/php/php.ini`), e por isso um pedido com
data inválida responde **200** com quatro `Warning` do PHP colados na frente do
JSON — incluindo o caminho absoluto do arquivo. Se produção o tem ligado, o mesmo
acontece lá.

Produção não está sob controle de versão (**D37**) e não se toca até a janela do
corte. Fica como item do procedimento, e não como código a escrever.

**Ressalva:** o ensaio está no CI e **ainda não executou lá** — o CI está parado
por cobrança da conta desde 13/09 (**D187**). Foi rodado à mão, 10 de 10, e a
falsificação também.

### D17 — Mapeamento Doctrine divergente do banco — CORRIGIDO

`orm:schema-tool:update --dump-sql` propõe **260 comandos**, dos quais **114 são
destrutivos**. A causa é que muitas relações estão anotadas como `@ORM\OneToOne`
quando na realidade são `ManyToOne`: o Doctrine então acha que precisa criar
índice único onde hoje há índice comum.

Exemplos do que a ferramenta proporia: `ALTER TABLE clientes ... ADD UNIQUE INDEX
(unidade)` (um cliente por unidade), `ALTER TABLE agendas ... ADD UNIQUE INDEX
(cliente)` (um agendamento por cliente), `ALTER TABLE logradouros DROP tipo`.

**Rodar `orm:schema-tool:update --force` contra a produção destruiria o banco.**
Nunca executar. O comando é sugerido em vários tutoriais de Doctrine, então vale
o aviso explícito para quem entrar no projeto.

**Medido em 30/08/2026, e o número é redondo demais para ser coincidência:**
`scripts/conferir-relacoes-doctrine.php` confronta cada anotação com o índice
que a coluna realmente tem, e o resultado é **114 de 114**.

Nenhuma `@ORM\OneToOne` do sistema tem índice único que a sustente. Não é um
subconjunto a triar: são todas. E 114 é exatamente o número de comandos
destrutivos que o `schema-tool` propõe — os dois números são o mesmo fato visto
de dois lados.

**O critério é objetivo, e é de propósito.** A ferramenta não julga o domínio,
que seria opinião: ela pergunta se o banco garante unicidade sobre a coluna. Uma
relação é `OneToOne` de verdade quando há índice único; sem ele, a coluna aceita
repetição e a anotação está mentindo. É a mesma medida que o `schema-tool` usa
para propor o que propõe.

#### CORRIGIDO em 30/08/2026, e a verificação achou uma segunda forma

As 114 anotações viraram `ManyToOne` — 114 linhas trocadas em 59 arquivos, uma
substituição de um para um. Antes de trocar, conferi que nenhuma tinha
`mappedBy`, `cascade`, `orphanRemoval` ou `fetch`: as 114 eram idênticas em
forma, o que tornava a troca segura de fazer em bloco.

**O `schema-tool` continuou propondo quatro `ADD UNIQUE INDEX` depois disso**, e
foi assim que a segunda forma apareceu. Não vinham de anotação de relação e sim
de `unique=true` dentro do próprio `@ORM\JoinColumn`, em `Privilegio` e
`PrivilegioUsuario` — a mesma afirmação de unicidade escrita de outro jeito, e
portanto invisível a um roteiro que procurasse só por `OneToOne`.

As quatro eram falsas do mesmo modo: `privilegios.grupo` e
`privilegios.funcionalidade` têm índice **comum**, e a unicidade real do domínio
é a do par — que existe no schema PostgreSQL como
`UNIQUE (inquilino_id, grupo, funcionalidade)`. Afirmar unicidade em cada coluna
separadamente diria "um privilégio por grupo", que é outra coisa.

O roteiro passou a procurar as duas sintaxes. **Procurar por uma sintaxe encontra
uma sintaxe, e não o problema** — foi o `schema-tool` quem me corrigiu aqui.

**O resultado, medido nos dois estados:**

| | Antes | Depois |
|---|---:|---:|
| `ADD UNIQUE INDEX` propostos | **114** | **0** |
| Comandos totais propostos | 269 | 269 |

Os 269 comandos totais não mudaram, e isso é esperado: eles são a divergência
inteira entre mapeamento e banco, que inclui tipos de coluna, tamanhos e outras
coisas fora do escopo deste débito.

> **Correção de 30/08/2026, e é a mais importante desta página.** Este parágrafo
> terminava assim: *"o que mudou é que nenhum deles destrói dados agora — os que
> restam alteram definição, não unicidade"*.
>
> **É falso, e medido.** Dos 269, **quatro apagam coluna**, e três delas têm
> conteúdo:
>
> | Comando | Linhas | Preenchidas |
> |---|---:|---:|
> | `ALTER TABLE usuarios_acessos DROP local` | 1.880.078 | **1.804.389** |
> | `ALTER TABLE logradouros DROP tipo` | 709.440 | **709.440** |
> | `ALTER TABLE vendas DROP desconto_motivo` | 2.269.212 | 425 |
> | `ALTER TABLE usuarios DROP senha_php83` | 2.430 | 0 — sai no deploy da F1.1 (**D7**) |
>
> São **2.514.254 valores** que um `--force` destruiria hoje. E `logradouros DROP
> tipo` está nomeado como exemplo **quatro parágrafos acima**, no texto original
> deste mesmo débito — a frase corrigida contradizia a própria página.
>
> **DUAS CORREÇÕES DE 31/08/2026, e a segunda muda o sentido do número.**
>
> A primeira: `logradouros.tipo` **saiu da lista**. Não porque a coluna sumiu,
> mas porque foi **mapeada** na entidade (**D74**) — o `schema-tool` parou de
> querer apagá-la. São 3 comandos agora, não 4.
>
> A segunda: **o número está certo e engana.** Das colunas contadas, duas são
> constantes — medido:
>
> | Coluna | Preenchidas | Valores **distintos** |
> |---|---:|---:|
> | `usuarios_acessos.local` | 1.804.389 | **1** — sempre `web` |
> | `logradouros.tipo` | 709.440 | **1** — sempre `1` |
> | `vendas.desconto_motivo` | 425 | vários |
>
> Ou seja: dos 2.514.254 valores, **2.513.829 são duas constantes**. A
> informação que um `--force` destruiria de verdade são os **425** de
> `vendas.desconto_motivo`.
>
> **O aviso não muda** — nunca rodar `--force` —, mas a razão muda: não é o
> volume, é que um comando desses não distingue o que vale do que não vale, e
> ninguém quer descobrir a diferença depois.
>
> O que caiu a zero foi o `ADD UNIQUE INDEX`, que é o defeito do D17 e é o que a
> tabela acima mede. Generalizar de "os 114 índices únicos" para "nada destrói
> dados" foi um salto que ninguém verificou.
>
> **O aviso continua inteiro: nunca rodar `--force`.** E `vendas.desconto_motivo`
> merece nota, porque o nome engana: a coluna que o relatório de descontos usa é
> `vendas_produtos.desconto_motivo`, que é outra, e está mapeada. A de `vendas`
> tem 425 valores em 2,2 milhões de linhas e ninguém a lê.

**Verificação em tempo de execução:** 106 testes de caracterização passam no
MySQL e no PostgreSQL, e 35 de contrato — os mesmos números de antes da troca.
Confirma o que a análise previa: as 114 eram lado proprietário, e para esse lado
o Doctrine usa carregamento tardio nos dois tipos, gerando o mesmo SQL.

**Uma coisa que o `orm:validate-schema` acusa e NÃO é desta mudança:** relações
bidirecionais sem `inversedBy`, entre elas `Venda#pagamentos` e
`Venda#pontuacaoHistorico`. Conferi rodando a validação com a mudança guardada:
o erro é idêntico antes e depois. É pré-existente e de outra natureza.

> **Correção de 30/08/2026:** este parágrafo dizia "**duas** relações", e são
> muitas mais. Contadas: **19 entidades reprovadas, 31 queixas**. As duas de
> `Venda` eram as que apareciam na leitura daquele dia, não o total. Ver
> **D64**.

### D18 — Regras de negócio presas a ids fixos de banco — CORRIGIDO em 31/08/2026

`VendaPagamentoService` compara ids literais de `formas_pagamentos`: o 5 é
tratado como crédito do cliente e o 6 como crédito do programa de fidelidade
(linhas 64, 65 e 359).

**Ao resolver:** preservar os ids na migração é o mínimo; o correto é substituir
a comparação por uma coluna de tipo ou uma constante de configuração. Ver
`docs/anomalias-calculo.md`.

#### Medido em 30/08/2026, e o quadro mudou nos dois sentidos

O débito dizia: **"impacto direto na Fase 1 — se a carga reatribuir chaves
primárias, o sistema passa a debitar o crédito errado em silêncio"**. Essa frase
saiu daqui, porque **não é o caso desta migração**, e isso se confere no roteiro:

- as **83 tabelas** da carga trazem `id` explícito na lista de colunas do
  `\copy` — nenhuma exceção, conferido uma a uma;
- e as sequências são reajustadas depois, com um `setval` que percorre **toda**
  coluna de identidade do schema.

Chave preservada, sequência ajustada. O cenário de reatribuição silenciosa não
existe aqui.

**Mas a contagem estava curta, e para menos.** O débito nomeia três pontos, num
serviço só. São **seis**, em dois:

| Onde | O quê |
|---|---|
| `VendaPagamentoService:65,66` | formas **5** e **6** |
| `VendaPagamentoService:359` | forma **6** |
| `VendaPagamentoService:544` | formas **2** e **6** |
| `VendaService:201,206` | formas **5** e **6** |

A forma **2** — Permuta — não estava registrada em lugar nenhum.

**E há um id fixo que o débito não menciona, maior que todos os outros juntos:**

```php
// VendaPagamentoService:192
if ($venda->getCliente()->getId() != 1) {
    $this->salvarPacotes($venda);
    $this->salvarPontuacao($venda, $sessao);
    $this->gerarCupons($venda, ...);
    $this->agendaCheckout($venda);
}
```

**Uma comparação com o id 1 governa o programa de fidelidade inteiro** —
pacotes, pontuação, cupons e o checkout da agenda. O cliente 1 é o avulso, e a
regra "avulso não pontua" existe só aqui, escrita como um número.

**O que sobra do débito, então**, não é risco de corte: é opacidade. Seis
comparações que só quem conhece a tabela consegue ler, e uma sétima que é a
regra mais importante do programa de fidelidade disfarçada de `!= 1`. Continua
valendo corrigir — mas por legibilidade e por resistência a quem um dia
reordenar a tabela, e não porque a migração vá quebrá-las.

#### CORRIGIDO em 31/08/2026, por decisão sua: as sete viraram constantes

O que pesou na escolha: **a Fase 2 mantém o backend Laminas** — é fachada REST,
não reescrita. Este código não vai embora; vai ser lido e mexido por quem
construir a fachada. Diferente do D68, onde o argumento de "o legado está de
saída" valia, aqui ele não vale.

| Constante | Valor | Nome na tabela |
|---|---|---|
| `FormaPagamento::PERMUTA` | 2 | Permuta |
| `FormaPagamento::CREDITO_CLIENTE` | 5 | Crédito Cliente |
| `FormaPagamento::CREDITO_PROGRAMA_FIDELIDADE` | 6 | Crédito Prog. Fidelidade |
| `Cliente::AVULSO` | 1 | — |

**Os nomes vieram da tabela, não do comentário.** Consultei
`formas_pagamentos` em 31/08/2026 antes de batizar: batizar constante a partir
do que um comentário afirma seria repetir o erro que ela corrige.

**O cliente 1 é a exceção, e vale dizer como foi confirmado.** Na cópia
anonimizada ele se chama "Cliente 1" — o nome real foi apagado, então a tabela
não confirma nada. Quem confirma é `ClienteAvulsoTest`, que afirma em asserção
que *"o cliente de id 1 é o avulso e não entra no programa de fidelidade"* e
falha se isso mudar.

**Nenhuma mudança de comportamento**: os valores são os mesmos, e a suíte de
caracterização — que existe justamente para registrar o comportamento de hoje,
certo ou errado — passa com as mesmas 203 asserções.

Uma varredura larga por `getFormaPagamento()->getId() == <número>` e
`getCliente()->getId() != <número>` no `module/` inteiro não achou um oitavo
caso.

**O que NÃO foi feito, e continua sendo o certo de verdade:** uma coluna de tipo
em `formas_pagamentos`, para a comparação não depender de id nenhum. Constante
nomeada resiste ao leitor distraído; só a coluna resiste a quem reordenar a
tabela. É mudança de schema — entra no `mysql.sql`, no `postgres/schema.sql` e
na carga —, e schema se mexe no corte.

### D19 — `module/ISApi/src/Entity` não existia — CORRIGIDO em 11/09/2026

O `module.config.php` do ISApi declarava um driver Doctrine apontando para um
diretório de entidades que não existe no projeto. Qualquer comando que enumere o
mapeamento completo falha com `MappingException`. Foi criado um `.gitkeep` para
destravar as ferramentas.

**Ao resolver:** o módulo não tem entidades próprias; o certo é remover a
declaração do driver do `module.config.php`. Não foi feito na hora por ser
alteração de configuração sem cobertura de teste.

#### O `.gitkeep` calou o sintoma e deixou a causa

Ele fazia as ferramentas pararem de reclamar, e por isso ninguém voltou: a
configuração seguiu **afirmando que o módulo tem entidades**, com um diretório
vazio existindo só para sustentar a afirmação. O ISApi é controlador, serviço e
rota — e nada mais.

#### A cobertura veio ANTES da remoção, e era vermelha com ela no lugar

`tests/Caracterizacao/MapeamentoDoctrineTest.php`. A afirmação que importa não é
"o mapeamento enumera" — com o `.gitkeep`, enumerava. É **todo namespace
declarado mapear ao menos uma entidade**. Medido antes de mexer:

| namespace do driver | entidades |
|---|---:|
| **`ISApi\Entity`** | **0** |
| `ISBase\Entity` | 4 |
| `ISCadastro\Entity` | 15 |
| `ISConfiguracao\Entity` | 44 |
| `ISFinanceiro\Entity` | 4 |
| `ISMarketing\Entity` | 5 |
| `ISRelatorio\Entity` | 5 |
| `ISVenda\Entity` | 6 |

Oito namespaces, 83 entidades, e um driver que não mapeia nada — o único. Com o
teste escrito e o driver ainda declarado, a suíte deu **423 testes, 1 vermelho**,
e o vermelho nomeava `ISApi\Entity`. Depois da remoção: **423 verdes**. Não é
teste de desejo — ele nasceu reprovando o que existia.

A cadeia de drivers vem do `EntityManager`
(`getConfiguration()->getMetadataDriverImpl()`, um `MappingDriverChain`), e não da
leitura do arquivo de configuração: assim o teste prova o que está **em uso**, e
não o que está escrito.

#### O que saiu, e o que o sintoma original faz agora

Saíram o bloco `doctrine` do `module/ISApi/config/module.config.php` — com uma
lápide no lugar, explicando por que este é o único módulo sem ele —, o `use` do
`AnnotationDriver`, que era o único uso no arquivo, e o diretório com seu
`.gitkeep`.

**Provado por execução, sem o diretório:** `orm:validate-schema` responde *"The
mapping files are correct"* e `orm:info` lista as 83 entidades com `[OK]`. A
`MappingException` que motivou o `.gitkeep` não acontece mais — o que a evitava
era o diretório; o que a resolveu foi tirar quem a pedia.

O `orm:validate-schema` continua saindo com 1 por causa da seção **Database**
(*"not in sync with the current mapping"*), que é outro assunto — as 98 colunas do
**D65** — e o portão `conferir-schema-tool.sh` confirma *"igual à linha de base"*
depois desta mudança.

**E o portão de referências da documentação reprovou, com razão:** o título deste
débito cita o caminho que acabou de deixar de existir. A citação fica — ela é o
assunto do débito —, e o caminho entrou em `$explicadas` de
`scripts/conferir-referencias-docs.php` **com o motivo**, que é a porta de saída
que aquele roteiro projetou para exatamente este caso.

### D20 — A sangria acima do saldo era aceita — CORRIGIDO; restam 56 caixas negativos

Em `ISVenda\Controller\CaixasController::validarFormRetirada` (linha 129):

```php
} else if (empty($caixa) && $caixa->getValorFinal(false) < $valor) {
    $validacao .= "Valor indiponível.";
}
```

A condição deveria ser `!empty($caixa)`. Como está, há dois defeitos:

1. **Quando o caixa existe**, `empty($caixa)` é falso e o curto-circuito descarta
   a comparação de saldo. **A retirada acima do saldo é aceita** e o caixa fica
   negativo. Confirmado por
   `CaixaTest::testRetiradaMaiorQueOSaldoDeixaOCaixaNegativo`: saldo de 100,00,
   retirada de 250,00, caixa em −150,00.
2. **Quando não há caixa aberto**, `empty($caixa)` é verdadeiro e o PHP avalia
   `$caixa->getValorFinal(false)` sobre `null` — erro fatal em vez da mensagem
   "Caixa fechado" que a função pretendia exibir.

`CaixaService::retirada` não valida nada por conta própria, então não há segunda
barreira.

**O motivo de adiar era a caracterização, e ele expirou.** Escrevi aqui que
corrigir durante a caracterização invalidaria a comparação entre o sistema atual
e o novo. A caracterização está feita: os 89 testes são a linha de base, e
`CaixaTest::testRetiradaMaiorQueOSaldoDeixaOCaixaNegativo` congela justamente
este comportamento — ele falhar é o sinal combinado de que a correção foi
aplicada, não um acidente.

**O que faltava era um número, e ele está medido** (29/08/2026, na cópia
anonimizada — a anonimização reescreve dado pessoal, não valor financeiro):

| | |
|---|---:|
| Caixas negativos | **56** |
| De um total de | 125.771 — **0,04%** |
| Pior saldo | **−R$ 1.036,80** |
| Soma dos negativos | −R$ 6.736,65 |

E a distribuição diz mais que o total:

| Ano | Negativos |
|---|---:|
| 2016–2017 | 6 |
| **2018** | **41** |
| 2019–2020 | 2 |
| 2021–2023 | 0 |
| 2024–2026 | 7 |

**73% estão em 2018.** Foi um episódio, não um regime — e desde então acontece
umas duas ou três vezes por ano. O mais recente é de 23/05/2026.

**A correção foi aplicada em 29/08/2026**, por decisão sua — e não foi de um
caractere só, porque um caractere não bastava.

**O `!empty` no controller conserta a tela.** Os dois defeitos de uma vez: a
retirada acima do saldo passa a ser recusada, e o caso "caixa fechado" exibe a
mensagem em vez de chamar um método sobre `null`.

**Mas o serviço não validava nada, e é por ele que os testes passam.** Foi assim
que este defeito atravessou a caracterização inteira: `CaixaTest` fala com
`CaixaService::retirada()`, nunca com o controller — corrigir só a tela deixaria
a correção sem cobertura nenhuma, e o serviço seguiria aceitando.

Então a trava foi para o **serviço**, pelo mesmo motivo do D58: protege qualquer
chamador, inclusive um endpoint REST da Fase 2 que chame o serviço direto e passe
por cima da tela. Devolve `false`, e o controller já atribuía esse retorno a
`sucesso` — a recusa chega à tela sem mudança nenhuma lá.

**Retirada igual ao saldo continua permitida**, e isso tem teste próprio: é a
sangria de fim de expediente, e recusá-la transformaria a correção numa regressão
de operação — o caixa não conseguiria fechar em zero.

**Dois testes mudaram, e por motivos diferentes:**

`CaixaTest::testRetiradaMaiorQueOSaldoDeixaOCaixaNegativo` virou
`testRetiradaMaiorQueOSaldoERecusada`. Ele existia para congelar o defeito, e
trazia o aviso *"se esta asserção falhar, alguém corrigiu a validação de saldo"*.
Foi o que aconteceu.

`CaixaAberturaFechamentoTest::testReconciliacaoSubtraiAsRetiradas` zerava o caixa
e retirava 30,00 — **uma premissa que só funcionava porque a regra não era
cumprida**. O `valor_final` inicial passou de 0 para 100. O que o teste mede não
mudou, porque `atualizar()` recalcula do início: 100 + 500 − 30 = 570, igual a
antes.

#### Os 56 já negativos — DECIDIDO em 31/08/2026: ficam

**Por decisão sua, os 56 caixas negativos ficam como estão.** São registro
histórico do que de fato aconteceu no caixa, e corrigi-los reescreveria
exercícios fechados — o que costuma ter implicação fiscal e é assunto de
contabilidade, não de código.

O tamanho sustenta a escolha: **0,04% dos caixas**, R$ 6.736,65 acumulados em
dez anos, e 73% deles concentrados num único episódio de 2018. A trava que foi
para o `CaixaService` em 29/08 impede que a lista cresça, e é ela que fecha o
débito de engenharia.

**Este débito está encerrado nos dois lados**: o defeito foi corrigido, e o
resíduo foi decidido.

### D21 — RETIRADO: os pontos de fidelidade acumulam normalmente

**Este débito foi registrado por engano em 16/08/2026 e está cancelado.**

O registro afirmava que o saldo de pontos não acumulava a partir da segunda
compra, com base em `salvarPontuacao()` só atribuir pontos ao criar o registro
de crédito. A observação sobre o serviço estava certa; **a conclusão sobre o
sistema estava errada**.

O que faltou verificar: `VendasPagamentosController` chama
`ClienteService::atualizarPontuacao()` logo depois de `finalizar()`, e essa
função **recalcula o saldo somando o histórico válido**. O saldo, portanto, se
mantém correto a cada venda. Confirmado por
`FidelidadePontuacaoTest::testOrquestracaoCompletaAcumulaOSaldoCorretamente`:
duas compras de 90 pontos resultam em saldo 180.

O erro veio de caracterizar o serviço isoladamente e concluir sobre o sistema
inteiro. `atualizarPontuacao()` é chamada em quatro pontos — o fechamento de
venda, o cancelamento, a tela de clientes e o cron de vencimento.

**Não há passivo de pontos a acertar.** A orientação anterior de comparar
`clientes_creditos.pontuacao` com a soma do histórico para medir divergência
não se aplica.

O que **permanece válido** como observação de projeto: o serviço de fechamento
não mantém o saldo sozinho, e depende de o chamador executar o segundo passo.
Uma API REST nova que chame apenas `finalizar()` reproduziria exatamente o
defeito que este débito descrevia. Está caracterizado em
`testFinalizarSozinhoNaoAtualizaOSaldoNaSegundaCompra` e registrado em
`anomalias-calculo.md`.

### D22 — Campos de Lava VIP gravados cruzados nos royalties — CORRIGIDO

Em `FranquiaService::royaltiesAtualizar()`, ao persistir a linha de royalties:

```php
$royalties->setRetLavavip($faturamento['fat_lavavip']);   // Ret recebe FAT
$royalties->setFatLavavip($faturamento['ret_lavavip']);   // Fat recebe RET
```

Todos os outros pares seguem `setFatX(fat_x)` / `setRetX(ret_x)`. **Só o Lava VIP
está invertido.** Com alíquota de 5%, uma lavagem de 40,00 grava faturamento
2,00 e retenção 40,00 — os valores trocados de lugar. Confirmado por
`RoyaltiesTest::testCamposDeLavaVipSaoGravadosCruzados`.

**Escopo do estrago — a cobrança NÃO é afetada.** A fatura
(`RoyaltiesFatura.valor`) usa `ret_total`, que é somado dentro de
`royaltiesCalcular()` **antes** do cruzamento, a partir dos valores corretos. O
que fica errado é a tabela `royalties` persistida, lida pelo relatório de
franquias em `FranquiasController` (por volta da linha 216) — nesse caminho, as
colunas de Lava VIP aparecem trocadas.

O mesmo controller tem um segundo caminho (linhas 144–191) que **recalcula tudo
do zero**, com os valores corretos. Ou seja, o relatório pode mostrar números
diferentes para o mesmo período dependendo de qual caminho é exercitado.

**Corrigido em 29/08/2026**, e a parte mais útil foi o que a medição prévia
mostrou: **não havia histórico a recalcular.**

| | |
|---|---:|
| Linhas de royalties | 32.544 |
| **Com valor de Lava VIP** | **0** |
| Produtos do tipo `lavavip` | 20, dos quais **19 inativos** |
| Itens Lava VIP vendidos na história | **7** |
| Último deles | 2022 — a tabela de royalties começa em 2024 |

A categoria está praticamente aposentada: sobra **um produto ativo, numa
unidade**. O cruzamento existia no código e nunca teve dado para cruzar. Corrigir
é prevenção, não reparo.

**Um achado de brinde, e ele não é sobre o cruzamento.** Entre os sete itens
vendidos como Lava VIP estão três de **"CHOCOLATE TRENTO"** e um produto
"AMENDOIM" cadastrado na mesma categoria. O `tipo` do produto é o que define a
alíquota de royalty aplicada, então **um produto mal classificado manda receita
para o balde errado** — chocolate rendendo alíquota de lava-jato. Os dois estão
inativos hoje, e o estrago foi de R$ 15,00 em 2022. Fica registrado porque a
causa continua possível: nada impede classificar um chocolate como lavagem.

O teste `testCamposDeLavaVipSaoGravadosCruzados` virou
`testCamposDeLavaVipVaoParaAColunaCerta`.

### D23 — Cálculo de royalties duplicado entre serviço e controller — CORRIGIDO

A mesma lógica de apuração existe em `FranquiaService::royaltiesCalcular()` e,
copiada, em `ISRelatorio\Controller\FranquiasController` (linhas 144–191).
Foi essa duplicação que permitiu ao D22 passar despercebido: um dos caminhos
grava cruzado, o outro recalcula certo.

**Corrigido em 30/08/2026.** O controller passou a chamar o serviço, e as duas
cópias saíram — **84 linhas**, de 237 para 153 no arquivo.

**Conferi por diff antes de remover**, em vez de confiar na palavra "copiada":
as únicas diferenças entre as duas versões eram uma linha em branco e a palavra
`private`. Eram cópias byte a byte, e eram **duas**, não uma — o controller
duplicava também o `royaltiesCalcularCache()`.

**Uma imprecisão deste débito e do D22, corrigida ao medir.** Os dois diziam que
o relatório "pode mostrar números diferentes para o mesmo período dependendo do
caminho". Não pode: a escolha é **por ano** — de 2024 em diante lê o cache,
antes disso recalcula —, e a tabela `royalties` começa justamente em 2024. Um
período não sorteia caminho.

Isso não torna a duplicação inofensiva; torna-a **mais duradoura**. Como os dois
caminhos nunca respondem à mesma pergunta, ninguém vê as duas respostas lado a
lado — e a divergência do D22 podia durar indefinidamente sem que nada a
denunciasse.

**Ficou um teste amarrando os dois:**
`RoyaltiesTest::testOsDoisCaminhosDoRelatorioConcordam` compara campo a campo o
que cada apuração devolve para o mesmo período. Conferi que ele acusa:
reintroduzindo o cruzamento do D22, falha. Divergirem de novo passa a ser falha
de teste, e não descoberta de alguém meses depois olhando um relatório estranho.

### D24 — Consultas com `GROUP BY` incompatíveis com o PostgreSQL — ZERADO em 05/09/2026

O código tem consultas que projetam colunas não agregadas fora do `GROUP BY`.
Isso é aceito porque a produção roda **sem `ONLY_FULL_GROUP_BY`** — confirmado
por evidência: o `sql_mode` gravado nos dois eventos do banco (ver D26) não
inclui esse modo.

**O PostgreSQL sempre exige agrupamento completo e não tem modo permissivo.**
Não é ajuste de dialeto; é reescrita de consulta.

#### Inventário (auditoria de 16/08/2026)

`scripts/auditar-group-by.php` varre os repositórios por método e classifica as
**71 consultas** com `GROUP BY`:

| Classe | Qtd. | Significado |
|---|---:|---|
| **RISCO ALTO** | 4 | Projetam a entidade inteira agrupando por algo que não é a chave primária dela. Quebram com certeza. |
| **REVISAR** | 29 | Projetam campos de outros aliases fora do agrupamento. Cada uma precisa de decisão. |
| OK | 38 | Só agregações, ou dependência funcional da chave primária — o PostgreSQL aceita. |

As quatro de risco alto:

- `ISCadastro/Entity/AgendaRepository.php:172` — `SELECT age ... GROUP BY pro.id`
- `ISRelatorio/Entity/DashboardColaboradorRepository.php:87` — `GROUP BY col.id`
- `ISRelatorio/Entity/DashboardColaboradorRepository.php:134` — `GROUP BY das.colaborador`
- `ISVenda/Entity/VendaProdutoRepository.php:291` — `GROUP BY pro.id`

#### Confirmação por execução (navegação no ERP, 17/08/2026)

Com o modo estrito ligado, logado no ERP sobre a base real anonimizada, foram
varridos os 18 relatórios e as telas de agenda, clientes e comandas. Resultado:

| Confirmado quebrando | Como |
|---|---|
| `VendaProdutoRepository:291` | geração de cupons no fechamento de venda (suíte) |
| `DashboardColaboradorRepository:87` | **relatório Dashboard de Colaboradores devolve HTTP 500** |

O segundo é o mais eloquente: `SELECT` da entidade do dashboard com
`GROUP BY u1_.id`. É uma tela que o franqueado usa, e que **deixaria de abrir**
no PostgreSQL.

As outras duas de risco alto (`AgendaRepository:172` e
`ClienteRepository:410`, além de `UnidadeRepository:290` e `VendaRepository:781`)
não foram alcançadas: vivem em ações que exigem parâmetros específicos ou em
telas cujo `/index` só mostra o formulário de filtro, executando a consulta
pesada apenas na submissão.

**Isso não as inocenta** — significa que a varredura por navegação é rasa por
natureza. A análise estática continua sendo o inventário; a navegação serve para
confirmar e para medir o impacto visível.

Um achado lateral da varredura: `/mod-relatorios/rel-franquias/index` devolve 500
por template de view ausente — o controller tem apenas as ações `potenciais` e
`royalties`. Não é problema de SQL, mas é uma rota morta publicada.

#### Como reproduzir e acompanhar

O auditor roda no CI a cada push, sem falhar o build — é inventário de trabalho,
não regressão. Para ver o detalhe de cada consulta:

```bash
docker compose exec app php scripts/auditar-group-by.php --detalhe
```

Para o teste dinâmico, que pega o que a análise estática não vê, comente a linha
`command:` do serviço `mysql` no `docker-compose.yml` (ou rode
`SET GLOBAL sql_mode='ONLY_FULL_GROUP_BY,...'`) e exercite o sistema. A
cobertura melhora bastante com a base anonimizada carregada, porque muitos
relatórios só executam a consulta pesada quando há dados.

**Ao resolver:** as 4 de risco alto primeiro, depois triar as 29. A regra do
PostgreSQL é simples — ou o campo está no `GROUP BY`, ou está dentro de uma
agregação, ou depende funcionalmente de uma chave primária que está no
`GROUP BY`.

#### ZERADO, e o inventário virou portão — 05/09/2026

| | |
|---|---:|
| consultas com agrupamento | 105 |
| **que o PostgreSQL recusa** | **0** |
| suprimidas, com motivo escrito | 4 |
| aprovadas | 101 |

**O passo do CI deixou de ser tolerado.** Ele nascera com `continue-on-error`
porque era *inventário*: havia quebras, e o número servia para acompanhar a
redução da lista. Inventário que chegou a zero vira guarda — a partir daqui uma
consulta nova incompatível **derruba o build** em vez de virar mais uma linha de
relatório que ninguém lê.

*Posto à prova antes de confiar:* quebrei uma consulta de propósito
(`IndicacaoRepository::listarPorClienteIndicador`, com `GROUP BY ind.unidade` e
colunas nuas ao lado) e o passo saiu com **1**; restaurada, voltou a **0**.

##### A quarta supressão, e o alarme falso que ela desfaz

A quarta linha não é uma quebra dispensada: é a **única consulta do sistema
montada por `QueryBuilder`**, e por isso o auditor não conseguia fatiá-la — ele
lê DQL em texto.

Ela aparecia sob o cabeçalho *"NÃO FOI POSSÍVEL LER — não estão aprovadas, estão
fora da análise, o que é pior"*, sendo um `COUNT(vis.id)` com quatro filtros,
**sem `GROUP BY` nenhum** e sem outra coluna projetada. Agregado sozinho é válido
nos dois bancos: risco zero.

**Alarme falso num portão ensina a ignorar o portão.** O auditor passou a honrar
a marcação `@agrupamento-conferido` também no ramo em que desiste de ler — a
justificativa já era exigida com no mínimo 40 caracteres, e agora vale nos dois
ramos.

*A marcação ficou dentro do método, e não no bloco de documentação:* o auditor lê
o trecho a partir da linha da assinatura, então um comentário acima dela não é
visto. Descobri isso porque a primeira tentativa não surtiu efeito nenhum.

*O visagismo continua fora de qualquer trabalho de migração, por decisão sua* —
o que entrou ali foi um comentário, e nenhuma linha de código.
### D25 — `expirarPontuacao()` depende de quem rodou antes — CORRIGIDO em 30/08/2026

`PontuacaoHistoricoRepository::expirarPontuacao()` monta um DQL com
`DATE(his.dataCriacao)` mas **não registra** a função customizada
`ISBase\Doctrine\DateFunction`. Quem registra é o método irmão
`selecionarCientesPontuacaoVencida()`.

O vencimento de pontos só funciona hoje porque o cron chama os dois nessa ordem:

```php
$clientes = $rep->selecionarCientesPontuacaoVencida();   // registra DATE
$rep->expirarPontuacao();                                 // depende do registro
```

O registro fica na **configuração do EntityManager, que é global e persistente**
no processo. Por isso a falha é intermitente: depende do que mais rodou antes na
mesma requisição. Inverter as duas linhas, ou chamar a expiração de qualquer
outro ponto, quebra o vencimento com `QueryException`.

Descoberto porque o teste isolado falhava e o mesmo teste passava na suíte
completa — outro caso já havia registrado a função.

**Corrigido em 30/08/2026 pelo caminho mais amplo**, que era o certo: as oito
funções de DQL do sistema passaram a ser declaradas em
`config/autoload/doctrine.global.php`. A ordem de chamada deixou de ser fator —
elas existem desde a criação do EntityManager, para todo mundo.

A auditoria sugerida aqui deu o tamanho do padrão: **154 chamadas de
`addCustomDatetimeFunction()` em 30 arquivos**, para oito funções distintas.
Nenhuma registrava classe diferente para o mesmo nome, o que tornou a
centralização segura.

**As 154 chamadas foram removidas em seguida**, em mudança separada — 227 linhas
a menos em 29 arquivos. A separação se pagou: a remoção fez cair um teste, e ter
sido a única mudança daquele commit tornou a causa imediata.

O teste que caiu era `testApenasUmDosDoisMetodosRegistraAFuncaoDate`, em
`FidelidadeResgateVencimentoTest`, e ele **congelava este próprio débito** —
afirmava que um método registra a função DATE e o outro não.

Foi invertido em vez de apagado: agora garante que **nenhum dos dois** registre,
porque reintroduzir o registro em tempo de execução traria de volta o padrão em
que a ordem de chamada importa. A declaração global passou a ser a fonte única, e
esse teste é o que impede a volta silenciosa da outra.

A remoção não foi um não-evento, portanto. Foi verificada por um teste que
existia justamente para notá-la.

**Cobertura:** `tests/Caracterizacao/FuncoesDqlDeclaradasTest.php`. Seis casos
exercitam cada função em DQL real, e um sétimo chama `expirarPontuacao()`
**sozinha** — que é o sintoma original deste débito.

Vale registrar o que tornava esse sintoma tão difícil: **o caso isolado falhava e
o mesmo caso passava na suíte completa**, porque outro teste já havia registrado
a função. Uma falha que some quando se roda tudo é a falha que ninguém
investiga.

#### O TÍTULO FICOU SEM MARCA POR QUINZE DIAS — e a régua o contava como aberto

O corpo deste registro diz **"Corrigido em 30/08/2026"** desde o dia do conserto.
O título não dizia, e `scripts/contar-debitos.php` lê o título. Resultado: um
débito fechado em agosto contado entre os abertos até 14/09/2026.

Não é detalhe de contabilidade. É a mesma família do **D186** — *o número que a
régua dá depende de onde ela olha* —, e a lição prática é curta: **fechar um
débito inclui marcar o título**, porque o corpo não é lido por régua nenhuma.

#### E A GUARDA ERA MAIS ESTREITA DO QUE A FRASE QUE ELA DEFENDE

`testNenhumDosDoisMetodosRegistraFuncaoDeDql` diz, no próprio comentário, que
protege contra *"alguém reintroduzir o registro em tempo de execução"*. A
afirmação é sobre o sistema; a checagem lia **um arquivo** —
`PontuacaoHistoricoRepository.php`.

As 154 chamadas removidas em 30/08 vinham de dezenas de repositórios, e **34
arquivos de `module/` usam função de data em DQL**. Qualquer um deles podia
reintroduzir o registro, e o padrão *"a ordem de chamada importa"* voltaria
inteiro sem aquele teste ver.

Agora há `testNenhumArquivoDoSistemaRegistraFuncaoDeDqlEmTempoDeExecucao`, que
varre `module/` inteiro. **Ele lê por token, e não por texto:**
`ClienteCreditoRepository.php:59` tem a chamada dentro de um método
**comentado**, e uma varredura por `grep` acusaria código morto.

**Falsificado:** reintroduzir a chamada em `VendaRepository.php` — um arquivo que
a guarda antiga não alcançava — deixa a suíte vermelha nomeando o arquivo. E a
varredura tem piso: menos de 500 arquivos e ela falha, porque verde por ausência
de arquivo não é verde.

#### O QUE O DEFEITO CUSTAVA, medido

`expirarPontuacao()` tocaria **4.388 linhas, de 3.902 clientes, somando 368.274
pontos** — medido em 14/09/2026. As 64 unidades têm todas o mesmo prazo, 365
dias, então formam **um único grupo**: uma só exceção derrubava a rotina inteira.
Não é "os pontos não expiram para alguns": é a expiração inteira parada.

**E o número cresce todo dia, por construção.** O corte é
`DATE(NOW() - INTERVAL 365 DAY)`, então cada virada de meia-noite traz mais um dia
de pontuação de um ano atrás para dentro da faixa vencida. Medindo a mesma
consulta na véspera dava 3.426 linhas; **962 linhas entraram em um dia**. Enquanto
a rotina não roda, a fila não fica parada — ela engorda.

### D26 — Dois eventos agendados dentro do banco — PREPARADO em 07/11/2026: as duas limpezas viraram rotinas

O dump completo de 16/08/2026 revelou dois `EVENT` do MySQL, invisíveis para o
código da aplicação:

| Evento | Frequência | O que faz | Estado |
|---|---|---|---|
| `limpar_push_app_24h` | a cada 1 hora | `DELETE FROM push_app WHERE data < NOW() - 24h` | `ENABLE` |
| `evt_agendas_exclusoes_limpar` | a cada 1 dia | `DELETE FROM agendas_exclusoes WHERE data_exclusao < NOW() - 60 dias` | `DISABLE ON SLAVE` |

Não há trigger, procedure, function nem view — só estes dois.

**Por que importa na Fase 1:** o PostgreSQL **não tem event scheduler**. Não há
tradução automática; é preciso `pg_cron`, um agendador externo ou mover a rotina
para a aplicação. Como ambos fazem retenção de dados, perdê-los na migração faz
as duas tabelas crescerem sem limite — silenciosamente, porque nada quebra.

Dois detalhes que o dump também entregou:

- O `DEFINER` de ambos é **`andre`@`%`**, um usuário de banco que não aparecia em
  nenhum inventário. Entra na apuração do D3 e do D9 (quem tem acesso ao banco).
- O `sql_mode` gravado nos eventos é
  `STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`,
  **sem `ONLY_FULL_GROUP_BY`**. Isso confirma por evidência o que o D24 até então
  apenas inferia, e valida a configuração adotada no ambiente de desenvolvimento.

**Apurado em 19/08/2026.** O `@@event_scheduler` está **ON**, mas os dois eventos
não estão no mesmo estado:

| Evento | Estado | Intervalo | Última execução |
|---|---|---|---|
| `limpar_push_app_24h` | `ENABLED` | 1 hora | 19/08/2026 09:35 |
| `evt_agendas_exclusoes_limpar` | **`SLAVESIDE_DISABLED`** | 1 dia | **nunca** |

#### Apurado em 19/08: a causa está na própria definição

O responsável confirmou que este servidor é o **master**, com slaves ligados a
ele, e que nunca foi promovido. A explicação não é linhagem de réplica — está
escrita no evento:

```sql
EVENT `evt_agendas_exclusoes_limpar` ON SCHEDULE EVERY 1 DAY
  ... DISABLE ON SLAVE
  DO DELETE FROM agendas_exclusoes WHERE data_exclusao < DATE_SUB(NOW(), INTERVAL 60 DAY)
```

A cláusula `DISABLE ON SLAVE` foi escrita de propósito. E o outro evento,
`limpar_push_app_24h`, **não a tem** — é justamente ele que está `ENABLED` e
executando de hora em hora. A diferença entre os dois é essa linha.

**O efeito independe da causa: a limpeza nunca rodou.** Está agendada desde
26/03/2026, e `agendas_exclusoes` acumula desde então.

#### Cuidado ao ligar: são três réplicas do outro lado

Habilitar é uma linha:

```sql
ALTER EVENT evt_agendas_exclusoes_limpar ENABLE;
```

**Mas não faça isso antes de contar.** A primeira execução apagaria de uma vez
tudo que passou de 60 dias desde março — num único `DELETE`, que vira um único
evento de binlog e atravessa as três réplicas. `DELETE` grande em master com
réplicas é causa clássica de atraso de replicação, e o atraso aqui atinge o modo
degradado de contingência.

Contar primeiro:

```sql
SELECT COUNT(*) AS seriam_apagadas,
       MIN(data_exclusao) AS mais_antiga
  FROM agendas_exclusoes
 WHERE data_exclusao < DATE_SUB(NOW(), INTERVAL 60 DAY);
```

Se o número for grande, apagar em lotes **antes** de habilitar o evento — assim
a primeira execução automática já encontra pouca coisa:

```sql
DELETE FROM agendas_exclusoes
 WHERE data_exclusao < DATE_SUB(NOW(), INTERVAL 60 DAY)
 LIMIT 5000;   -- repetir até zerar, observando o atraso das réplicas
```

#### A contagem foi feita em 30/08/2026, e ela contradiz este débito

Rodada na cópia anonimizada — que é um retrato de **16/08/2026**, não a produção
de hoje:

| | |
|---|---:|
| Linhas em `agendas_exclusoes` | 20.608 |
| Com mais de 60 dias | 4.105 |
| **Mais antiga** | **2026-06-17 15:44** |
| Mais recente | 2026-08-16 18:57 |

**O débito diz que a tabela "acumula desde 26/03/2026". Não acumula:** não há uma
única linha anterior a 17/06. E a data não é qualquer uma — é **exatamente
sessenta dias** antes de 16/08 às 15:44.

**Não é a funcionalidade estreando em junho**, e a distribuição por dia mostra
por quê:

```
2026-06-17    105     <- dia parcial, cortado as 15:44
2026-06-18    334
2026-06-19    503
2026-06-20    337
```

Estreia teria rampa de adoção. Aqui o primeiro dia é **truncado** e o segundo já
está em volume pleno. É a assinatura de um `DELETE` de retenção que rodou, e não
de uma tabela que começou a encher.

**Também não é a coleta que filtrou:** `anonimizar.sql.modelo` só anula
`observacao` nessa tabela, e não há recorte por data na extração. E o código da
aplicação apenas **insere** — `AgendaService::arquivarAgendaExclusao()` é a única
escrita, e não há `DELETE` em lugar nenhum.

**A conclusão que a evidência sustenta:** alguma coisa apagou as linhas com mais
de 60 dias, em 16/08 por volta das 15:44. **O que não dá para saber daqui é o
quê** — o evento continua `SLAVESIDE_DISABLED`, então ou alguém o executou à mão,
ou existe um `cron` no sistema operacional fora do inventário.

**Ao ler a produção de novo, conferir primeiro:**

```sql
SELECT MIN(data_exclusao), MAX(data_exclusao), COUNT(*) FROM agendas_exclusoes;
SHOW EVENTS;   -- LAST_EXECUTED de evt_agendas_exclusoes_limpar
```

Se o mínimo continuar colado nos 60 dias, a limpeza está acontecendo por um
caminho que este projeto não mapeou — e **um apagador não inventariado é pior que
um apagador desligado**, porque some na migração sem que ninguém saiba que
existia.

**O que isso NÃO muda:** o PostgreSQL continua sem event scheduler, e a retenção
das duas tabelas continua precisando de dono explícito depois do corte. A
pergunta que mudou é se hoje ela já tem um, e qual.


O primeiro roda de verdade e a F1.6 precisa migrá-lo. **O segundo nunca
executou** — a limpeza de `agendas_exclusoes` que ele deveria fazer não acontece
desde que foi criado.

E o estado dele diz mais: `SLAVESIDE_DISABLED` é o que o MySQL marca num evento
que **chegou por replicação**. Ou seja, este banco tem linhagem de réplica — foi
promovido, restaurado a partir de uma, ou ainda replica de algum lugar. **Vale
esclarecer qual é o caso**, porque isso toca diretamente o desenho de redundância
da F1.7.

**Ao resolver:** decidir o destino de cada rotina antes da virada.

#### A resposta 127 do dono (12/10/2026), e a 168 que a cruza na fila (14/10/2026)

**Decidido pelo dono em 12/10/2026** (a pergunta 127 do lote, a opção (a), a recomendada): *"As duas viram rotinas do
crontab, com os prazos de hoje -- 24 h para a fila e 60 dias para o arquivo."* Ainda não feito: nenhuma rotina do código
apaga a `push_app` por idade nem a `agendas_exclusoes` (lido em 14/10/2026). **A resposta 168 (14/10/2026, o D478) a
cruza na fila:** a mensagem de WhatsApp que falhou sai depois de 7 dias -- e a limpeza de 24 h, a deste evento e a da
rotina da 127, a apaga antes. Se a rotina da 127 poupa a falhada (a `cronjob` que começa por `falhou:`) foi ao lote
(WHATSAPP-E-APLICATIVO, a verificação de 14/10/2026); quem fizer a rotina da fila lê a resposta antes. E a sobra das 100
linhas por dia das filas do parabéns e do "sentimos sua falta" é apagada por este evento antes de ser mandada (o
**D489**).

#### As duas rotinas, escritas em 07/11/2026 (a fatia 113, ROTINAS-NOVAS-E-CRONTAB) — PREPARADO

Pelas respostas do dono 127 (a) (*"As duas viram rotinas do crontab, com os prazos de hoje"*), 184 (a) (a de 24 h poupa
a mensagem que falhou, que sai em 7 dias), 185 (a) (os 7 dias contam do pedido, como estão) e 195 (a) (as duas na etapa
1, e o evento de hora em hora desligado na mesma janela, reversível). Conferido antes: nenhuma ação do
`CronJobController` apagava a `push_app` por idade nem a `agendas_exclusoes`; só o `PushAppRepository::apagarFalhasAntigas`
(a falhada de 7 dias, o D478) existia.

- **`/api/cronjobs/pushAppLimpar`** (`CronJobService::pushAppLimpar`, `PushAppRepository::apagarPendentesAntigas`): apaga a
  linha da fila com a `data` mais antiga que agora menos `PushApp::HORAS_DA_PENDENTE_NA_FILA` (24) horas, com o `<`
  estrito do evento, **menos a falhada** (`cronjob` começando por `falhou:`). De hora em hora, aos 35 minutos, como o
  evento — e a sobra do parabéns e do "sentimos sua falta" sai antes do envio das 10:00, como hoje (o D489; a 186 (c)).
- **`/api/cronjobs/agendasExclusoesLimpar`** (`CronJobService::agendasExclusoesLimpar`): apaga o horário desmarcado com
  `data_exclusao` de mais de `CronJobService::DIAS_DO_ARQUIVO_DE_EXCLUSOES` (60) dias. Uma vez por dia, às 02:40. Pelo
  DBAL, porque a tabela não tem entidade.
- **No molde das outras:** a guarda do token primeiro (o 401 do D4), a trava (`TravaDeExecucao`, com a conexão no
  PostgreSQL) com `{"sucesso": false, "mensagem": "Processo já em execução."}`, e a linha no log das rotinas (a da fila só
  quando apagou alguma; a do arquivo todo dia, com zero também). **Em lotes de 5.000**, pelo id, e não num DELETE só: o
  aviso das três réplicas deste débito. As rotas sem login na `rotas-publicas.php` e no `conferir-rotas-sem-login.php`,
  as páginas em `/api/docs/cronjobs`, o inventário de ações regerado, e as duas no `--conhecidas=` do
  `conferir-agendamento.php` no `scripts/conferir-tudo.sh` (como a dos convênios: esperam a janela).
- **As linhas** em `docs/crontab-a-aplicar.md`, na forma do crontab novo (o D331: o cabeçalho, o `-fsS`, o domínio raiz e
  o `|| logger -t rotinas`); **o passo 12 da etapa 1** as põe no crontab e desliga o `limpar_push_app_24h`.
- **O `ALTER EVENT`, medido num banco descartável** (o MySQL 8.4.13 local, apagado no fim): o `DISABLE` e o `ENABLE` mudam o
  estado, e **trocam o `DEFINER` pela conta que os roda** (`franquiabv@%` virou `root@localhost`); com `ALTER
  DEFINER=<o de antes> EVENT ...`, o `DEFINER` fica. O passo 12 e a volta da etapa 1 passaram a essa forma.
- **A cláusula sem o privilégio (a verificação da 113, medido no mesmo dia, de novo num descartável do 8.4.13, com duas
  contas descartáveis apagadas no fim):** uma conta só com o banco, diante do evento de outra (como o do `andre`), recebe
  `ERROR 1227 ... SUPER or SET_ANY_DEFINER` no `ALTER DEFINER=... EVENT ... DISABLE`, e nada muda (`ENABLED`, o mesmo
  `DEFINER`); sem a cláusula, a mesma conta desliga **e vira a dona**; e a volta com a cláusula, pela mesma conta, também
  recusa — só uma conta com o privilégio devolveu o dono. No 8.4 o privilégio é o `SET_ANY_DEFINER` (o `SET_USER_ID` do 8.0
  não existe mais). O passo 12 dizia "sem ele, o `ALTER EVENT` sem a cláusula, e o `DEFINER` novo anotado": trocar o dono de
  um objeto do banco de produção como recurso do roteiro. Agora o privilégio se confere antes da janela, e, recusada a
  cláusula na janela, a linha PARA com o evento ligado, como hoje (nada se perde além do de hoje: a falhada sai em 24 h até
  ele se desligar); o que fazer nesse caso foi ao lote (TELAS-RESTANTES [corte], 07/11/2026; recomendada a (a), o evento
  ligado até uma conta com o privilégio).

**Provado** (`tests/Caracterizacao/D26LimpezasNoCrontabTest.php`, 9 casos, nos dois bancos): a fronteira de cada prazo dos
dois lados (24 h e um segundo sai, 24 h exatas fica; 60 dias e um segundo sai, 60 dias exatos fica), a falhada poupada
(de ontem e de dezembro), a conta devolvida igual à do banco, a segunda rodada sem nada e sem linha no log, 5.001 linhas
saindo todas (os lotes), a desistência com a trava detida, e a linha do log do arquivo com zero; e, desde a verificação
da 113, **a resposta das duas rotas** (`testAsRotasDizemQueDesistiram`, com um serviço de mentira): `{"sucesso": false,
"mensagem": "Processo já em execução."}` com a trava detida, `{"sucesso": true}` com uma conta (o zero também), e o 401 sem
o token certo, sem chamar a rotina — antes, a rota que respondia sempre `sucesso: true` passava (o espião do
`SegredosForaDoCodigoTest` nunca devolve `false`; os mutantes R1 a R5, vermelhos nos dois bancos). A guarda do token e a
página da documentação vêm também do `SegredosForaDoCodigoTest`, que lê as ações e as rotas sem lista. **Não provado:** as rotinas
na produção ou no BVIP, e o `ALTER EVENT` na produção (o item 11 de "O que falta" da etapa 1); quem apara hoje a
`agendas_exclusoes` aos 60 dias (a leitura 11 da etapa 1). **O que resta:** a janela da etapa 1 — as linhas no crontab e o
evento desligado.

## Sem prazo definido

### D10 — Cobertura de testes limitada ao núcleo financeiro — REENQUADRADO em 11/09/2026: deixou de ser verdade, e o que falta é o VALOR dos relatórios — PARCIAL, lido em 05/10/2026 — PARCIAL em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: o par Visagismo::listagemIndexRelatorio, adiado pelo dono; e uma venda de cenário com mais de um item descontado, para distinguir o enraizamento no item.

> **05/10/2026, depois da nota:** a venda de dois itens descontados entrou, com caso e mutante (a seção "A VENDA DE DOIS ITENS DESCONTADOS", no fim deste débito). Falta só o Visagismo, adiado pelo dono.

*Atualizado em 17/08/2026.* O registro anterior dizia que o sistema não tinha
nenhum teste. Deixou de ser verdade: as frentes W3 e W4 entregaram **95 testes**
— 67 de caracterização sobre venda, caixa, fidelidade, royalties, convênios e
cupons, e 28 de contrato congelando o formato da API do aplicativo.

O que **permanece** em aberto é a cobertura fora desse núcleo. Agenda, cadastros,
marketing, financeiro e os 18 relatórios não têm teste nenhum. A escolha foi
deliberada: caracterizar primeiro o que envolve dinheiro, porque é onde um erro
silencioso durante a migração custa mais caro.

**Ao resolver:** conforme cada módulo for reescrito na Fase 3, o teste de
caracterização do comportamento atual vem antes da reescrita — não depois.

#### O parágrafo acima envelheceu, e o inventário de 11/09/2026 o desmente

Ele é de 17/08/2026 e diz duas coisas que deixaram de ser verdade: que são **95
testes**, e que agenda, cadastros, marketing e financeiro **não têm teste nenhum**.
Medido hoje, contando arquivos de teste que tocam cada namespace:

| | arquivos de teste | |
|---|---:|---|
| `ISCadastro` | **28** | cliente, agenda, fidelidade, indicação, endereço |
| `ISConfiguracao` | **28** | produto, unidade, usuário, convênio, pacote |
| `ISVenda` | **19** | comanda, caixa, pagamento, fiado |
| `ISBase` | 12 | utilitários, busca, data, monetário |
| `ISApi` | 6 | os endpoints do aplicativo |
| **`ISRelatorio`** | **4** | e é aqui que a frase original ainda acerta |
| **`ISFinanceiro`** | **3** | o mais magro, junto com marketing |
| **`ISMarketing`** | **3** | cupom, sorteio, NPS |

E o total: **66** arquivos de caracterização com **423 testes**, mais **3** de
contrato com **67**. Não 95.

**A agenda não tem módulo próprio** — a entidade mora em
`module/ISCadastro/src/Entity/Agenda.php`, e é por isso que a cobertura dela está
contada dentro dos 28 do `ISCadastro`: `AgendaApiTest`, `AgendamentoCriadoTest`,
`CheckinDaAgendaTest`, `DesmarcarAgendamentoTest`, `FecharAgendaTest`,
`RegraDeAgendamentoTest` e `ValidacaoDeAgendamentoTest`.

#### E os relatórios não são 18, nem estão descobertos do jeito que a frase sugere

São **19 controladores** e **36 ações vivas** no inventário
(`docs/inventario-acoes.csv`, nenhuma marcada `MORTA`) — e as 36 são exercitadas
em toda execução do CI por `scripts/medir-relatorios-no-postgres.php`, cujo
critério é *"nenhum pode quebrar"*.

Isso é cobertura de **fumaça**, e não de valor: ela prova que o relatório responde
sem erro do servidor nos dois bancos, e não que o número que ele imprime está
certo. A distinção importa, e é o que sobra de verdade deste débito:

| o que existe | o que falta |
|---|---|
| as 36 ações respondem sem quebrar, nos dois bancos, a cada execução do CI | nenhuma caracterização do **valor** que elas calculam |

As exceções são `DashboardFaturamentoAnosTest` e `RoyaltiesTest`, que caracterizam
número de relatório de verdade — e nasceram de defeito encontrado, não de plano de
cobertura.

**O que continua valendo sem mudança:** a ordem do *"ao resolver"* acima. O teste
de caracterização vem antes da reescrita, e não depois.

#### E o primeiro de valor existe, desde 12/09/2026

`tests/Caracterizacao/FluxoDeMovimentoTest.php`, seis casos sobre
`DashboardMovimentoRepository::selecionarPorMesAnoUindade()` — o relatório de fluxo
de movimento, que não tinha teste nenhum.

**Por que este primeiro:** ele é o mais sensível à migração entre os descobertos.
`FluxoMovimentoController` monta as duas datas como **texto** (`"$ano-$mes-01"` e o
último dia do mês) e a consulta compara `dataInicio >= :inicio AND dataFim <= :fim`
— com as colunas sendo `date` nos dois bancos e a anotação dizendo `datetime`, que é
um dos 70 desacordos que o D65 agora vigia. Cada lado da comparação é de um tipo
diferente do declarado, e o parâmetro chega como string: **coerção é onde os dois
motores divergem**, e é a família do D42.

**O que ele afirma, e o que deliberadamente não afirma.** Nenhum número absoluto é
congelado — a regra que o `DashboardFaturamentoAnosTest` já escreveu, porque os
números vêm da cópia e mudam a cada coleta. O que está fixo é a relação:

| | |
|---|---|
| a faixa **igual** à linha a inclui | o `>=` e o `<=` na igualdade, nas duas bordas |
| um dia depois do início | a linha sai |
| um dia antes do fim | a linha sai |
| filtrando pela unidade dela | continua achando |
| filtrando por outra unidade | não acha |
| qualquer linha devolvida | está dentro da faixa pedida |

A faixa vem do **valor lido da própria linha**, e não de um literal: a fixture grava
`CURRENT_DATE` nos dois campos (`fixtures.sql:919-923`), então ler é o que faz o
teste continuar valendo amanhã. E ele **não escreve nada** — só consulta, então não
há resíduo a restaurar.

**Falsificado:** trocando `>=` por `>` no repositório, exatamente as duas
afirmações de inclusividade falham, nomeando a linha `4178`, e as outras quatro
continuam verdes — porque tratam de exclusão e de recorte. É o sinal de que o teste
mede a fronteira, e não o caminho geral.

**E a resposta que a migração precisava:** **429 testes verdes nos dois bancos.** A
inclusividade da borda se comporta igual em MySQL e PostgreSQL — o relatório não
perde o primeiro nem o último dia do mês no corte. Isso não era sabido antes; era
suposto.

#### E o que falta deixou de ser "os 18 relatórios": são 37 métodos, com nome

"Os relatórios não têm cobertura de valor" não diz a ninguém o que fazer. Medido em
12/09/2026, o vão tem nome e tamanho.

Os controladores de relatório chamam **58** métodos de repositório, e **37
distintos não aparecem em teste nenhum**. Eles não moram no `ISRelatorio` — moram
onde o dado mora:

| módulo do repositório | métodos sem teste |
|---|---:|
| `ISConfiguracao` | 15 |
| `ISVenda` | 10 |
| `ISRelatorio` | 7 |
| `ISCadastro` | 5 |
| `ISFinanceiro` | 1 |

**Os onze que calculam dinheiro, que são por onde começar:**

| método | relatório |
|---|---|
| `totalDescontoPorCaixa` | Caixas |
| `totalTrocoPorCaixa` | Caixas |
| `selecionarPagamentosPorCaixa` | Caixas |
| `totalDescontoPeriodo` | Vendas |
| `selecionarGorjetasPorColaboradorPeriodo` | Vendas |
| `selecionarVendasProdutosPorColaboradorPeriodo` | Vendas |
| `selecionarUsuariosComVendas` | Vendas |
| `listagemIndexRelatorioDescontos` | Descontos |
| `relatorioVendasProduto` | Produtos |
| `radarTemperaturaFaturamento` | RadarTemperatura |
| `selecionarTotalizadoresPorColaboradorMesAno` | PesquisasNps |

**Os 37 são um PISO, e não um total** — duas razões, ditas porque o número sem elas
valeria menos. Primeiro: "aparece em teste" é casamento de **substring** no corpo
dos testes, que é positivo fraco — um teste que menciona o nome não necessariamente
exercita o método, então há métodos contados como cobertos que não estão. Segundo: a
detecção de chamada é `$var->metodo(` mais `getRepository(...)->metodo(`, filtrada
pelos nomes que existem como `public function` em algum `*Repository.php` — ela não
vê chamada que passe por um serviço no meio.

**A ordem que isto sugere, e que não é decisão de ninguém:** os de dinheiro primeiro,
porque erro silencioso ali sai do bolso de alguém; e dentro deles, os de `Caixas` e
`Vendas`, que são onde o D49 (dinheiro em ponto flutuante) e o D65 (tipo da coluna)
se encontram.

#### A LISTA FECHOU — e fechou num número que a própria medida não sabia contar

**12/09/2026.** Quatro arquivos novos e um estendido, 30 casos, verdes nos dois
motores. Pela medida do inventário, **43 de 43**: nenhum método da lista sem teste.

| arquivo | o que passou a ter teste |
|---|---|
| `ServicosDoFiltroNoRelatorioTest` | `ProdutoRepository::selecionarProdutos` |
| `PesquisasNpsNoRelatorioTest` (estendido) | `unidadeDetalhes`, `colaboradorDetalhes`, `selecionarUnidadesRelatorioNps`, `selecionarUsuariosRelatorio` |
| `ComboDeConveniosNoRelatorioTest` | `ConvenioRepository::popularCombobox` |
| `AtendimentosNoRelatorioTest` | `DashboardFaturamentoRepository::selecionarPorUnidadeMesAno` |

##### E OS DOIS ÚLTIMOS DA TABELA NÃO ESTAVAM NA LISTA — o nome já contava como coberto

A medida casa por **nome de método**, e o registro acima já avisava que isso é
positivo fraco. Hoje deu para medir **quanto** fraco. Dos 43 nomes, **oito existem
em mais de uma classe de repositório**:

| nome | classes que o definem | as que os relatórios realmente chamam |
|---|---:|---:|
| `popularCombobox` | **13** | 3 — `Unidade`, `Grupo`, `Convenio` |
| `relatorioSelecionarAniversariantes` | 3 | 3 |
| `listagemIndexAdmin` | 3 | 1 — `VipClass` |
| `listagemIndexRelatorio` | 2 | 2 — `Cliente`, `Visagismo` |
| `popularComboboxAnos` | 2 | 1 — `DashboardFaturamento` |
| `selecionarPorUnidadeMesAno` | 2 | 1 — `DashboardFaturamento` |
| `selecionarTotalizadoresPorColaboradorMesAno` | 2 | 1 — `NpsResultadoColaborador` |
| `selecionarTotalizadoresPorUnidadeMesAno` | 2 | 1 — `NpsResultadoUnidade` |

Resolvendo cada chamada para a classe de verdade — inclusive as quatro que passam
por variável, como `$repositoryNpsColaborador` —, a lista deixa de ter 43 nomes e
passa a ter **48 pares (classe, método)**. Medindo par a par, com teste que cite a
classe **e** o método, **três** estavam abertos:

| par | situação |
|---|---|
| `Convenio::popularCombobox` | vão real — **fechado hoje** |
| `DashboardFaturamento::selecionarPorUnidadeMesAno` | vão real — **fechado hoje** |
| `Visagismo::listagemIndexRelatorio` | **fora de escopo por decisão do dono** — "ignorar tudo do onesignal, visagismo e sms", anotado como débito futuro |

Então o fechamento honesto é **47 dos 48**, e o que falta é o que foi mandado não
fazer.

##### O CASO MAIS FEIO FOI O DO NPS, e ele tinha a forma exata do falso positivo

`selecionarTotalizadoresPorColaboradorMesAno` existe em `NpsItemRepository` e em
`NpsResultadoColaboradorRepository`. A lista do D10 é "métodos chamados pelos
controllers de relatório", o nome aparece em `PesquisasNpsController:243`, e por isso
entrou. Mas a chamada dali resolve para a **segunda** classe — e varrendo `module/`,
`public/`, `config/` e `data/`, a primeira tem **um único chamador, o
`CronJobService`**, que não é relatório nenhum.

Os cinco primeiros casos do arquivo de NPS, escritos horas antes, cobriram a classe
do **cron**. No instante em que ficaram verdes, o nome passou a contar como coberto
— e a classe que a tela usa seguiu sem um teste, com `grep -rl NpsResultadoColaborador tests/`
devolvendo vazio. É a **quarta** colisão da série, e a pior: nas outras três o nome
repetido era comportamento parecido em classes parecidas; aqui são respostas
diferentes, e o teste de uma atestou a outra.

O caso `testOsDoisHomonimosRespondemNumerosDiferentes` transforma isso em asserção:
com o mesmo colaborador, mês e ano, um devolve **2 avaliações / soma 18** e o outro
**3 linhas / soma 20**. Fica vermelho no dia em que alguém unificar as duas — e nesse
dia o registro precisa ser relido, não atualizado.

##### TRÊS ACHADOS DE COMPORTAMENTO, e cada um saiu de um caso que precisou existir

**1. O cabeçalho e o detalhe do NPS discordam sobre o `contabilizado`.** Dois métodos
do mesmo arquivo, na mesma tela: o dos totalizadores filtra `nps.contabilizado`, e o
`colaboradorDetalhes` não. Cabeçalho diz 2 avaliações e soma 18; o detalhe calcula a
média sobre 3 respostas e dá 6,67. A divergência vive na janela entre responder a
pesquisa e o cron consolidá-la, a cada 10 minutos. Está em
`docs/anomalias-calculo.md`, com a medição e a falsificação.

**2. A ordem alfabética muda entre os dois bancos.** Saiu de um caso meu escrito
errado — eu comparei a ordem do banco com o `sort()` do PHP, que é byte a byte. O
vermelho levou a comparar os dois motores: **376 das 616 posições** trazem nome
diferente, por causa de espaço e hífen, não de acento. Virou o **D175**.

**3. A regra da linha mais antiga nunca tinha sido medida.** `dashboard_faturamento`
não tem restrição de unicidade (D29), e o `ORDER BY das.id ASC` com `setMaxResults(1)`
é o que decide qual linha a tela mostra quando o par (unidade, mês, ano) duplica. A
cópia tem **zero** pares repetidos em 4.019 linhas, então o caso cria o par dentro da
transação. Falsificado: trocando `ASC` por `DESC`, só esse caso fica vermelho.

**E um quarto achado que não é de relatório:** `npsContabilizar` divide por zero se a
primeira pesquisa do mês de uma unidade não tiver item. Virou o **D176**.

##### DUAS SUSPEITAS QUE NÃO SOBREVIVERAM À MEDIÇÃO

Escritas porque custaram tempo e para ninguém refazer o caminho.

| suspeita | medição | veredito |
|---|---|---|
| a média anual de NPS é média de médias, não ponderada | 2 de **1.427** pares colaborador/ano divergem; maior diferença **0,024** em escala de 0 a 10 | imaterial |
| a consolidação sobrescreve a linha a cada item da pesquisa | **0** pesquisas com dois itens do mesmo colaborador, em 37.957 | não alcançável |

##### E TRÊS LACUNAS DE CENÁRIO, cada uma destravando um filtro que não se media

Nos três casos o cenário de fixtures não tinha o dado que torna o filtro observável,
e a linha foi criada **dentro da transação** — não no `fixtures.sql` —, porque é dado
de caso e não de cenário. Nada sobra depois, e foi conferido nos dois bancos: zero
linhas remanescentes.

| o que faltava | o que destravou |
|---|---|
| nenhum produto **desativado** na unidade (0 de 6) | o `pro.status = :status` de `selecionarProdutos` |
| nenhum convênio **inativo**, e o único do cenário sem unidade | o `con.status = true` e os dois ramos do `OR` de unidade |
| nenhum par **duplicado** em `dashboard_faturamento` | o `ORDER BY das.id ASC` |

E um caso precisou de **sessão**: `ConvenioRepository::popularCombobox` não recebe a
unidade por argumento — lê de `SessaoAcl`, e concatena o valor na DQL (sítio do D73).
Sem sessão, `getUnidade("id")` devolve `null`, a DQL fica com `conuni.unidade = ` e o
analisador recusa. O teste monta o `Container` da sessão à mão; é o único arquivo da
série que precisa disso, e com ele ficou provado que o recorte por unidade **funciona**
— o que nenhum teste tinha mostrado.

##### O ESTADO MEDIDO NO FIM

| | |
|---|---:|
| caracterização, espelho MySQL do CI | **587** testes, 11 pulados, exit 0 |
| caracterização, espelho PostgreSQL do CI | **587** testes, 11 pulados, exit 0 |
| contrato | **67** testes, exit 0 |
| portões | **33**, sendo 1 vermelho conhecido (D50, espera produção) |

Os arquivos novos **não têm nenhuma chamada de pulo** — nem `markTestSkipped` nem
`exigeHistoricoDaProducao` —, então os 30 casos executaram nos dois espelhos. Os 11
pulados são de arquivos anteriores, que dependem da cópia de produção.

#### E MAIS TRÊS RELATÓRIOS, todos com achado — ainda em 12/09/2026

`ComboboxDeAnosNoRelatorioTest`, `VendasCanceladasNoRelatorioTest` e
`AniversariantesNoRelatorioTest`. **Sobram 19 dos 37.**

##### O INVENTÁRIO CASA POR NOME, e dois "métodos" eram cinco

O mesmo nome existe em classes diferentes, e o inventário conta uma vez só:

| nome | onde mora, de verdade |
|---|---|
| `popularComboboxAnos` | `DashboardFaturamentoRepository` (o do relatório) **e** `SaidaParcelaRepository` (o da tela de saídas) |
| `relatorioSelecionarAniversariantes` | `UnidadeRepository`, `UnidadeSocioRepository` **e** `UsuarioRepository` — três consultas diferentes |

Os dois arquivos cobrem **todas** as implementações, e dizem no cabeçalho por quê.
E uma suspeita foi derrubada: o `DashboardFaturamentoAnosTest`, apesar do nome,
testa `relatorioUnidades` e não o combo — a ausência era real.

##### A ASSIMETRIA DOS TRÊS `:status` ESTÁ CERTA, e agora tem guarda

Os três irmãos de aniversariantes passam tipos diferentes no mesmo parâmetro, e os
dois de `Usuario` carregam um comentário sobre o **D97**. Medido:

| coluna | MySQL | PostgreSQL | o que o método passa |
|---|---|---|---|
| `unidades.status` | `tinyint(1)` | `boolean` | `true` |
| `unidades_socios.status` | `tinyint(1)` | `boolean` | `true` |
| `usuarios.status` | `int` | `integer` | `1` |

Cada um passa o tipo da **sua** coluna. Uniformizar os três — a coisa mais natural
ao ver o mesmo nome três vezes — derruba a tela no PostgreSQL e não quebra nada no
MySQL. **Falsificado:** trocando os dois `1` por `true`, três casos ficam
vermelhos com a mensagem exata que o comentário previa — *invalid input syntax for
type integer: "t"*. O conserto do D97 tinha comentário e não tinha guarda.

##### E UM FILTRO QUE ALGUÉM COMENTOU EM VEZ DE APAGAR

`listagemIndexCanceladas` filtra `ven.status = 0`. **(Corrigido em 28/10/2026, o D636: a redação de
antes dizia que esse zero "é também o estado de comanda aberta", e não é -- a comanda aberta é o
status 3, `RegraDaVendaNoPdv.php:31`; o 0 só sai de `VendaService::cancelar` e da rotina
`comandasCancelar`.)** O filtro comentado era para separar a rotina do cancelamento à mão:

    WHERE ven.status = :status  //AND ven.canceladoMotivo != :motivo

A intenção de separar cancelamento automático de manual ficou pela metade. O teste
**grava o comportamento de hoje** e registra o porquê.

##### DUAS LINHAS DE CENÁRIO, e as duas destravam mais de um filtro

| o que entrou | o que destrava |
|---|---|
| uma parcela de saída **desativada e de 2025** | o filtro `par.status` e a diferença entre um ano e dois no combo |
| `data_contratacao` em **dois** usuários, mesmo mês e dias 08 e 25 | o método da contratação inteiro, a ordenação por `DAY()`, e — porque o dia difere do dia de nascimento do mesmo usuário — a distinção entre as duas colunas |

##### E DOIS ERROS MEUS, achados pela execução e não por revisão

O primeiro: escrevi `CAST(... AS INTEGER)` no SQL do teste, que é PostgreSQL — o
MySQL quer `SIGNED` e respondeu *You have an error in your SQL syntax*. O segundo:
passei `false` sem tipo e o PostgreSQL respondeu *invalid input syntax for type
boolean: ""*. **É a mesma família que estes arquivos vigiam no código de produção,
encontrada no SQL dos próprios testes** — e só apareceu porque a suíte roda nos
dois motores.

E um terceiro, de raciocínio: afirmei "nada em julho" **sem filtrar unidade**, e a
cópia tem 61 unidades e 2.432 usuários. Afirmação de vazio só vale dentro do
recorte do cenário.

#### TREZE dos 37, e a lista de dinheiro FECHOU menos uma — em 12/09/2026

Seis arquivos novos, 41 casos, verdes nos dois motores:

| arquivo | métodos |
|---|---|
| `VendasNoRelatorioTest` | `totalDescontoPeriodo`, `selecionarUsuariosComVendas`, `selecionarColaborador` |
| `PagamentosDoCaixaNoRelatorioTest` | `selecionarPagamentosPorCaixa`, `selecionarPorPeriodoUnidade` |
| `DescontosNoRelatorioTest` | `listagemIndexRelatorioDescontos` |
| `RadarDeTemperaturaNoRelatorioTest` | `radarTemperatura`, `radarTemperaturaFaturamento` |
| `ProdutosNoRelatorioTest` | `relatorioVendasProduto` |
| `PesquisasNpsNoRelatorioTest` | `selecionarTotalizadoresPorColaboradorMesAno` |

**A LISTA DE ONZE FECHOU.** O último — as **gorjetas** — exigiu acrescentar linha
ao cenário: até 12/09/2026 a tabela tinha zero linhas na faixa 9000xxxx, e as
11.938 do banco são todas da cópia, que o CI não carrega. Entraram **três**, e o
`fixtures.sql` explica por que três: uma para contar, outra para contar em mês
diferente, e uma em venda não finalizada para o filtro de status poder ser
distinguido. Com o filtro: 2 gorjetas, R$ 19,80. Sem ele: 3, R$ 118,80.

**E A LISTA DE ONZE TINHA UM ERRO DE CLASSIFICAÇÃO:**
`selecionarTotalizadoresPorColaboradorMesAno` soma `item.nota`, que é nota de
pesquisa e não valor. São **dez de dinheiro e um de nota**.

**O ÚLTIMO DELES PRECISOU ESCREVER, e é o único da série que escreve:** o cenário
tem **zero** linhas em `nps` e em `nps_itens`. Sem dado não há afirmação, e usar a
cópia anonimizada não roda no CI. Ele monta três pesquisas dentro da transação que
o `CaracterizacaoTestCase` reverte — o que `ConsolidacaoNpsTest` já faz na mesma
tabela.

**SOBRAM 23, e agora os 23 são 23.** Por algumas horas foram 23 pela medição e 24
de verdade, e a diferença era minha: a detecção de cobertura é casamento de
**substring** no corpo dos testes, e `selecionarGorjetasPorColaboradorPeriodo`
tinha saído da lista porque eu escrevi o nome dele num docblock **explicando por
que ele NÃO estava testado**. O inventário passou a contá-lo como coberto — o
falso positivo que o próprio D10 avisava existir, com um exemplo concreto e,
agora, resolvido pelo lado certo: escrevendo o teste.

**E a lacuna das gorjetas fechou junto**, com três linhas de cenário e uma
décima primeira exclusão na limpeza — `gorjetas.venda` aponta para `vendas` com
`NO ACTION`, então sem ela as gorjetas bloqueariam o `DELETE` das vendas, que é o
mesmo defeito corrigido horas antes.

##### O RELATÓRIO DE VENDAS NÃO TINHA COBERTURA NENHUMA, nem de fumaça

O portão `medir-relatorios-no-postgres.php` lista `rel-vendas/index` entre as
ações `SEM_TABELA_PROPRIA`, com esta justificativa: *"formulario; o dado sai em
rel-vendas/exportar"*. A justificativa está certa — o `index` é mesmo só o
formulário. Mas **`rel-vendas/exportar` não é chamado em lugar nenhum**: procurar
por ele em `scripts/`, `tests/` e `.github/` devolve **uma** ocorrência, que é a
própria linha da justificativa.

O mesmo vale para `rel-atendimentos/index`, que aponta para
`rel-atendimentos/gerar`. As outras duas exclusões da lista apontam para
endereços que **são** medidos, e a diferença é verificável contando ocorrências:
**duas** para as que valem, **uma** para as que não. Quatro métodos passavam por
ali sem nunca ter rodado contra PostgreSQL em conferência automática nenhuma.

##### E TRÊS LACUNAS DE CENÁRIO, ditas em vez de disfarçadas

Cada uma está escrita no cabeçalho do arquivo que a encontrou, com o que faltaria:

| o que não se distingue hoje | o que faltaria |
|---|---|
| as **gorjetas** por colaborador | o cenário tem **zero** linhas em `gorjetas`; o banco inteiro tem 11.938, todas da cópia |
| *"a listagem é enraizada no ITEM"*, a decisão central do relatório de descontos | nenhuma venda do cenário tem **mais de um** item descontado; com um item por venda, enraizar no item ou na venda dá a mesma contagem |
| `SUM` contra `AVG` no radar, filtrado pela unidade | `dashboard_colaboradores` tem **uma** linha do cenário, e com uma linha as três agregações coincidem |

A terceira foi contornada medindo **sem** o filtro de unidade, sobre as milhares
de linhas da cópia. As duas primeiras esperam linha de cenário.

#### TRÊS dos onze de dinheiro, e o terceiro é o que mais valia

`tests/Caracterizacao/VendasPorColaboradorNoRelatorioTest.php` cobre
`selecionarVendasProdutosPorColaboradorPeriodo`. **Sobram 34 dos 37.**

Aquele método carrega um comentário de vinte linhas sobre um defeito **já
corrigido em 26/08/2026** — e nada testava a correção. O comentário mede o estrago
(380 grupos no relatório de avulso, 380 no de pacote, erro de até **R$ 1.240** numa
linha) e enuncia a invariante que o conserto restabelece: *"com a média ponderada,
`valorUnitario × quantidade` volta a ser exatamente `SUM(quantidade ×
valorUnitario)`"*. Conserto feito, medido à mão, e deixado **sem guarda** por
dezessete dias.

**O cenário consegue distinguir média de escolha arbitrária, e isso foi conferido
antes de escrever** — é o que dá força à afirmação. Medido nas fixtures: colaborador
90001000, produto 90001000, duas linhas de `89,90` e `110,00`, média ponderada
**99,95**, que não é nenhum dos dois.

**Falsificado, e a falsificação reproduz o defeito original com números:** trocando
a expressão de volta por `venpro.valorUnitario`, o MySQL escolhe `89,90`, e
`89,90 × 2 = 179,80` contra os `199,90` reais — **R$ 20,10 de erro num grupo de duas
linhas**. Os dois casos ficam vermelhos, e o segundo nomeia o preço escolhido.

Os quatro casos do arquivo: a invariante do comentário; o valor devolvido não é
nenhum preço cru; a quantidade é a soma e não a contagem; e nenhum grupo devolvido é
insumo (`pro.tipo != 'proins'`, filtro que ninguém guardava).

**439 testes verdes nos dois bancos.**

#### Dois dos onze de dinheiro, feitos no mesmo dia — e o que eles acharam

`tests/Caracterizacao/TotaisDoCaixaNoRelatorioTest.php` cobre
`totalDescontoPorCaixa` e `totalTrocoPorCaixa`, os dois primeiros da lista de
dinheiro. Com ele, sobravam 35 dos 37.

Cada afirmação compara o método com uma **soma independente em SQL** sobre as
mesmas linhas — é isso que prova que o DQL seleciona o conjunto certo, e não uma
repetição do mesmo cálculo. **Falsificado:** neutralizando o `WHERE ven.status` do
método, três casos ficam vermelhos nomeando `35.0` contra `30.0`, e um deles é o do
formato, o que mostra que ele não é redundante.

**E a medição achou uma diferença real entre os bancos.** Os dois métodos somam
coluna `double precision` e devolvem o resultado cru do DQL:

| | MySQL | PostgreSQL |
|---|---|---|
| `totalDescontoPorCaixa(55927)` | `double` `8084.0` | **`string`** `'8084'` |
| `totalTrocoPorCaixa(78458)` | `double` `440.0` | **`string`** `'440'` |
| caixa inexistente | `integer` `0` | `integer` `0` |

Mesmo caixa, mesmo valor, **tipo de PHP diferente**. É inofensivo **neste caminho**,
e isso foi conferido e não suposto: a tela passa os dois por
`Monetario::applyMask()` (`is-relatorio/caixas/index.phtml:82-83`), que devolve saída
idêntica para os dois tipos em dez pares de entrada, inclusive com centavos —
`8084.5` e `'8084.5'` dão `8.084,50`.

**Deixaria de ser inofensivo** num consumidor que usasse `removeMask()` ou comparação
estrita: `Monetario::removeMask('8084.5')` devolve **`'80845'`**, porque o ponto é
separador de milhar no formato brasileiro — a armadilha que o D49 registra. Por isso
o último caso do arquivo afirma o **formato** e não o tipo: o tipo pode mudar sem
consequência, e é a consequência que interessa.

**435 testes verdes nos dois bancos.**

#### E a pergunta seguinte era se isso é sistêmico — varrido, e não é

Se `SUM()` volta `string` no PostgreSQL e `double` no MySQL, a pergunta que importa
não é "quantos métodos", é **quantos consumidores se machucariam com isso**. Varrido
em 12/09/2026:

| | |
|---|---:|
| métodos de repositório que agregam `SUM`/`AVG` | **44** |
| … em `ISVenda` | 21 |
| … em `ISCadastro` | 15 |
| … em `ISConfiguracao`, `ISRelatorio`, `ISFinanceiro` | 3, 3, 2 |
| **consumidos perto de `Monetario::removeMask()`** | **0** |
| consumidos perto de comparação estrita | 5, **todos falso positivo** |

Os cinco são chamadas a `listagemIndex`, que é nome genérico — **19** repositórios
têm um, e só o do `ClienteRepository` agrega. O `===` que a janela pegou é
`$estado === 'ativo'` montando um argumento, e não o resultado da agregação sendo
comparado.

**Então a diferença de tipo não machuca ninguém hoje**, e isso é medida e não
suposição: nenhum dos 44 resultados de agregação passa pela função que transformaria
`'8084.5'` em `'80845'`. O que sobra é latência — e o teste dos dois totais do caixa
é o primeiro lugar onde ela fica vigiada.

#### A VENDA DE DOIS ITENS DESCONTADOS — 05/10/2026, e a raiz da listagem ganha guarda própria

A segunda metade do que a nota de 05/10/2026 dava como faltando, e que o cabeçalho
do `DescontosNoRelatorioTest` admitia: nenhuma venda do cenário tinha mais de um item
descontado, e com um item por venda "enraizado no item" e "enraizado na venda"
contam igual.

**Medido antes de escrever, e o registro estava meio certo.** Devolvendo a raiz de
antes de 26/08/2026 — `FROM Venda ven JOIN ven.produtos pro` com
`GROUP BY ven.id ORDER BY ven.dataCriacao DESC`, o código de antes do d4f83a52 —, o
arquivo NÃO ficava todo verde: no MySQL caía um caso,
`testALitagemRepeteOItemUmaVezPorPagamentoDaVenda` (2 linhas onde esperava 3). Mas
esse caso caracteriza um DEFEITO, a linha repetida por pagamento, e quem consertar o
defeito muda o caso junto; se o conserto fosse um `GROUP BY ven.id`, a raiz voltaria
para a venda e o caso seria "atualizado" sem vermelho nenhum que dissesse o que se
perdeu. A guarda da decisão de 26/08 morava, por acaso, no caso de um defeito. (Com o
desempate `pro.id DESC` do D47, que veio depois, a raiz na venda nem roda: o paginador
recusa ordenar por coluna de associação a-muitos. Por isso o mutante usa a ordem de
antes.)

**O que entrou:**

| | |
|---|---|
| `tests/Caracterizacao/fixtures.sql` | a venda **90001610**, finalizada, na unidade 90001000, com **dois** itens descontados e motivos diferentes (5,00 e 3,00; 52,00 no total, um pagamento). O bloco diz o que ela evita: o dia 14/02 (os R$ 200,00 que o `ReceitaParaYungasTest` crava), o par colaborador/produto da média de 99,95 do `VendasPorColaboradorNoRelatorioTest`, e um segundo pagamento |
| `DescontosNoRelatorioTest::testAListagemTemUmaLinhaPorItemDescontadoENaoPorVenda` | conta no SQL os itens descontados distintos e as vendas deles (4 e 3), exige mais itens que vendas, os 4 itens distintos na listagem e os **dois** motivos da venda de dois |

O caso conta itens DISTINTOS de propósito: ele fala da raiz, e não da repetição por
pagamento, e continua valendo no dia em que ela for consertada.

**Falsificado** com a raiz de antes, um mutante só, restaurado byte a byte: no MySQL o
caso novo fica vermelho (3 itens onde há 4), junto com o da repetição (3 linhas onde há
5); no PostgreSQL a consulta nem roda — o `GROUP BY ven.id` com colunas do item fora do
agrupamento —, e cinco casos do arquivo caem com erro. A suíte inteira, nos dois
bancos, rodou depois da fixture nova (os números estão no roadmap): nenhum outro caso
contava as linhas que ela acrescenta.

**O que resta, e por isso o título continua PARCIAL:** o par
`Visagismo::listagemIndexRelatorio`, adiado pelo dono ("ignorar tudo do onesignal,
visagismo e sms"). É o 48º dos 48 pares; os outros 47 têm teste.

### D34 — O inventário de diretórios de upload estava incompleto — PARCIAL em 14/09/2026: o repositório fechou e ganhou vigia, a regra em produção espera o Dia 0 — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como parcial, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: levar ao nginx de produção, no Dia 0, a regra que recusa PHP em public/visagismo, que lá ainda não existe.

Descoberto em 19/08/2026, ao migrar os pontos de chamada da F1.5.

O levantamento do Dia 0 catalogou seis diretórios de upload, porque partiu dos
que apareciam no **D1**. Mas o código escreve em mais lugares, e dois ficaram de
fora:

| Diretório | O que grava | Estava protegido? |
|---|---|---|
| `public/visagismo` | **foto enviada pelo cliente** | **NÃO** |
| `public/app.json` | configuração do aplicativo | n/a (é arquivo, não diretório) |

**O `visagismo` é o grave.** Recebe imagem enviada por cliente através da geração
de visagismo por IA, e ficou fora da regra de nginx que impede execução de PHP
em diretório de upload — a lista cobria `arquivos`, `contratos`, `banners`,
`assistentes`, `vip-class`, `logs` e `layout/img/avatar`.

Um `.php` enviado ali seria **executado pelo servidor**. É exatamente o cenário
do D12, num diretório que eu mesmo deixei de fora ao escrever a configuração.

**Corrigido no `docker/nginx/default.conf`** e provado: `/visagismo/teste.php`
devolve 403 sem executar.

**Pendente em produção**, como todo o resto do Dia 0 — e agora com um item a
mais para conferir na configuração do servidor.

**A lição, que vale além deste item:** o inventário partiu de um débito em vez de
partir do código. O certo é derivar a lista de `grep -rn "public/" module/`, que
é o que a migração acabou fazendo por acidente.

*Conferido em 31/08/2026, rodando essa varredura:* o código cita
`public/visagismo`, `public/vip-class`, `public/arquivos`, `public/layout`,
`public/app.json` e `public/global`. **Nenhum diretório de upload novo apareceu.**

Os dois últimos não são diretório de upload e não precisam da regra:
`public/app.json` é o arquivo de configuração do **D36**, e `public/global` só é
**lido**, para o logo do cabeçalho dos PDFs.

*O portão de referências acusou este parágrafo* — a primeira versão escrevia
**public/app** (sem crases aqui de propósito: é citação de um erro, não
referência a arquivo), recortado de um caminho maior, e nenhum diretório com
esse nome existe. O caminho real é `./public/app.json`, em `AplicativoController:76`. Vale
como exemplo do que o portão pega: prosa que cita um caminho que não existe.
E vale como aviso do que ele **não** pega — ele confere existência, não se a
frase ao redor está certa.

A verificação confirmou em vez de acusar, e fica registrada para a próxima
pessoa não precisar refazê-la.

#### O arquivo que PROVOU o conserto entrou versionado — fechado em 14/09/2026

Varrendo os sete diretórios de upload atrás de coisa versionada, apareceu um
arquivo só, e era meu:

    public/visagismo/teste.php      <?php echo 111*2;

É o arquivo que subiu para provar que a regra nova devolvia 403. Ele entrou no
repositório **pelo mesmo commit que fechou o furo** (`5547526`), porque
`/public/visagismo/` era o único dos sete que faltava no bloco LGPD do
`.gitignore` — `arquivos`, `assistentes`, `banners`, `contratos`, `vip-class` e
`logs` estavam lá; o diretório que o D34 acabara de acrescentar à regra de
nginx, não.

**O gesto que provou a correção plantou a exposição.** Um `.php` executável de
verdade viajando no deploy para um servidor que ainda **não** tem a regra —
porque o Dia 0 em produção continua congelado por decisão de quem manda. Aqui
ele dá 403; lá, hoje, daria `222`.

E não havia vigia nenhum. Medido em 14/09/2026, antes de escrever qualquer
coisa: `conferir-tudo.sh` não citava nginx, upload, visagismo nem 403, e
nenhum passo do CI citava `check-ignore`, `ls-files` ou `default.conf`.

#### O portão, e por que ele faz TRÊS perguntas

`scripts/conferir-protecao-do-webroot.php` é o 37º portão, e a lista de
diretórios protegidos não é dele: sai do próprio `docker/nginx/default.conf`,
da regra que de fato recusa. Uma segunda lista escrita dentro do vigia
concordaria com a primeira no dia em que fosse escrita e divergiria na semana
seguinte, em silêncio — que é o defeito do D34 outra vez, um nível acima.

| Pergunta | O que ela teria pego |
|---|---|
| Tem executável versionado no webroot? | o `teste.php`, no dia em que entrou |
| O git **ignora** os diretórios de upload? (quem responde é `git check-ignore`) | a ausência que deixou a primeira acontecer |
| Todo diretório onde o **código escreve** está na lista do nginx? | o D34 inteiro |

A primeira nasceu estreita — só os sete diretórios de upload — e o parágrafo que
eu escrevi dizendo *"ele não olha o resto do webroot"* foi o que mostrou o
tamanho do buraco. Alargada, ela achou na primeira execução dois `.php` que o
nginx servia com 200 e o PHP **executava**, sem login: é o **D188**, fechado no
mesmo dia. Achou também o `.htaccess` do esqueleto do Laminas, que é legítimo e
entrou numa lista de permitidos de dois itens — os dois são porta de entrada,
não conteúdo.

A terceira é a que importa, e é a lição escrita neste registro: *o inventário
partiu de outro débito em vez de partir do código.* Agora parte do código, toda
vez — e das **duas** formas de escrever que convivem hoje: o caminho lógico da
abstração da F1.5 (`Armazenamento::padrao()->guardar($tmp, 'banners/' . $x)`) e
a escrita direta que o visagismo ainda usa (`'./public/visagismo/' . $x`), que
não foi migrada por decisão registrada no D35. Vigia que conhecesse só uma
seria verde sobre a outra.

Ele **conta separado o que não conseguiu resolver**: chamada cujo caminho é
variável que ele não rastreou sai pelo nome e derruba o portão. Somar
não-resolvido com resolvido já mentiu nesta casa, e o remédio está escrito no
D186.

Medido hoje: **8** diretórios na regra, **2** executáveis permitidos no webroot,
**31** escritas do código caindo dentro da lista, **12** citações fora de escopo
declaradas (`public/app.json`, que é o D36, e `public/global`, que só é lido),
**0** não resolvidas.

#### A falsificação, que é o que separa portão de enfeite

Cada pergunta foi quebrada de propósito e o arquivo restaurado byte a byte:

| O que foi quebrado | Saída |
|---|---|
| o estado real, antes do conserto | 1 — acusou o `teste.php` **e** o `.gitignore`, em linhas separadas |
| um `.php` versionado em `public/logs/` | 1 — nomeou o arquivo |
| um `.php` versionado em `public/global/` (fora de upload) | 1 — "o nginx serve, o PHP executa, e nada do ERP passa por ele" |
| um permitido da lista deixou de existir | 1 — acusou o permitido órfão **e** o `index.php` que virou intruso |
| `/public/visagismo/` fora do `.gitignore` | 1 — "o git não ignora public/visagismo" |
| `'./public/visagismo/'` virou `'./public/recibos/'` | 1 — apontou arquivo e linha |
| `'assistentes/'` virou `'recibos/'` na abstração | 1 — apontou arquivo, linha e método |
| o caminho virou variável não rastreada | 1 — **NÃO RESOLVIDA**, não verde |
| a regra do nginx encolheu para 7 diretórios | **2 — recusou medir** |

A última é a que eu mais queria ver: piso furado não vira verde nem vermelho,
vira recusa. Portão que mede errado é pior que portão que não existe.

#### Um efeito que eu não tinha previsto, e quem o achou foi outro portão

O `git rm` levou junto o **diretório**. `teste.php` era o único arquivo
versionado dentro de `public/visagismo`, e sem ele o diretório some de todo
checkout novo — inclusive o do CI. O portão de referências acusou na mesma
rodada: três citações na documentação a um caminho que deixara de existir.

Não é regressão, e isso foi conferido antes de ser explicado: os **quatro**
lugares que gravam ali fazem `mkdir($diretorio, 0775, true)` antes de escrever —
três em `module/ISApi/src/Service/VisagismoService.php` e um em
`module/ISCadastro/src/Service/VisagismoService.php`. O código nunca dependeu de
o diretório vir do git. Ele vinha por acidente, de carona no arquivo que não
devia estar lá.

Na mesma rodada o portão também acusou uma sujeira **minha**: a falsificação
tinha criado `public/logs/` para plantar um executável ali, apagou o arquivo e
deixou o diretório vazio para trás. Falsificação que não restaura o terreno
inteiro é falsificação pela metade.

#### O que continua de pé

A regra em produção. O `docker/nginx/default.conf` deste repositório tem
`visagismo` desde agosto; o servidor não tem, porque nada foi mexido lá — e
isso é o D12, onde o assunto mora. Enquanto for assim, o valor de hoje é que o
repositório parou de **carregar** o problema junto.


### D38 — Integrações externas sem credencial na homologação

Decisão do responsável em 23/08/2026, ao subir o BVIP: seguir sem as chaves de
OneSignal, Zoomtec e SMS por ora.

| Integração | O que para de funcionar | Gravidade |
|---|---|---|
| `onesignal.app` | Push para o aplicativo do cliente | grave |
| `onesignal.webpush` | Push no navegador | médio |
| `zoomtec.bearer_token` | Visagismo — geração de texto e imagem | médio |
| `sms.usuario` / `sms.senha` | Envio de SMS | médio |

**Em homologação isso é o comportamento correto, não um problema.** As chaves
ficam vazias de propósito para que as integrações **falhem de forma visível** em
vez de disparar mensagem de verdade para cliente real. Um cron de marketing
rodando por acidente com credencial de produção manda push e SMS para 242 mil
pessoas.

**O que fica pendente de fato:** quando alguma dessas telas precisar ser
validada em homologação, a credencial usada tem que ser **de sandbox do próprio
fornecedor**, nunca a de produção. Vale confirmar com cada um se oferecem
ambiente de teste — OneSignal oferece aplicativo separado, e para SMS costuma
haver conta de teste.

Enquanto isso, as telas que dependem delas simplesmente não são exercitáveis
ali, e isso precisa constar do plano de validação da Fase 3 para ninguém
descobrir na hora.

*Atualizado em 11/10/2026 (o item RESPOSTAS-SEM-RODADA):* **o push tem a sua
credencial de teste decidida.** Pela pergunta 74 da página do lote (10/10/2026), a
opção (a), o servidor de teste fala com os dois endereços do Google, com um projeto
Firebase só de teste -- é a credencial "de sandbox do próprio fornecedor" que este
débito pede, para o push novo por FCM (o D461, a troca do push pelo Firebase).
O servidor de teste está pronto para isso desde o `2dd3ffa` (o R23): um arquivo que
abre só o Google, ligado pelo dono com a conta de serviço do projeto de teste, que é
dele e fica fora do repositório. **Continuam congelados, pela decisão de 23/08:** o
OneSignal (o `onesignal.webpush` inclusive), o Zoomtec do visagismo e o SMS -- sem
credencial de teste, e com as telas deles não exercitáveis no teste. Nada mudou no
código aqui; o título continua sem marca.

### D40 — O dashboard de colaboradores tem linhas duplicadas e divergentes — PREPARADO em 15/09/2026

Descoberto em 26/08/2026, ao analisar as consultas da F1.4 contra dados reais.

A tabela `dashboard_colaboradores` deveria ter **uma linha por colaborador, mês e
ano**. Tem mais:

| | |
|---|---|
| Linhas na tabela | 18.364 |
| Combinações repetidas | **107** |
| Repetidas com valores **divergentes** | **84** |
| Maior divergência observada | R$ 1.658 contra R$ 42.251,50 |

E não é divisão legítima por unidade. O pior caso, colaborador 200 em abril de
2023, tem as duas linhas com a **mesma unidade** e o mesmo `data_criacao` até o
segundo — `2023-04-01 23:01:23`. São duas gravações do mesmo lote, com números
diferentes.

**A consequência hoje:** `DashboardColaboradorRepository::dadosRelatorio` agrupa
por `col.id` projetando a entidade inteira. Quando há duas linhas, o MySQL
devolve **uma arbitrária** — e o relatório mostra R$ 1.658 ou R$ 42.251,50 sem
que ninguém saiba qual, nem que havia escolha. O mesmo vale para
`extratoPlanosCarreira` e `planosCarreiras`.

Não é problema criado pela migração. É problema que a migração **encontrou**,
porque o PostgreSQL recusa a consulta e obriga a dizer qual linha se quer.

As datas afetadas incluem 12/2025, então não é sujeira histórica — a geração
ainda duplica.

**A mesma corrida aconteceu em `royalties`, e lá deu o contrário: ver o D71.** A
origem é idêntica — ler-e-criar-se-não-existe sem restrição de unicidade —, mas a
consequência se inverte pela forma como cada relatório lê. Aqui a consulta agrupa
e o banco **devolve uma linha arbitrária**, então o número exibido pode estar
errado hoje. Lá o relatório **soma**, e como um lado de cada par está zerado, o
número está certo — por acidente. Os dois ficam separados porque o conserto e a
urgência são diferentes; a remissão é para quem investigar um e precisar do
outro.

#### ENCERRADO em 27/08/2026 sem causa confirmada, e por que isso basta

Investigado até o fim do que a evidência alcança. O resultado:

| | |
|---|---|
| Requisições ao endpoint, na janela retida | **14, de um único IP** — uma por dia |
| Registro de 01/12/2025 | **uma execução**, às 23:00:01 |
| Entradas no crontab | **uma**, hoje às 23:30 |
| **Última duplicata criada** | **16/12/2025** |
| Linhas criadas até | 14/08/2026 |

**São oito meses sem duplicata nova, com a rotina rodando todo dia.** O problema
parou.

**Por que a causa não será confirmada:** os logs do servidor web retêm cerca de
catorze dias, e as duplicatas são de 2023 e do fim de 2025. A evidência que
decidiria já foi rotacionada. O correlato mais plausível é a mudança de horário,
de 23:00 para 23:30, que aconteceu em algum momento entre dezembro e agosto — mas
isso é correlação, não prova, e vai ficar assim.

**Por que encerrar mesmo assim:**

1. O efeito está protegido nos dois lados — as órfãs foram limpas e o schema
   PostgreSQL tem chave única em `(inquilino_id, colaborador, unidade, mes, ano)`.
2. O problema não se manifesta há oito meses.
3. Continuar investigando consome tempo sem poder chegar a lugar nenhum.

**O que fica valendo, e não depende da causa:** a limpeza pré-migração precisa
rodar **imediatamente antes do dump**, não semanas antes. Se a causa voltar, ela
aparece como violação de chave única no PostgreSQL — alto e claro, em vez de
silenciosa.

Uma limitação minha, registrada: **a base de desenvolvimento não serve mais para
remedir isto**, porque a limpeza foi testada nela e as duplicatas foram apagadas.
Os números acima vêm da medição de 26/08, antes do teste.

#### O caminho até aqui, e a hipótese que caiu

O crontab de produção foi lido, e o registro de 01/12/2025 mostra **uma única
execução, às 23:00:01** — batendo com as duplicatas daquele dia, que nasceram
entre 23:00:02 e 23:01:05.

Há **uma entrada só** no crontab, e o horário mudou depois para 23:30. A hipótese
de "duas execuções simultâneas vindas do agendador" cai.

**O que o registro não prova:** a linha é escrita pelo `echo` do crontab **antes**
do `curl`, então atesta que **este servidor** chamou uma vez — não que o endpoint
foi chamado uma vez. Com três réplicas no parque, outra máquina com crontab
apontando para a mesma URL produziria exatamente o que se vê.

**Fecha com o log do servidor web**, que registra toda requisição:

```bash
zgrep -h relatoriosDashboardColaboradoresAtualizar /var/log/nginx/access.log.*.gz   | awk '{print $1}' | sort | uniq -c | sort -rn | head
```

Mais de um IP de origem, ou duas requisições no mesmo minuto, confirma.

**O que muda na prática: nada do que já foi feito.** A limpeza das órfãs e a chave
única no PostgreSQL protegem contra o efeito de qualquer causa. Mas enquanto a
causa não for confirmada, **novas duplicatas podem continuar aparecendo no MySQL**,
e a limpeza precisa rodar imediatamente antes do dump da migração.

O texto abaixo é o raciocínio original, mantido porque a parte mecânica dele
continua válida — o que caiu foi a origem das execuções simultâneas, não a
explicação de como elas produzem duplicata.

#### O raciocínio original, de 26/08/2026

`CronJobService::relatoriosDashboardColaboradoresAtualizar` faz **ler-e-criar-se-
não-existe**, sem atomicidade:

```php
$dashboard = $repoDashboard->selecionarPorMesAnoColaborador($colaborador->getId(), $mes, $ano);

if (empty($dashboard)) {
    $dashboard = new \ISRelatorio\Entity\DashboardColaborador();
    ...
```

Duas execuções simultâneas leem "não existe" e ambas criam. E os dados dizem
exatamente isso:

| | |
|---|---|
| Quando as duplicatas nascem | **sempre entre 23:00 e 23:05** — a janela do cron |
| Os dois picos | **01/04/2023 (64 linhas) e 01/12/2025 (116)** — dia 1º do mês |
| Nos demais dias | pares esparsos, 2 a 6 linhas |

O dia 1º concentra porque **nenhum colaborador tem linha do mês novo ainda**:
todos entram no ramo "criar" e toda sobreposição vira duplicata. Nos outros dias
a linha já existe, o ramo é "atualizar", e só duplica quem ainda não tinha linha.

**Por que os valores divergem** — e esta é a parte que explica R$ 1.658 contra
R$ 42.251,50:

Criada a duplicata, `selecionarPorMesAnoColaborador` usa `setMaxResults(1)` e
passa a devolver **sempre a mesma das duas**. Todo dia seguinte do mês atualiza
essa, e a outra **congela no valor do dia 1º**:

| id | `total_vendas` | `data_alteracao` |
|---|---|---|
| 9867 | R$ 42.251,50 | 2023-04-30 |
| 9868 | R$ 1.658 | **NULL** |

No conjunto todo: **109 nunca atualizadas contra 105 atualizadas** — um órfão por
par. A linha órfã não é um número concorrente: é o retrato do primeiro dia,
esquecido.

**Como é disparado, e por que sobrepõe.** São endpoints HTTP em
`/api/cronjobs/...`, protegidos por um token fixo no código. O trabalho é longo —
cerca de 180 colaboradores, dezenas de consultas cada — e no dia 1º é o mais
lento, porque tudo é inserção. Um agendador que expire e repita a chamada produz
exatamente este padrão: a primeira execução continua no servidor, a segunda
começa, e as duas criam. Ter o cron instalado em mais de uma máquina apontando
para o mesmo banco produz o mesmo efeito.

#### DECIDIDO em 26/08/2026, e o que ficou feito

**1. Unicidade — aprovada e implementada.** A chave é
`(inquilino_id, colaborador, unidade, mes, ano)`, emitida por
`gerar-ddl-postgres.php`. O `inquilino_id` vem primeiro de propósito: sem ele a
unicidade valeria entre inquilinos, e o primeiro cliente a gravar impediria o
segundo.

É a **primeira restrição de unicidade do schema** — até aqui não havia nenhuma,
que é o D29 por extenso.

**A carga no PostgreSQL passa a FALHAR se ainda houver duplicatas.** É de
propósito: falhar é melhor que migrar o problema.

**2. Limpeza — aprovada e implementada**, em `pre-migracao-limpeza.sql`. A regra
decidida pelo responsável:

> Fica a linha que vinha sendo atualizada. A sobrevivente **herda a `data_criacao`
> mais antiga** do grupo, para o registro guardar quando aquele mês começou a ser
> medido, e não quando a duplicata nasceu. Quando nenhuma das duas foi atualizada
> — não há dado que distinga —, fica a de menor id, que é a que a aplicação já lia.

Provado contra a base real: 107 grupos, 107 linhas removidas, 18.364 para 18.257,
a linha viva de cada par preservada, e idempotente na segunda execução.

Dos 107 grupos, 2 tinham as duas linhas **idênticas** — mesmos valores, mesma
data — então o caso de *fallback* é inócuo por construção.

**3. A consulta.** Com as órfãs fora, `GROUP BY das.id` resolve as três consultas
do Dashboard sem escolha de negócio nenhuma. Sai da lista de decisões da F1.4.

#### RESOLVIDO em 30/08/2026, e por um caminho melhor que o previsto

**A rotina passou a usar a trava** que o próprio arquivo oferece vinte linhas
abaixo — `acquireLock('relatoriosDashboardColaboradores')`, liberada em
`finally`. Duas execuções deixam de se sobrepor, e a corrida some na origem.

**O caminho que este débito previa era pior.** Eu havia escrito que a geração
precisaria *tolerar* a violação de unicidade: capturar, reler a linha do outro
processo e seguir. Isso esbarra num detalhe do Doctrine que só aparece ao tentar
— **um `flush()` que falha fecha o EntityManager** —, então "capturar e reler"
não é uma linha: exige recriar o gerenciador no meio de um laço sobre a base
inteira. Evitar a corrida é mais simples, e mais seguro, do que sobreviver a ela.

**E o teste não precisava de dois processos**, ao contrário do que este débito
afirmava. A concorrência é como a situação *aparece*; o que se verifica é o
comportamento **sob contenção** — e esse se produz segurando a trava e chamando
a rotina, porque um `flock` já detido responde igual venha de outro processo ou
do próprio teste. Está em `tests/Caracterizacao/CronTravaSimultaneaTest.php`.

**A primeira versão desse teste não acusava**, e vale registrar por quê: ela
contava linhas da tabela. A rotina **atualiza** as linhas do mês corrente em vez
de inserir, então o total não muda quando ela roda — a asserção era verdadeira
nos dois mundos e provava nada. Passou a observar `data_alteracao`, que é o que
distingue "rodou" de "desistiu". Conferido: com a trava desligada, o teste falha.

**Um número que apareceu ao verificar:** a rotina completa leva **21 minutos**
na base local. Rodando diariamente às 23h30, qualquer segundo disparo dentro
dessa janela colidia — o que explica por que a corrida acontecia de verdade, e
não só em teoria.

**O que continua valendo:** a trava é de arquivo e não protege entre máquinas
(D44), e a limpeza `scripts/pre-migracao-limpeza.sql` ainda precisa rodar
imediatamente antes do dump — produção acumulou duplicatas e a cópia local só
está limpa porque a limpeza já rodou nela.

**O que este parágrafo dizia até 15/09/2026, e não diz mais.** Ele afirmava que
"a corrida continua acontecendo no MySQL" e que "novas duplicatas aparecem todo
dia 1º". Era texto ANTERIOR à trava de 30/08/2026, e ficou posicionado depois da
seção RESOLVIDO como se descrevesse o estado de hoje — contradizendo a medição
da própria entrada, três telas acima: última duplicata criada em 16/12/2025,
oito meses sem duplicata nova com a rotina rodando todo dia.

O que resta verdadeiro: produção pode ter acumulado duplicatas ATÉ 30/08/2026, e
por isso `scripts/pre-migracao-limpeza.sql` roda imediatamente antes do dump.

**Vale conferir quantos agendadores chamam `/api/cronjobs/...` e com que tempo
limite.** É a única peça que os dados não contam, e reduzir para um único
disparador elimina a causa sem esperar pelo resto.

É o **D29** — nenhuma restrição de unicidade em todo o banco — cobrando a conta.
Uma chave única em `(colaborador, unidade, mes, ano)` teria impedido a segunda
gravação em 2023.


#### A chave única que encerrou este débito não tinha portão nenhum — 15/09/2026

Este débito foi dado por encerrado porque *"o efeito está protegido nos dois
lados"*: a trava de um lado, a chave única do outro. Os dois existem mesmo — a
trava em `CronJobService.php:711`, a chave em `postgres/schema.sql:1755` e no
PostgreSQL vivo.

**E nada guardava a chave.** Varri `scripts/`, `tests/` e o `ci.yml` atrás de
qualquer asserção sobre unicidade. As únicas ocorrências de `UNIQUE` falam do
problema **oposto** — o `schema-tool` propondo índice indevido, que era o D17. O
`verificar-schema-postgres.php` compara tabela, coluna e **tipo** contra o banco
vivo, e não olha restrição. O `conferir-unicidade.php` mede **dados**, não DDL.

**A contraprova, medida e não suposta.** Comentei a linha 562 de
`gerar-ddl-postgres.php` — a que declara a unicidade do D40 — e rodei o conjunto
inteiro **sem** o portão novo:

```
CONTRAPROVA: os 38 anteriores, com a chave do D40 ARRANCADA -> EXIT 0
  1 vermelho(s) conhecido(s), com motivo escrito acima.
  Nenhuma falha inesperada.
```

Trinta e oito portões verdes com a proteção arrancada. É o vão, em uma linha.

#### O portão: `scripts/conferir-unicidade-do-schema.php`

São **quatro** restrições de unicidade no schema PostgreSQL, de **duas origens**
— e essa distinção é o que torna o portão necessário:

| tabela | colunas | forma | origem | débito |
|---|---|---|---|---|
| `clientes_sessoes` | `(token_hash)` | índice | traduzida do `mysql.sql` | F1.5 |
| `dashboard_colaboradores` | `(inquilino_id, colaborador, unidade, mes, ano)` | restrição | `$unicidades` do gerador | **D40** |
| `privilegios` | `(inquilino_id, grupo, funcionalidade)` | restrição | `$unicidades` do gerador | D24 |
| `royalties` | `(inquilino_id, unidade, data)` | restrição | `$unicidades` do gerador | D71 |

A primeira vem traduzida do MySQL — e é a **única** `UNIQUE KEY` de todo o
esquema herdado, que é a medida exata do D29. As outras três nós é que
acrescentamos, e **não existem em banco nenhum hoje**: só passam a valer no dia
do corte. Por isso apagá-las era barato e invisível — nenhum banco de hoje
reclamaria.

**A ordem das colunas faz parte da declaração.** Em tabela com escopo a chave
começa por `inquilino_id`; sem isso a unicidade valeria *entre* inquilinos, e o
primeiro cliente a gravar impediria o segundo. Uma chave com `inquilino_id` no
meio guarda outra coisa, e o portão recusa a troca de ordem pelo mesmo motivo que
recusa a ausência.

Ele confere as **duas** fontes: a saída do gerador e o `postgres/schema.sql`
comitado — porque o arquivo é *saída* do gerador e pode ficar para trás dele.

#### A falsificação: quatro mutações

| | o que mudou | esperado | obtido |
|---|---|---|---|
| U1 | a linha do D40 comentada no gerador | vermelho | 1, nomeando a tabela e o débito |
| U1b | **os 38 portões anteriores, no mesmo estado** | **verdes** | **exit 0 — o vão** |
| U2 | só a ORDEM: `…unidade, ano, mes` | vermelho | 1, *"declarado (…mes, ano), achado (…ano, mes)"* |
| U3 | a linha apagada só do `schema.sql` | vermelho | 1, *"ficou para trás do gerador"* |
| U4 | piso acima do medido | 2 | 2, *"devolveu 2393 linhas, o piso é 9000"* |

**A U2 é a que separa um portão que guarda de um que parece guardar.** Um que
aceitasse a troca de ordem estaria verde sobre uma chave que não protege o que o
D40 decidiu proteger.

#### Três consertos nesta própria entrada

1. **Um cabeçalho órfão** — `#### O que ainda falta, e por que não foi feito
   agora`, seguido de linha em branco e do cabeçalho seguinte. Sem corpo nenhum.
   Saiu.
2. **Um parágrafo que se contradizia com a própria entrada.** Ele afirmava, já
   *depois* da seção RESOLVIDO, que *"a corrida continua acontecendo no MySQL…
   Novas duplicatas aparecem todo dia 1º"* — enquanto três telas acima a mesma
   entrada mede *"oito meses sem duplicata nova, com a rotina rodando todo dia"*
   e *"última duplicata criada: 16/12/2025"*. Era texto anterior à trava de
   30/08/2026, posicionado como se fosse o estado de hoje. Reescrito para o que
   resta verdadeiro.
3. **O título não tinha marca**, e por isso `contar-debitos.php` contava o D40
   entre os 41 *"aberto — sem marca nenhuma no título"*, enquanto o corpo dizia
   ENCERRADO e RESOLVIDO. É o D179 de novo, com um agravante: lá a régua não
   conhecia o verbo; aqui não havia verbo.

   Passou a **PREPARADO**, e a escolha não é de gosto: o glossário de
   `contar-debitos.php` define preparado como *"o conserto está pronto e
   desligado, esperando a janela do corte"* — que é exatamente o estado da chave
   única, que existe no schema PostgreSQL e só passa a valer no corte.

#### O que este portão NÃO prova

Nada sobre os **dados**, nem aqui nem em produção. Ele lê DDL. Quem mede
duplicata é o `conferir-unicidade.php` — e aquele, apontado para a cópia local, é
**verde por limpeza**: a cópia só está limpa porque `pre-migracao-limpeza.sql` já
rodou nela. Responder se produção acumulou duplicatas depois de 30/08/2026 exige
apontá-lo para a réplica, e é acesso que este repositório não tem.

---

### D43 — `yungasNpsAtualizar` estava desligada — REMOVIDA em 31/08/2026

**Reescrito em 28/08/2026.** O registro anterior dizia que a rotina existe, é
alcançável e ninguém a agenda — e concluía que faltava agendá-la. Faltava olhar
dentro dela primeiro.

**O corpo do método tem 27 linhas, e as 27 estão comentadas.** Ela não faz
nada.

Agendá-la seria pior que não agendar: produziria um `sucesso: true` a cada
execução, fecharia este débito na aparência, e não sincronizaria um único NPS.
É a mesma família do relatório que erra para menos — o que não existe manda
perguntar; o que finge responder, não.

**A integração Yungas está viva.** `yungasFaturamentoAtualizar` tem 49 linhas
ativas e roda às 23:40; `yungasAutenticacao` funciona. Foi só a metade do NPS
que alguém desligou, comentando o corpo e deixando a rota para trás. O código
que a desligou não está no repositório: o histórico começa na importação
inicial, então não há o que datar nem por que.

**A decisão é de negócio, não de engenharia:**

| Se | Então |
|---|---|
| a Yungas ainda espera receber o NPS | descomentar, testar contra a API deles, e só então agendar |
| não espera mais | remover o método, a ação do controller e a rota |

Hoje é superfície sem função: uma rota que responde sucesso e não faz nada.
Nenhuma das duas saídas é cara; o que custa é continuar sem escolher.

*Correção de 30/08/2026:* este parágrafo dizia "rota **pública**". Não é — ela
confere o token do cron como as outras 26. O que a faz responder sucesso a
qualquer chamador é outra coisa, e vale para 27 das 30 ações: o `sucesso: true`
sai mesmo quando o token não confere (**D4**, item 3). A rota não está aberta;
a resposta é que não distingue.

#### A DECISÃO, tomada em 31/08/2026: removida

**Por decisão sua, a Yungas não espera mais o NPS.** Saíram o método
`CronJobService::yungasNpsAtualizar` (29 linhas, das quais 27 comentadas), a ação
`CronJobController::yungasNpsAtualizarAction`, a rota
`/api/cronjobs/yungasNpsAtualizar` e a entrada correspondente no `ApiDocsController`.

**Uma conferência valeu a pena antes de apagar.** O inventário classificava esta
ação como **CONTRATO-APP** — *"versões antigas em campo; os 28 testes seguram"*
—, o que teria sido motivo para não tocar nela. Não era evidência: esse veredito
é atribuído em bloco a **toda** ação do módulo `ISApi`, e a suíte de contrato não
cobre rota de cron nenhuma. Conferido antes, não depois.

**Se um dia a Yungas voltar a querer NPS**, reconstruir é barato:
`yungasAutenticacao` e `yungasFaturamentoAtualizar` continuam de pé e servem de
molde — inclusive já com o tempo limite que o **D67** lhes deu.

**O que os portões passaram a dizer:**

| Portão | Antes | Agora |
|---|---|---|
| `conferir-agendamento.php` | 2 rotinas sem agendamento | **1** — só o D50, que espera produção |
| `conferir-rastro-das-rotinas.php` | 12 rotinas mudas de 27 | **11 de 26** |

E `conferir-rastro-das-rotinas.php` fez o que foi desenhado para fazer, desta vez
de verdade e não numa prova fabricada: acusou que `yungasNpsAtualizarAction`
estava na lista conhecida e não existia mais. **Lista desatualizada esconde a
próxima** — foi para isso que aquele ramo existe.

---

### D48 — A aplicação conecta como superusuário, e isso desliga o RLS inteiro — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: rodar `scripts/criar-papeis.sh` no corte, depois do schema e antes da carga, e apontar o `local.php` de produção para `bvip_app`.

Descoberto em 27/08/2026, ao começar a F1.9.

A F1.3 escreveu **75 políticas de isolamento por inquilino**, com `ENABLE` e
`FORCE ROW LEVEL SECURITY`. A F1.8 carregou 16 milhões de linhas e provou que o
comportamento financeiro não mudou. Nada disso valia o que parecia valer: **quem
conecta é `franquiabv`, que é superusuário — e superusuário está acima de
qualquer política.**

Medido, sessão nova, sem declarar inquilino:

| Quem consulta | Resultado |
|---|---|
| `franquiabv` (superusuário) | **61 unidades** — o RLS nem é avaliado |
| papel comum | `ERROR: unrecognized configuration parameter "app.inquilino_id"` |

`FORCE` faz a política valer para o **dono da tabela**, que sem ele escaparia.
Não faz — nem poderia — valer para superusuário.

**Já está resolvido no ambiente**, e provado: `postgres/papeis.sql.modelo` cria
`bvip_app` (comum, lê e escreve), e os 67 testes de caracterização passam com a
aplicação conectando como `bvip_app`, com o RLS em vigor. (Havia um segundo
papel, `bvip_bi`, somente leitura, para o Power BI; saiu em 09/09/2026 com ele
— ver **D9**.) `scripts/conferir-papeis.sh` prova as propriedades tentando violá-las.

**O que continua em aberto é produção.** Lá a aplicação segue com a credencial
de dono, e a troca precisa entrar no procedimento de corte — não depois, porque
carregar dados de um segundo inquilino com o isolamento desligado é o cenário em
que o vazamento não deixa rastro.

**Por que isso é pior que parece:** o sintoma de RLS desligado é *nenhum
sintoma*. Tudo funciona, todas as telas respondem, todos os testes passam. A
falha só aparece quando existe um segundo inquilino — e aí ela aparece como dado
de um cliente na tela de outro.

**Ao resolver:** rodar `scripts/criar-papeis.sh` no corte, **depois** do schema
(um `DROP SCHEMA ... CASCADE` leva as permissões junto) e antes da carga, e
apontar o `local.php` de produção para `bvip_app`.

---

### D49 — Todo o dinheiro do sistema está em ponto flutuante

Descoberto em 27/08/2026, ao revisar a conferência da migração.

> **O D30 é este mesmo defeito**, registrado dez dias antes e mais magro. Foi
> reduzido a remissão em 30/08/2026. Os dois já tinham divergido na escala
> recomendada — `numeric(12,2)` lá, `numeric(14,2)` aqui —, e 14 é a certa.

As **85 colunas numéricas** do sistema são `double` no MySQL e `double
precision` no PostgreSQL. **Nenhuma é `decimal`.** Isso inclui
`vendas.valor_total`, `vendas.valor_recebido`, `vendas.desconto_total`,
`vendas_produtos.comissao`, `vendas_produtos.valor_unitario`,
`royalties_faturas.valor` e `saidas_parcelas.valor_pago`.

`double` é binário: 0,10 e 0,01 **não têm representação exata**. O erro é
minúsculo por linha e se acumula por soma. Num relatório que soma 2,2 milhões
de vendas, a precisão absoluta do tipo já está na casa da unidade — ou seja,
**alguns reais**, não centavos.

**Isto é anterior à migração e não foi introduzido por ela.** A conversão é
fiel: `double` de um lado, `double precision` do outro, e a conferência prova
que os valores atravessaram idênticos. O defeito é do modelo de dados, e tem a
idade do sistema.

**Onde já apareceu:** as somas dos dois bancos divergiam na 16ª casa decimal
(`155232205,08550483` contra `155232205,08552286`) porque somar doubles depende
da ORDEM da soma, e as duas engines não somam na mesma ordem. Não é divergência
de dado — é o tipo fazendo o que o tipo faz.

**Consequência prática que já mordeu:** a primeira versão de
`scripts/conferir-migracao.php` comparava as somas em ponto flutuante com
tolerância de meio centavo, e eu havia escrito que "a diferença aceita é zero,
porque as somas vêm de tipos decimais exatos". Não vêm. Numa soma de 155
milhões, meio centavo está **abaixo do que o tipo distingue**: a prova passava
por sorte, e uma divergência real de alguns centavos passaria junto. Corrigido —
a soma agora é feita em decimal exato dos dois lados e comparada como texto.

**Ao resolver:** migrar as colunas de valor para `numeric(14,2)` (ou a escala
que cada uma pedir). No PostgreSQL isso é um `ALTER TABLE ... TYPE numeric
USING round(col::numeric, 2)`, que reescreve a tabela — em `vendas_produtos`
são 3,78 milhões de linhas, então é trabalho de janela.

**Não faça isso junto com o corte.** O corte tem como portão "o comportamento
financeiro não mudou"; trocar o tipo do dinheiro no mesmo movimento torna esse
portão impossível de ler, porque os dois efeitos ficam misturados. Depois do
corte, com os 67 testes de caracterização servindo de rede, é uma mudança
mensurável.

---

### D50 — A rotina que sinaliza contratos a renovar não é agendada, e o filtro devolve sempre vazio — PREPARADO, lido em 05/10/2026 — PARCIAL em 07/10/2026: a regra está escrita junto do campo; faltam a linha do crontab em produção e o nome novo

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: aplicar em produção a linha do crontab (01:30), pronta em `crontab-a-aplicar.md`; renomear o campo e documentar a regra seguem como recomendação.

Descoberto em 28/08/2026, lendo as exceções do inventário de ações.
**Reescrito no mesmo dia**, depois de a regra de negócio ser informada — a
primeira versão deste registro tirava a conclusão errada, e o erro vale ser
mostrado porque é o erro que a Fase 3 corre o risco de repetir.

**A regra, dita por quem decide:** o convênio tem data de vencimento de
contrato, mas **a vigência segue até que alguma parte cancele**. A data é
referência comercial, não corte automático.

**Então o sistema está certo.** `VendaProdutoService` concede o desconto olhando
`status`, tipo do produto e unidade/dia da semana — e **não** olha
`contratoVencido`. Isso não é uma verificação faltando: é a regra implementada.
Os R$ 649,50 concedidos nos últimos 12 meses por convênios com data ultrapassada
são desconto legítimo, sob contratos em vigor.

**O defeito é um só, e é de acompanhamento comercial.** A rotina
`ConveniosController::atualizarVencimento` marca quais contratos passaram da
data. Ninguém a agenda — não está nas 25 entradas do crontab, nada no sistema a
referencia. Resultado:

| | |
|---|---:|
| Convênios ativos | 616 |
| Com data de contrato ultrapassada | **18** |
| Marcados pela rotina | **0** |
| O mais antigo, ultrapassado há | **1.933 dias** |

O único consumidor do sinalizador é o **filtro da listagem de convênios**. Como
nada nunca marca ninguém, filtrar por "contrato vencido" devolve **lista
vazia** — e quem procura contratos a renovar conclui que está tudo em dia,
quando há 18 para revisitar, um deles de mais de cinco anos atrás.

Relatório que erra para menos é pior que relatório que não existe: o que não
existe manda perguntar; o que mente responde.

## O risco vai na direção contrária da intuição

Este é o ponto que importa para as Fases 2 e 3.

Olhando o código, `contratoVencido` parece um controle de validade que alguém
esqueceu de ligar. **Um desenvolvedor bem-intencionado, reconstruindo a tela,
tende a "consertar" isso** — ligando o sinalizador ao desconto, achando que
corrige um esquecimento.

Isso **quebraria a regra do negócio**: cancelaria na hora o desconto de 18
convênios em vigor, e de todos os que passassem da data dali em diante. Sem
ninguém decidir, e com aparência de correção de bug.

**O nome é a armadilha.** `contrato_vencido` lê-se como validade; o que ele
significa é "passou da data de referência, vale revisitar".

## Situação: a rotina existe e está pronta para agendar (28/08/2026)

**Ela deixou de ser ação de admin.** Era
`ConveniosController::atualizarVencimento` — sem view, terminando em
`die("FIM")`, alcançável por URL só porque o módulo tem rota curinga. Virou
`/api/cronjobs/conveniosAtualizarVencimento`, com a mesma forma das outras 25:
verificação de token, log ao final, e entrada na documentação da API. A ação de
admin foi removida, e com ela uma escrita alcançável pelo curinga sem motivo.

**E um defeito foi corrigido no caminho.** A rotina lia
`selecionarConvenios()`, que devolve **só os ainda não marcados**. O ramo que
desmarca em `ConvenioService::atualizarVencimento()` era, por isso, código
morto: renove um contrato, estenda a data, e ele fica na lista de "a renovar"
para sempre. **Uma lista que só cresce deixa de ser lista.** Agora processa
todos os ativos.

Quatro testes de caracterização cobrem os dois sentidos — marca quem passou da
data, **desmarca quem foi renovado** —, e o de desmarcar foi verificado voltando
à consulta antiga: ele falha. Um quinto guarda a regra de negócio: marcar não
mexe no `status`, que é o que o desconto lê.

**Falta aplicar a linha no crontab**, e produção está congelada. A linha está
pronta em `crontab-a-aplicar.md`, com o horário escolhido (01:30) e o porquê.

**Aviso para quando entrar no ar:** os 18 convênios com data ultrapassada
aparecem de uma vez na primeira execução. É o esperado, não defeito — vale
avisar quem cuida dos contratos, para o salto não ser lido como erro.

**O que continua valendo:**

1. **Aplicar a linha do crontab.** É o único passo que falta, e é em produção.
2. **Renomear na API e no schema novo** para algo que diga o que é:
   `contrato_a_renovar`, `contrato_data_ultrapassada`. Nome que descreve o
   estado não convida a ser ligado ao preço.
3. **Documentar a regra junto do campo**, para que a próxima pessoa não precise
   perguntar: a vigência segue até cancelamento por uma das partes.

Ver `inventario-acoes-excecoes.md`.

**Ele está sozinho.** O levantamento em `campos-regra-ou-referencia.md` varreu os
80 campos do sistema cuja forma sugere controle — todo booleano, mais prazos e
limites — e `contrato_vencido` é **o único** com forma de regra e função de
referência. Dos 10 que aparecem em `WHERE`, os outros nove são filtrados por
métodos que decidem; só este é filtrado apenas por um relatório e pela própria
rotina que o alimenta. Não há uma safra de armadilhas esperando a reconstrução.

#### O item 3 feito, em 07/10/2026 (o item CODIGO-MORTO-E-DOCS)

**A regra está escrita junto do campo**, no docblock de `Convenio::$contratoVencido`
(`module/ISConfiguracao/src/Entity/Convenio.php`): a vigência segue até uma das partes
cancelar, a data é referência comercial, quem marca e desmarca é
`ConvenioService::marcarVencimento` (no cadastro, na alteração e na rotina do cron),
quem lê, e o aviso -- não ligar o campo ao desconto, que a venda concede pelo `status`
e pelo vínculo com a unidade. Só comentário: nenhum comportamento muda, e não há o que
testar. O arquivo é CRLF desde a importação, e as linhas novas também (o
`.gitattributes` pede não renormalizar `*.php`).

**Os leitores, completados na verificação do mesmo dia:** a primeira versão do docblock
dava só "o filtro da lista e a API". São também o selo "Vencido" da lista antiga
(`is-configuracao/convenios/index.phtml`) e o da tela nova, pela API; e
`ConvenioRepository::selecionarConvenios()`, que a rotina deixou de ler em 01/09 (acima)
e ficou **sem chamador** -- e que devolve os ativos **não** marcados, justamente a forma
que o aviso proíbe. Quem procurar ali "os convênios ativos" para a venda cai nela. O
docblock agora o nomeia; apagá-lo é o mesmo gesto do código morto do D73, e não foi
feito aqui.

**Fica de pé:** o item 1, a linha do crontab em produção (pronta em
`crontab-a-aplicar.md`, para a janela do corte), e o item 2, o nome novo na API e no
esquema novo, que segue recomendação.

---

### D51 — `vendas.comanda_temp` é fóssil, e está dentro do maior índice da tabela

Descoberto em 28/08/2026, ao procurar o correlato de `comanda_temp` no schema.

**Não existe `vendas.comanda`.** A comanda é uma venda, e o estado dela está
codificado em `vendas.status`: `NULL` é venda temporária, `3` é comanda aberta,
`1` concluída, `0` cancelada. A substituição está escrita no código, com
comentário: `$venda->setStatus(null); //null = venda temp`.

`comanda_temp` foi a marcação anterior, e **o que ela marcava tem nome na
operação**: a *comanda fantasma*.

> Uma comanda temporária é a que foi aberta — o cliente esteve na unidade — e não
> foi fechada, por falta de pagamento. O caso comum é o caixa abrir uma comanda
> nova e finalizar essa, deixando a primeira órfã.

Os motivos de cancelamento gravados à mão confirmam: além dos 11.718
automáticos, há **243 "duplicado"** e **159 "abriu duas vezes"**.

O sinalizador durou **21 meses** — de 2018-06-06 a 2020-03-21 — e nesse período
marcava quase toda comanda cancelada (98,8% em 2018). Depois parou. A rotina
`comandasCancelar`, que é de 2017, seguiu rodando sem ele até hoje: 2.021
cancelamentos em 2026.

Hoje **nada lê e nada escreve** o campo, exceto o construtor da entidade, que
grava `false` em toda venda nova.

**O custo é um índice de 91 MB** — o maior de `vendas`, maior que a chave
primária (49 MB):

```
vendas_idx_vendas_data_status (inquilino_id, data_criacao, status, comanda_temp)
```

A quarta coluna é o fóssil, e nenhuma consulta do sistema cita `comandaTemp`. O
índice serve pelas três primeiras; a última é peso morto em 2,2 milhões de
linhas e em cada escrita nova.

**Ao resolver — depois do corte, nunca durante.** O portão do corte é "o
comportamento não mudou", e mexer no schema no mesmo movimento torna esse portão
ilegível.

1. Confirmar com quem sabe o que a "comanda temporária" de 2018–2020
   significava. Cinco anos de silêncio é forte indício, não prova — e o código
   que a escrevia não está no repositório.
2. `DROP COLUMN comanda_temp` e recriar o índice com três colunas.

**Ao lado:** a entidade declara `nullable=true` e o MySQL diz `NOT NULL`. A
divergência nunca apareceu porque o construtor sempre grava `false`. O gerador de
DDL seguiu o MySQL, então o PostgreSQL ficou fiel à origem — mas a anotação
continua mentindo, e é ela que o Doctrine usa.

---

### D52 — O cancelamento automático não distinguia fantasma de abandonada, e a lente que mediria a separação estava cega por charset — CORRIGIDO em 14/09/2026

Descoberto em 28/08/2026, ao entender o que `comanda_temp` marcava.

**A operação distingue dois casos; o sistema, não.**

> Uma comanda temporária é a que foi aberta — o cliente esteve na unidade — e não
> foi fechada, por falta de pagamento. O caso comum é o caixa abrir uma comanda
> nova e finalizar essa, deixando a primeira órfã.

A rotina `comandasCancelar` cancela toda comanda aberta há mais de dois dias e
grava **a mesma frase em todas**: *"Comanda aberta a mais de dois dias
(cancelamento automático)"*. Fantasma e abandonada recebem o mesmo tratamento e
ficam indistinguíveis depois.

**Medido, de 01/01/2025 até 16/08/2026 — só as que tinham item lançado:**

| | Comandas | Valor na comanda |
|---|---:|---:|
| Venda fechada no **mesmo dia** — a fantasma clássica | 2.662 | R$ 302.598 |
| Venda em até 3 dias — registrada depois | 382 | R$ 35.037 |
| **Nenhuma venda por perto** | **2.017** | **R$ 176.126** |

As 2.662 do primeiro grupo são ruído operacional: o serviço foi cobrado na outra
comanda. **As 2.017 do último não são** — cerca de **1.200 por ano**, com
serviço selecionado e sem nenhuma venda daquele cliente em três dias.

**O que o número é e o que não é.** `valor_total` numa comanda aberta acompanha a
soma dos itens — foi verificado. Então R$ 176 mil é o valor que estava na
comanda quando ela foi abandonada. **Não é prova de receita perdida**: item
selecionado não é serviço prestado, e a comanda pode ter sido aberta por engano
com item já escolhido. É o teto do que estava em aberto, e o piso de quanto vale
investigar.

**O mecanismo da fantasma continua no código.** `VendaService::comandaAbrir()`
procura comanda existente **pelo usuário** e só com `status IS NULL`; uma comanda
já aberta para aquele cliente, com `status = 3`, não é encontrada — e uma
segunda é criada. É exatamente o caso descrito pela operação, e ele não foi
fechado.

## Situacao: as duas primeiras acoes estao implementadas (28/08/2026)

**1. O cancelamento diz qual dos dois casos e.** `CronJobService::comandasCancelar()`
pergunta, para cada comanda, se o cliente teve venda finalizada no mesmo dia, e
grava uma de duas frases -- `COMANDA_DUPLICADA` ou `COMANDA_ABANDONADA`,
constantes da classe porque relatorio vai procura-las. O log passou a trazer as
duas contagens separadas.

**2. A segunda abertura deixou de criar comanda nova.** `VendaService::comandaAbrir()`
e `comandaAbrirAgenda()` consultam `selecionarComandaCliente()` -- que ja existia
e ja era usada pela agenda e pela listagem de clientes -- antes de criar. Se o
cliente ja tem comanda aberta **na mesma unidade**, ela e devolvida, e a tela
avisa: *"Este cliente ja tinha uma comanda aberta nesta unidade."*

O escopo e por cliente E unidade, nao so por cliente: comanda nao tem unidade
propria, ela vem do usuario que a abriu, e um cliente atendido em duas unidades
no mesmo dia tem direito a duas comandas.

**Seis testes de caracterizacao** cobrem as duas mudancas
(`tests/Caracterizacao/ComandaFantasmaTest.php`), e foram verificados
DESLIGANDO cada guarda de proposito: com a guarda da comanda desligada, o teste
de reaproveitamento falha; com a classificacao desligada, o da frase falha.
Passam nos dois bancos -- 73 testes, 136 assercoes.

**3. A terceira acao e uma decisao sua, e agora ela tem numeros.**
`scripts/medir-comandas-abandonadas.sql` reclassifica o historico pela mesma
pergunta que a rotina passou a fazer, e mostra por ano e por unidade:

| Ano | Abandonadas | Duplicadas | Valor abandonado |
|---|---:|---:|---:|
| 2023 | 1.165 | 1.446 | R$ 84.897 |
| 2024 | 1.619 | 1.674 | R$ 130.875 |
| 2025 | 1.542 | 1.720 | R$ 130.423 |
| 2026 (ate 16/08) | 857 | 942 | R$ 80.740 |

E a concentracao por unidade nao e aleatoria -- nos ultimos 12 meses, Itapema
Meia Praia tem 144 comandas abandonadas contra 41 de Itajai Centro. Isso e sinal
de processo, nao de acaso, e e o tipo de coisa que so aparece quando o numero
existe.

## DECIDIDO em 31/08/2026: o historico fica, e o roteiro e a lente

**Por decisao sua, nada e reescrito.** Os registros historicos mantem a frase
unica com que foram cancelados, e quem precisar da separacao roda
`scripts/medir-comandas-abandonadas.sql`, que classifica **na leitura** pela
mesma pergunta que a rotina passou a fazer.

**Por que isso e melhor que regravar**, e nao so mais barato: a classificacao
continua auditavel. Da para ver a regra que produziu cada numero, e se a regra
mudar, o numero muda junto. Um `UPDATE` no historico congelaria a versao de hoje
da regra em dez mil linhas, sem deixar rastro de qual versao foi.

**O preco, e ele e real:** quem quiser o corte precisa saber que o roteiro
existe. Por isso ele fica citado aqui e no roadmap -- e por isso esta frase
existe, em vez de a decisao ficar so no historico do git.

**O que continua sem dono, e isto e operacao, nao codigo:** a concentracao por
unidade. Itapema Meia Praia com 144 comandas abandonadas contra 41 de Itajai
Centro nos ultimos 12 meses e sinal de processo. O numero existe agora; olhar
para ele todo mes seria uma tela, e tela e materia da Fase 2.

**O que este debito NAO decide:** cobrar ou nao as ~1.500 por ano. Isso nunca foi
pergunta de engenharia, e o valor de R$ 130 mil ao ano e teto do que estava em
aberto, nao receita perdida -- item selecionado nao e servico prestado.

---

#### A lente que a decisão de 31/08 nomeou estava cega — corrigida em 14/09/2026

A decisão registrada acima diz: *o histórico fica, e quem precisar da separação
roda `scripts/medir-comandas-abandonadas.sql`*. Esse roteiro tem três blocos. O
terceiro — o único que responde **"a classificação nova subiu?"** — vinha
**vazio**, e o comentário ao lado dizia que vazio era o esperado: *"quando parar
de vir vazio, a classificação está no ar"*.

O único indicador que diria "subiu" estava preso em "não subiu", e o comentário
ensinava o leitor a não desconfiar.

**A causa é de charset, não de dado.** O bloco filtrava com `IN` sobre dois
literais **acentuados**, e o cliente `mysql` deste contêiner negocia
`character_set_client = latin1` — medido em 14/09/2026. Rodado exatamente como o
cabeçalho do próprio arquivo mandava, os bytes UTF-8 são remendados na entrada e
a comparação casa zero linhas. Sem erro e sem aviso.

Os blocos 1 e 2 nunca sofreram disso porque filtram com
`LIKE 'Comanda%cancelamento autom%'` — ASCII puro, o acento fica depois do `%`.

**As quatro combinações, medidas uma a uma na cópia local:**

| Predicado | Invocação | Bloco 3 devolve |
|---|---|---|
| `IN` acentuado | como o arquivo documentava | **vazio** |
| `IN` acentuado | com `--default-character-set=utf8mb4` | 43 |
| `LIKE` ASCII | como o arquivo documentava | 43 |
| `LIKE` ASCII | com a opção | 43 |

A linha que importa é a terceira: **o predicado ASCII acerta independentemente de
quem invoca**, e a opção de charset só acerta quando alguém lembra dela. As duas
entraram — a opção no cabeçalho, com o porquê escrito ao lado —, mas é o
predicado que faz o roteiro parar de depender de memória.

As 43 são **22 abandonadas + 21 duplicadas**, todas de 08/09/2026, de alguém
rodar a rotina contra a cópia local. **Produção não tem nenhuma**: lá seguem
32.640 linhas com a frase única antiga, a última de 15/08/2026 — o servidor
continua rodando o código velho, como manda a decisão de 11/09.

#### E o portão cobria metade da correção

O D52 diz ter corrigido a abertura de comanda duplicada. Corrigiu — nos **dois**
caminhos que abrem comanda. Mas só **um** deles tinha teste.

| O que foi arrancado | Quem acusava antes | Quem acusa agora |
|---|---|---|
| a guarda de `comandaAbrirAgenda()` | **ninguém** | 1 falha, e só no caso novo da agenda |
| `AND uni.id = :unidade` de `selecionarComandaCliente()` | **ninguém** | 1 falha, e só no caso novo da unidade |

Medido em 14/09/2026, arrancando uma de cada vez e restaurando os bytes: em
cada rodada este arquivo fica com **uma** falha, e os outros sete casos
continuam verdes — que é o que separa buraco real de redundância.

`comandaAbrirAgenda()` é o caminho da **agenda** — o que responde ao botão da
tela e produz o aviso *"este cliente já tinha uma comanda aberta nesta
unidade"*. `grep -rn comandaAbrirAgenda tests/` não devolvia nada. Arrancar a
guarda dele deixava tudo verde.

O escopo **por cliente E unidade** é decisão escrita neste próprio registro — *um
cliente atendido em duas unidades no mesmo dia tem direito a duas comandas* — e
não estava preso por teste nenhum. Sem a cláusula, o caixa da segunda franquia
recebe a comanda aberta na primeira, **com os itens da outra loja dentro**. O
teste que parecia cobrir isso (`testComandaDeOutroClienteNaoEReaproveitada`) só
prova que a guarda não é por caixa.

Os dois casos novos estão em `tests/Caracterizacao/ComandaFantasmaTest.php`, e a
falsificação mostra que **cada um acusa o seu**, com os cinco testes antigos
continuando verdes — que é o que prova que o buraco era real e não redundância.

#### O que continua sem dono, e é da operação

A concentração por unidade. Nos últimos 12 meses, só abandonadas: **Itapema Meia
Praia 148 (R$ 19.839) contra Itajaí Centro 41 (R$ 5.065)**. Isso não é dívida de
código — é matéria da Fase 2, como este registro já dizia.


### D53 — `clientes_creditos.pontuacao_validade` é fóssil da implementação anterior da mesma regra, e a previsão desta entrada se cumpriu: a API passou a publicá-lo — PARCIAL em 15/09/2026: fora da resposta e com portão; a coluna espera o corte

Descoberto em 28/08/2026, ao ensinar `scripts/campos-regra-ou-referencia.php` a
ignorar comentários. **Reescrito no mesmo dia**, depois de a regra de negócio
ser enunciada e de eu medir o que o sistema faz hoje.

**A regra, dita por quem decide:**

> A data de validade da pontuação deve ser respeitada. No dia seguinte a
> validade ter expirado, a pontuação deve ser subtraída do cliente.

**Ela é cumprida.** Não por essa coluna, e sim pelo razão: `expirarPontuacao()`
marca como `expirada` todo lançamento de `pontuacoes_historicos` com mais de um
ano, e o saldo em `clientes_creditos.pontuacao` — que é **derivado**, soma dos
lançamentos ativos — é recalculado na mesma execução do cron das 23:00.

**Medido, e o número prova o "dia seguinte":**

| | |
|---|---:|
| Lançamentos `expirada` | 285.275 |
| Lançamentos `ativa` com mais de um ano | 1.801 |
| **Atraso máximo entre esses** | **1 dia** |

Um dia é exatamente a defasagem de uma rotina diária das 23:00 contra uma base
tirada durante o dia. A comparação é `DATE(dataCriacao) < hoje - 1 ano`, então o
dia do aniversário ainda vale e a subtração acontece no dia seguinte — o que a
regra pede, na letra.

Está coberto por `FidelidadeResgateVencimentoTest`, **nos dois lados da
fronteira**: `testPontosComMaisDeUmAnoSaoExpirados` e
`testPontosExatamenteNoLimiteDeUmAnoNaoExpiram`.

**A coluna é o fóssil da implementação anterior da mesma regra**, e o dado diz
isso com clareza:

| Intervalo entre validade e criação do crédito | Créditos |
|---|---:|
| **365 dias** | 9.255 |
| **366 dias** | 7.393 |
| 0 dias (o padrão do construtor, nunca ajustado) | 44.922 |
| cauda de 393 a 428 dias | ~1.700 |

Um ano. A mesma regra, materializada por crédito em vez de calculada sobre o
razão — e a cauda sugere que a data era prorrogada a cada novo ponto. O código
que a escrevia não está no repositório.

#### Medido em 30/08/2026: a coluna não parou. Piorou.

Ao contar os nulos do **D65** apareceu uma data que este registro não tinha:
**06/09/2024**.

| | Até 06/09/2024 | Depois |
|---|---|---|
| Linhas com `pontuacao_validade` **nula** | existem | **nenhuma** |
| Valor típico quando preenchida | **365 ou 366 dias**, mais cauda de 393 a 428 | **0 dias**, em 44.922 linhas |
| Com 365/366 depois da virada | — | 445 |

São 153.135 nulas e 103.849 preenchidas em 256.984 créditos reais.

**"Fóssil" descreve o que a coluna significa, e não o que acontece com ela.** Ela
continua sendo escrita — a última é de 16/08/2026 —, só que com a data de criação
do próprio crédito. Como *validade*, isso diz "venceu no instante em que
nasceu".

**Isso é pior que uma coluna abandonada.** Coluna vazia manda perguntar; coluna
preenchida com o padrão do construtor **responde**, e responde errado. Qualquer
relatório, endpoint ou tela nova que leia `pontuacao_validade` como "quando estes
pontos vencem" concluirá que 44.922 créditos venceram no dia em que foram
criados.

**O que mudou em 06/09/2024 não está no repositório** — o histórico começa na
importação inicial. O que se pode dizer é que, a partir dali, o caminho que
deixava a coluna nula deixou de existir, e todo crédito novo passou a nascer com
o padrão de zero dia.

**Isso reforça a remoção, não a enfraquece.** Enquanto a coluna existir, ela é uma
resposta errada esperando por quem pergunte.

**Não há regra quebrada, e não há o que implementar.** O que resta é limpeza,
junto com o **D51**, depois do corte: remover a coluna e o método comentado
`ClienteCreditoRepository::selecionarPontuacoesVencidas()`.

**Remova o método comentado também.** Código comentado que sobrevive a uma
limpeza é o que faz a próxima pessoa acreditar que existe uma segunda regra de
vencimento — foi o que aconteceu comigo.

**Um conserto feito no caminho:** o prazo de um ano estava escrito **duas
vezes**, em `selecionarCientesPontuacaoVencida()` e em `expirarPontuacao()`. Os
dois precisam concordar — o primeiro escolhe quem tem o saldo recalculado, o
segundo decide o que expira. Divergindo, pontos expirariam sem o saldo ser
refeito, e o cliente veria um saldo que já não existe, sem erro em lugar nenhum.
Agora vêm de uma constante só, com teste que guarda a fonte única.


#### A previsão se cumpriu em oito dias, dentro deste repositório

O parágrafo acima dizia, em 30/08/2026:

> Qualquer relatório, endpoint ou tela nova que leia `pontuacao_validade` como
> "quando estes pontos vencem" concluirá que 44.922 créditos venceram no dia em
> que foram criados.

Em **07/09/2026**, oito dias depois, o commit `d8f7c9f` acrescentou a
`FidelidadeJson::credito()`:

```php
'pontuacaoValidade' => self::dia($credito->getPontuacaoValidade()),
```

A coluna passou a sair em cinco pontos de `/adm/api` — `GET
/adm/api/clientes/:id/creditos`, o detalhe e as duas respostas de resgate. **Nada
ficou vermelho**, porque nada vigiava a porta: o único ensaio que tocava no campo
o AFIRMAVA, medindo a Regra 6 sobre ele.

Medido em 15/09/2026 na cópia anonimizada, só leitura:

| | |
|---|---:|
| créditos reais | 256.984 |
| coluna nula | 153.135 |
| preenchida | 103.849 |
| validade = data de criação | **44.922** |
| **com `pontuacao > 0` e validade no passado** | **47.319** |

Os 47.319 são o dano: para cada um deles a API respondia que a validade dos
pontos já passou, enquanto o razão — que é quem manda — dizia o contrário.

#### O que foi feito em 15/09/2026

1. **A linha saiu do `FidelidadeJson`.** Era a única leitura do fóssil fora da
   própria entidade, e é ela que transformava coluna morta em resposta errada.
2. **O ensaio mudou de alvo em vez de sumir.** `testAValidadeSaiComoDiaESemFuso`
   media a Regra 6 sobre o fóssil; passou a medi-la sobre a validade DE VERDADE,
   a do pacote contratado, formatada pelo mesmo `self::dia()`. E entrou o ensaio
   que faltava em 07/09: `testOCreditoNaoPublicaOFossilDaValidadeDePontuacao`.
3. **O método comentado `selecionarPontuacoesVencidas()` foi apagado**, como esta
   entrada já pedia.
4. **Nasceu `scripts/conferir-fosseis.php`**, o 38º portão, ligado ao
   `conferir-tudo.sh` na linha `fósseis lidos`.

#### O portão, e a armadilha que ele quase teve

A regra dele é uma só: um fóssil declarado só pode ser citado por quem está na
licença, e cada permissão carrega o motivo escrito. Hoje são duas — a própria
entidade (anotação, propriedade, construtor, getter, setter) e o ensaio que
impede a chave de voltar.

**A licença vale nas duas direções.** Arquivo novo lendo o fóssil reprova, e
permissão que sobrou também: se um permitido parar de citar a coluna, a permissão
virou letra morta e sai no mesmo commit — senão fica autorizando uma leitura
futura que ninguém discutiu.

**E a armadilha do nome quase me pegou.** O fóssil se chama `pontuacao_validade`;
a regra de verdade se chama `pontuacao_validade_dias`, e o método que a aplica se
chama `selecionarPontuacaoValidadePorClienteUnidade`. **O nome do fóssil é
prefixo do nome da regra e está dentro do nome do método que a implementa.** A
primeira versão do portão casava por substring e acusava **oito** arquivos — seis
eram a regra de verdade. Com fronteira de identificador dos dois lados, são dois.

É a mesma lição do D177, apertado no dia anterior: substring não identifica nada.
Lá ela creditava cobertura que não existe; aqui ela **acusa defeito que não
existe**, que é o erro caro na outra direção.

#### A falsificação: cinco mutações

| | o que mudou | esperado | obtido |
|---|---|---|---|
| G1 | a linha volta ao `FidelidadeJson` | portão vermelho | 1, nomeando o arquivo |
| T1 | a mesma linha | ensaio vermelho | 1 falha, em `testOCreditoNaoPublicaOFossil…` |
| G2 | uma permissão que não cita mais | vermelho | 1, *"permissão que sobrou"* |
| G3 | o nome da **regra de verdade** num arquivo qualquer | **verde** | 0 |
| G3 | o nome do **fóssil** no MESMO arquivo | vermelho | 1 |
| G4 | piso acima do medido | 2 | 2, *"li 1012, o piso é 2000"* |

**A G3 é a que vale**: mesmo arquivo, o nome da regra de verdade deixa o portão
verde e o nome do fóssil o deixa vermelho. É a prova de que a fronteira separa os
dois, e não uma suposição de que separa.

#### Uma correção de rota nesta própria entrada

O último parágrafo dizia que o prazo de um ano "agora vem de uma constante só".
**Já não é verdade, e a mudança é melhoria:** o prazo virou configuração por
unidade (`unidades.pontuacao_validade_dias`, com `0` = não expira), lida por
`PontuacaoHistoricoRepository::unidadesPorPrazo()`. A preocupação original — que
`selecionarCientesPontuacaoVencida()` e `expirarPontuacao()` concordem — continua
atendida, porque os dois chamam aquela mesma função. O que mudou é que a fonte
única deixou de ser uma constante e passou a ser uma tabela.

Isso afasta a coluna `clientes_creditos.pontuacao_validade` **ainda mais** da
regra: ela não é nem a versão velha do prazo atual, é a versão velha de um prazo
que também já mudou de forma.

#### O que continua aberto

**O `DROP COLUMN`**, que é DDL em produção congelada — decisão do dono, junto com
o D51, depois do corte. Até lá a coluna continua `NOT NULL` no banco e continua
sendo escrita pelo construtor da entidade, e é por isso que a entidade está na
licença do portão em vez de ser limpa.

O que mudou é que ela deixou de ser **alcançável**: sai do banco para lugar
nenhum, e a porta por onde ela escapou está vigiada.

---

### D54 — A coluna `pontuacao_validade_dias` precisa existir em produção ANTES do corte — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: aplicar `migracao-pontuacao-validade-unidade.sql` em produção e só então atualizar o `mysql.sql` com a estrutura de produção, no passo 1 do corte.

Criada em 28/08/2026, ao tornar a validade da pontuação configurável por unidade.

**Não é dívida de código — é dívida de sequência**, e o erro que ela previne é
caro.

`scripts/migracao-pontuacao-validade-unidade.sql` acrescenta
`unidades.pontuacao_validade_dias` (INT NOT NULL DEFAULT 365). Foi aplicada ao
ambiente local e ao `mysql.sql` versionado, e a aplicação já depende dela.

**Produção ainda não tem a coluna**, porque está congelada.

**A armadilha está no passo 1 do procedimento de corte:** *"atualizar `mysql.sql`
com a estrutura de produção"*. Se esse passo rodar antes da migração, o dump
refeito **não terá a coluna** — e como é dele que
`scripts/gerar-ddl-postgres.php` parte, o schema PostgreSQL nasceria sem ela. A
aplicação sobe, e morre na primeira tela de unidade e na primeira execução da
rotina de vencimento.

**A ordem que resolve, e não tem alternativa:**

1. Aplicar `migracao-pontuacao-validade-unidade.sql` em produção. É aditiva,
   idempotente, e a tabela tem 61 linhas — é das mudanças mais seguras que
   existem.
2. **Só então** atualizar o `mysql.sql` com a estrutura de produção.
3. Seguir o corte normalmente.

**Se a decisão for não tocar em produção antes do corte**, então esta mudança
precisa ser revertida e refeita depois — e é mais barato aplicar o `ALTER` do
que reverter o código, os testes e o schema.

**Já aconteceu, em miniatura, no ambiente local — e vale como aviso.** Ao
terminar esta mudança, o `postgres/schema.sql` foi regerado com a coluna, mas o
banco PostgreSQL local não foi recarregado a partir dele. O arquivo tinha a
coluna; o banco, não. A suíte passou inteira no MySQL (84 testes) e quebrou 44
testes no PostgreSQL, todos por `column unidades.pontuacao_validade_dias does
not exist`. Um `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` resolveu.

O erro local custou uma execução de suíte. O mesmo erro em produção, no corte,
derruba a aplicação — e a diferença entre os dois casos é só a hora em que ele
aparece. **Regerar o schema não é o mesmo que aplicá-lo**: sempre que uma coluna
nascer aqui, ela precisa existir nos três lugares — no `mysql.sql` versionado, no
`postgres/schema.sql` gerado, e nos bancos que estiverem de pé.

---

### D55 — Dez snapshots de contrato não descreviam carga nenhuma — RESOLVIDO

Descoberto em 28/08/2026 ao conferir os números do roadmap; resolvido no mesmo
dia. Fica registrado porque a causa se repete sozinha, e a trava que a impede
precisa ser entendida por quem mexer nos testes depois.

**O sintoma.** Quatro testes de contrato falhavam: `unidade-selecionar`,
`unidade-selecionar-v2`, `servicos-selecionar` e `banners-selecionar`. Não era
regressão — o código desses endpoints não é tocado desde a importação inicial
(`eaf1fca`). O que mudou foi o banco local ter ganhado a cópia anonimizada da
produção, e os endpoints passarem a devolver estrutura de verdade.

**A causa, e ela não era a que eu supus primeiro.** Minha primeira leitura foi
"os snapshots foram gravados contra banco vazio, e regravar exige a réplica de
produção". Estava errada na segunda metade, e medir mostrou por quê.

O CI monta o banco com `mysql.sql` (estrutura) mais
`tests/Caracterizacao/fixtures.sql` (cenário). As fixtures povoavam oito
tabelas — e **nenhuma delas era `app_banners`, `agendas`, `cupons`, `sorteios`,
`areas_atuacao` ou `indicacoes`**. Pior: os testes pediam `/api/.../1`, e as
fixtures criam a unidade e o cliente **90001000**. O id 1 nunca existiu ali.

Montando o banco do CI e batendo nos endpoints, os dez devolviam exatamente as
estruturas vazias que os snapshots guardavam. **O CI estava verde, e o verde não
significava nada.** Não faltava acesso a produção: faltava cenário.

**Dez, não quatro.** Quatro acusavam. Os outros seis passavam:

| Snapshot | O que guardava | Situação antes |
|---|---|---|
| `unidade-selecionar` | `sucesso` | falhava |
| `unidade-selecionar-v2` | `sucesso` | falhava |
| `servicos-selecionar` | `sucesso` | falhava |
| `banners-selecionar` | `banners: "(vazio)"` | falhava |
| `agenda-selecionar` | `sucesso` | **passava** |
| `cliente-agenda` | `agenda: "(vazio)"` | **passava** |
| `cupons-selecionar` | `cupons: "(vazio)"` | **passava** |
| `indicacoes-listar` | `indicacoes: "(vazio)"` | **passava** |
| `sorteios-selecionar-cupons` | `sorteios: "(vazio)"` | **passava** |
| `unidades-selecionar` | 4 campos como `null` | **passava** |

O último é o mais instrutivo, e não estava na varredura inicial: gravava
`estado`, `latitude`, `longitude` e `telefone` com o tipo **`null`**, porque a
unidade das fixtures os tinha vazios. Produção manda texto nos quatro. O teste
passava afirmando um contrato que o aplicativo nunca recebe.

`(vazio)` merece uma palavra: o gerador de snapshot, sem elemento para
inspecionar numa lista, escrevia **a ausência como se fosse um tipo**.
`{"banners": "(vazio)"}` diz que existe uma lista chamada banners e não diz nada
sobre o que há dentro de um — que é a única coisa que o aplicativo lê.

**O que foi feito.**

1. `fixtures.sql` ganhou o cenário do contrato: banner, duas agendas, área de
   atuação com serviço, cupom válido, sorteio já realizado e indicação já
   rendida — e a unidade teve os quatro campos vazios preenchidos.
2. Os dez testes passaram a pedir os ids que as fixtures criam.
3. Os snapshots foram regravados **contra o banco do CI**, não contra a base
   anonimizada — para isso existe `scripts/montar-banco-contrato.sh`.
4. `ContratoTestCase::assertContrato()` ganhou uma trava: **estrutura sem carga
   não é mais gravável nem aceitável**, a menos que o snapshot se declare um
   caso de vazio pelo sufixo `-sem-dados`.

Resultado: 28 de 28 verdes, com 177 linhas de contrato onde antes havia
invólucro. As duas suítes passam nas duas ordens de execução.

**Três coisas que o caminho ensinou, e que valem mais que o conserto:**

**O cenário do contrato e o cenário do cálculo precisam de sujeitos
diferentes.** O cupom foi parar no cliente 90001000 e três testes financeiros
quebraram — `CupomTest::cuponsDoCliente()` conta `cupons_clientes WHERE cliente
= 90001000` e afirma quantidades exatas. O cupom mudou para o cliente convidado.

**As fixtures precisam apagar filho antes de pai.** As agendas novas apontam
para um produto, e `DELETE FROM produtos WHERE id >= 90001000` passou a falhar
por chave estrangeira — o que faz o mysql **abortar o arquivo inteiro naquela
linha**, deixando as fixtures pela metade. A limpeza dos filhos agora vem antes.

**E o carregador precisa saber falhar.** A primeira versão de
`montar-banco-contrato.sh` filtrava a saída com `| grep -v ... || true` para
esconder o aviso de senha do mysql — e escondia os erros junto. O banco carregou
pela metade e o script disse que estava tudo bem; os snapshots quase foram
regravados contra ele. É o mesmo defeito que já apareceu na conferência da carga
(D49) e no comparador de ensaios: **uma verificação que não consegue acusar é
pior que nenhuma**, porque ocupa o lugar de uma que conseguiria.

**O que continua valendo para quem vier depois:** um snapshot só vale o dado que
existia quando foi gravado. A trava impede o caso extremo — gravar o vazio — mas
não impede um cenário pobre. Se um endpoint novo entrar, o cenário dele entra
junto.

---

### D56 — O `status` do sorteio chegava nulo ao aplicativo — CORRIGIDO

Descoberto em 28/08/2026 ao povoar as fixtures do contrato (D55), corrigido no
mesmo dia por decisão sua. Só apareceu porque o endpoint passou a devolver
carga: enquanto a lista vinha vazia, não havia campo para estar errado.

**O defeito eram dois erros empilhados**, e o de cima escondia o de baixo:

1. `AplicativoController::sorteiosSelecionarCuponsAction()` chamava
   `Arrays::marketingSorteiosStatus($sorteio['nome'], false)` — passava o
   **nome** do sorteio onde a função espera a *chave* do status (`ativo`,
   `aguardandoSorteio`, `finalizado`, `cancelado`). Nenhum nome casa, então caía
   fora de todos os ramos e devolvia nulo.

2. Mesmo corrigindo a chamada, não haveria o que passar:
   `SorteioVendaRepository::selecionarSorteiosPorCliente()` não selecionava
   `sor.status`. O campo nem existia no array — trocar só a chamada teria
   substituído o nulo por aviso de índice indefinido.

**A correção são duas linhas:** `sor.status` entrou no SELECT da DQL, e a ação
passa `$sorteio['status']`.

Um detalhe do PostgreSQL que valia conferir e foi conferido: a DQL agrupa por
`sor.id` e seleciona outras colunas da mesma entidade. Isso é legal nos dois
bancos — ambos reconhecem a dependência funcional da chave primária — então
acrescentar mais uma coluna não recria o problema do D24.

**O impacto, medido contra a cópia anonimizada da produção:**

| | |
|---|---|
| Sorteios cadastrados | 4, entre out/2023 e out/2025 |
| Participações | 7.214 |
| **Clientes que veem sorteio no app** | **5.002** |
| Última atividade | out/2025 — **a função está dormente há 10 meses** |
| Valores de status na base | `finalizado`, `cancelado`, `aguardandoSorteio` |

Os valores da base **são** as chaves que a função conhece, então a correção
produz rótulo de verdade: um cliente que antes recebia `null` agora recebe
`"Finalizado"`.

**A mudança de contrato é de uma linha:** `status` passa de `null` para
`string`. Nenhum campo entra, nenhum sai, nenhum muda de nome. Um aplicativo
antigo que tratava o nulo como "sem informação" passa a receber texto no lugar
de nada — que é a intenção da tela.

O risco é baixo justamente por causa do estado da função: **não há sorteio em
andamento desde out/2025**. Os 5.002 clientes alcançados veem sorteios
encerrados, e o rótulo que passam a ver é "Finalizado" — informação correta
onde antes não havia nenhuma.

Ver `funcionalidades-sem-uso.md` para onde os sorteios se encaixam entre as
funções vivas, dormentes e nunca ligadas.

**Uma coisa que a correção trouxe de brinde:** a fixture do contrato usava
`status = 'aberto'`, valor que **o sistema não produz**. Passou a usar
`finalizado`. Cenário de teste com valor inventado é um contrato que descreve um
mundo que não existe — e teria escondido este mesmo defeito de novo.

---

### D57 — A função "assistentes" fica FORA da Fase 2 — DECIDIDO em 31/08/2026 — ABERTO, lido em 05/10/2026 — DECIDIDO em 15/10/2026 pela resposta 144 (a): o uso entra na tela nova, e a administração fica na tela antiga

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está aberto: o defeito segue sem conserto no código. Falta: a resposta à P14 do consolidado sobre os assistentes, refeita sobre a premissa corrigida pelo D343: vínculo vazio quer dizer todas as unidades.

Registrada em 28/08/2026, por decisão sua ao avaliar
`funcionalidades-sem-uso.md`: *"VIP Class sim, assistentes anote como débito e
revistaremos no futuro"*.

**O estado, medido na cópia anonimizada da produção:**

| | |
|---|---|
| `assistentes` | 4 cadastrados |
| `assistentes_grupos` | 11 |
| **`assistentes_unidades`** | **0 — nenhuma das 62 unidades tem acesso** |
| Ações no inventário | **7** (`AssistentesAdminController` 5, `AssistentesUsuariosController` 2) |

Não é função pela metade: é função inteira, com conteúdo cadastrado e a chave
nunca girada. Alguém construiu, alguém cadastrou, e nenhuma unidade foi
vinculada.

**Correção de registro (03/10/2026):** o "0 -- nenhuma das 62 unidades tem acesso" desta tabela
leu o vínculo vazio como função desligada, e o código lê o contrário: `assistentes_unidades` vazia
quer dizer **todas** as unidades (`module/ISConfiguracao/src/Entity/AssistenteRepository.php:57-60`,
`uni.unidade IS NULL OR ...`). Os 4 assistentes estão no menu de 211 pessoas, em 31 das 32 unidades
ativas. E o "como conferir" abaixo mede a coisa errada: a tabela deixar de estar vazia quer dizer
que alguém **restringiu** os assistentes, e não que os ligou. É o **D343**, que registra o erro e o
conserto do texto (este débito, o `docs/roadmap.md` e o `docs/funcionalidades-sem-uso.md`). A
decisão de 31/08 foi tomada sobre esta premissa, e o desenho dos assistentes a leva de novo ao dono
(a P14 de `docs/f3-telas-restantes/telas-consolidado.md`).

**O que NÃO precisa ser decidido, porque já está resolvido:**

As três tabelas migram junto com as outras 82 — estão em `migrar-dados.sh`, têm
`inquilino_id` e RLS ligado no `postgres/schema.sql`. **Adiar a decisão não custa
nada no corte**, e é por isso que dá para adiá-la com tranquilidade. O dado
atravessa inteiro, ligada ou não.

**O que precisa ser decidido, e só na Fase 2:**

Se as 7 ações viram endpoint REST e tela React. São 2% do inventário de 360 — o
suficiente para valer a pergunta, pequeno o bastante para não travar nada.

#### A DECISÃO, tomada em 31/08/2026

**Por decisão sua, as 7 ações ficam FORA da Fase 2.** Nenhum endpoint REST,
nenhuma tela React.

O raciocínio: o dado atravessa o corte de qualquer jeito, então nada se perde. E
se a função vier a ser desejada um dia, nascer na plataforma nova é mais barato
que portar telas que ninguém nunca usou — portar tela sem usuário é como uma
função morta vira permanente.

**O que isso cria, e não é para agora:** a remoção das três tabelas e do código
legado vira tarefa **depois do corte**, junto com as outras limpezas pós-corte.
Antes disso não se mexe — enquanto o sistema atual estiver de pé, o código
precisa continuar existindo.

**O que isso NÃO muda:** `migrar-dados.sh` e o `postgres/schema.sql` continuam
como estão. As três tabelas migram, com `inquilino_id` e RLS. Tirá-las agora
seria decidir a remoção antes do corte, que é exatamente o que esta decisão
adiou.

**A ressalva que fica de pé:** a decisão foi tomada sobre o retrato local. Se a
consulta abaixo devolver algo diferente de zero quando a produção voltar a ser
lida, alguém ligou a função e a decisão merece ser revista antes da limpeza.

**Como conferir se algo mudou:** se `assistentes_unidades` deixar de estar
vazia, alguém ligou a função e a decisão já foi tomada na prática.

```sql
SELECT COUNT(*) FROM assistentes_unidades;
```

*Correção de registro (04/10/2026, o D343):* a consulta acima mede a coisa errada -- a
tabela deixar de estar vazia quer dizer que alguém **restringiu** os assistentes. O que diz
se a função está ligada é quem vê o cartão: a pessoa ativa, numa unidade ativa, com um
assistente ativo do seu grupo e da sua unidade, e o vínculo vazio quer dizer todos e
todas (a regra de `AssistenteRepository::listagemIndexUsuarios`). Medido no
`franquiabv_dev` em 04/10/2026: **215 pessoas, em 31 unidades** (sem contar o privilégio
do menu, que é o Dβ do desenho; com ele, as 211 do Q12).

```sql
SELECT COUNT(DISTINCT u.id) AS pessoas, COUNT(DISTINCT u.unidade) AS unidades
  FROM usuarios u
  JOIN unidades un ON un.id = u.unidade AND un.status = 1
  JOIN assistentes a ON a.status = 1
 WHERE u.status = 1 AND u.id < 90001000
   AND (NOT EXISTS (SELECT 1 FROM assistentes_grupos ag WHERE ag.assistente = a.id)
        OR EXISTS (SELECT 1 FROM assistentes_grupos ag WHERE ag.assistente = a.id AND ag.grupo = u.grupo))
   AND (NOT EXISTS (SELECT 1 FROM assistentes_unidades au WHERE au.assistente = a.id)
        OR EXISTS (SELECT 1 FROM assistentes_unidades au WHERE au.assistente = a.id AND au.unidade = u.unidade));
```

*Conferido em 30/08/2026:* segue **4 assistentes, 11 grupos, 0 unidades**. Nada
mudou, e a decisão continua aberta com o mesmo custo de antes.

**A ressalva importa:** a conferência foi feita na cópia anonimizada local, que é
um retrato da produção — não a produção de hoje, que está congelada. Se alguém
ligou a função desde a última leitura, isto não veria. Vale repetir a consulta
no servidor quando ele voltar a ser lido.

> **Conferido em 07/10/2026 (o item CODIGO-MORTO-E-DOCS):** a premissa desta decisão --
> "nenhuma unidade vinculada" como função desligada -- está errada, e o D343 a corrigiu
> aqui e nos dois guias; o `docs/roadmap.md` e o `docs/funcionalidades-sem-uso.md` ainda
> tinham duas frases da mesma leitura ("as funções desligadas", "não há usuário hoje"
> para a VIP Class), corrigidas hoje ao lado delas. A P14 do consolidado, que leva a
> decisão de volta ao dono, segue sem resposta: nem no consolidado, nem entre as 47
> respostas da página do lote lidas nesta data.

**Decidido pelo dono em 08/10/2026 (pergunta 49 da página do lote, com o D343):** a opção (a),
*"Manter a ordem de 27/09"* -- "A tela antiga segue em uso, para os alunos e para quem administra,
até a nova chegar." A VIP Class fica em 8º de 9 na ordem das telas, depois de contas a pagar, sabendo
que ela tem gente usando. A pergunta era só sobre a posição da VIP Class; o que este débito espera --
se a administração dos assistentes (e a da VIP Class) entra na tela nova, a P14 do consolidado -- segue
sem resposta, e ele continua ABERTO.

**Decidido pelo dono em 11/10/2026, registrado em 15/10/2026 (a pergunta 144 da página do lote, a P14
do consolidado; a fatia PONTE-PARA-A-ANTIGA):** a opção (a), *"A VIP Class inteira; dos Assistentes, só
a tela de uso"* -- "A administração dos Assistentes fica na tela antiga, consertada." É a opção B da P1
de `docs/f3-telas-restantes/tela-assistentes.md`. O que ela fecha aqui: a função não fica fora da tela
nova -- o USO entra (a F1b, a F2, a F3 e a F7 do desenho, a lista da opção B; a F1 já foi feita; as
fatias 86 a 88 da fila das telas) --, e a ADMINISTRAÇÃO fica na tela antiga, já consertada pela F1 de
04/10/2026 (o D340, o D341, o D342 na parte dos assistentes, e do D407 ao D412). A F6 do desenho, as
páginas públicas, não é do uso nem desta resposta: espera a P15 do consolidado (a P2 do desenho), e é a
fatia 89 da fila. No menu novo, o "Configurações › Assistentes VIP" abre a tela antiga em aba nova desde
15/10/2026 (`novaAba`, com o motivo em `frontend/src/telas/menu-do-erp.teste.ts`), e não cai mais na tela
que prometia "o conteúdo entra quando for migrado". O painel do Zapier era condição da RECOMENDAÇÃO da
pergunta, e não do dono: ela pedia "(a), depois de você olhar o painel do Zapier", e abria a desativação
dos bots se ele dissesse, na observação, que quase ninguém conversou com eles em 2026. O dono marcou (a)
e deixou a observação vazia: não pediu a desativação, e a resposta não a deixa em aberto. A remoção das
três tabelas depois do corte, que a decisão de 31/08 anotava, caiu com ela: o uso migra, e as tabelas
ficam.

---

### D58 — Desativar a pesquisa derrubava o cadastro do programa de fidelidade — CORRIGIDO

Descoberto e corrigido em 28/08/2026, ao examinar a função "pesquisas" para
decidir se entra na Fase 2.

**Um clique numa tela de administração quebrava um fluxo público de 25 mil
usuários** — sem aviso, sem erro visível para quem clicou, e numa tela diferente
daquela em que se clicou.

**O caminho, inteiro:**

1. `PesquisaRepository::selecionarPorFidelidade()` busca a pesquisa **ativa** com
   `programaFidelidade = 1` e devolve `getOneOrNullResult()` — pode ser nula.
2. `PesquisasController::ativarRemoverAction()` desativava **qualquer** pesquisa
   sem conferir se era a da fidelidade. `removerPesquisasPlanoFidelidade()`
   garante que nunca haja duas marcadas, mas nada garantia que houvesse uma.
3. Com nenhuma ativa, `FidelidadeController::cadastroAction()` passava o nulo
   adiante e a view fazia `$pesquisa->getPerguntas()`
   (`fidelidade/cadastro.phtml:216`) **sem conferir**.

Resultado: a página do cadastro de fidelidade deixava de renderizar — desde a
abertura, não só a etapa 5. Na submissão o mesmo nulo reaparecia em
`FidelidadeController:83`.

**Por que importava tanto:**

| | |
|---|---|
| Clientes no programa de fidelidade | **25.455** |
| Respostas acumuladas da pesquisa | **25.471** |
| Pontuação de cadastro mais recente | **2026-08-16** |

A diferença de 16 respostas para 25.455 cadastros confirma o que o código já
dizia: a pesquisa é a **etapa 5 obrigatória** da adesão.

**A correção, nas duas camadas:**

**1. Impedir a causa.** `PesquisaService::alterStatus()` recusa desativar a
pesquisa marcada como do programa de fidelidade, devolvendo `false`. O
controller passou a olhar esse retorno e explica o motivo em vez de anunciar
sucesso.

A trava ficou no **serviço, não no controller** — protege qualquer chamador,
inclusive os que ainda não existem, e pode ser testada sem subir HTTP.

**2. Sobreviver ao efeito.** Sem pesquisa ativa, a etapa 5 passa em branco em
vez de derrubar a página: o controller monta lista vazia de perguntas, e a view
percorre lista vazia. A validação não encontra pergunta por responder, e
`FidelidadeService` ainda marca `programaFidelidade` — que é o que a etapa faz
de essencial. **O cliente adere; só não responde pesquisa.**

Vale notar por que a camada 2 continua necessária mesmo com a camada 1: a trava
impede *desativar*, mas a marcação pode sumir por outro caminho — uma edição, uma
carga de dados, um banco novo antes de cadastrar a primeira pesquisa.

**Nada muda no estado de hoje.** Com a pesquisa 2 ativa e marcada, as duas
camadas são inertes: o fluxo público continua idêntico. São mudanças defensivas.

**Cobertura:** `tests/Caracterizacao/PesquisaFidelidadeTest.php`, 5 testes.
Conferi que eles acusam — removendo a trava do serviço, o primeiro falha com
"Failed asserting that true is false".

O teste dos dois pontos que derrubavam a página verifica **por leitura do
código**, não por execução, e a razão é registrada nele: exercitar a adesão
exigiria um cliente com hash de 8 caracteres, e a anonimização reescreveu os
hashes para 32. É mais um ponto cego conhecido da base anonimizada.

---

### D59 — A API do aplicativo não autentica, e um telefone devolve o CPF — PARCIAL, lido em 05/10/2026 — PARCIAL em 11/10/2026: a sessão V4 renova — PARCIAL em 12/10/2026: o código sai pelo WhatsApp central; falta a credencial, do dono — PARCIAL em 14/10/2026: o número será novo — ANOTADO em 14/10/2026: a 189 (b)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: as rotas trancadas só sobem com o aplicativo novo em `/api/v4`, após o inventário de versões (W3); WhatsApp nas oito unidades; token de renovação.

> **Leia junto com o D13**, que é o mesmo assunto visto do lado do código — lá
> está a causa: `AplicativoController` declara `$tokenPadrao` e nunca o compara.
> Este registra o que se alcança sem autenticação; aquele, por que ela não
> existe. Conferido em 30/08/2026 que não são duplicata.

Registrado em 29/08/2026, ao desenhar a autenticação da Fase 2. **Confirmado por
você:** *"hoje não faz autenticação, apenas identificação de telefone"*.

**O que isso significa na prática**, demonstrado contra o ambiente local:

```
$ curl http://localhost:8080/api/cliente/agenda/149612
{"sucesso":true,"agenda":[{"id":5409814,"data":"04/09/2026 09:00:00",
 "colaborador":"Colaborador 2425", ...
```

Nenhum cabeçalho, nenhum segredo — **o id sequencial do cliente é a credencial**.
As rotas recebem `:id` direto, não há endpoint de login, e o módulo não tem
listener de autenticação. O único `bearer_token` do código é da Zoomtec, para o
visagismo, e não tem relação com o cliente.

Vale dizer o que isso **não** é: não é um defeito introduzido, nem uma regressão.
É o desenho original, de quando o aplicativo era menor e o risco era outro.

**A porta de entrada é o telefone, e ela é mais larga do que o id.**

`GET /api/agendas/getCliente?telefone=47999999999`, sem cabeçalho nenhum,
devolve:

```
    id        90004000
    nome      Alvo Da Demonstracao
    cpf       111.222.333-44
    rg        MG-11.222.333
    telefone  +55 (47) 99999-9999
    email     alvo@exemplo.test
```

Demonstrado no ambiente local com um cliente criado só para isso e removido em
seguida — a base anonimizada reescreve o formato do telefone, então não havia
como casar com dado existente.

**Um número de telefone devolve o CPF e o RG da pessoa.** É a "identificação por
telefone" que sustenta o aplicativo, e ela entrega documento de identidade a
quem apenas pergunta.

**E devolve o `id`, que destrava o resto:**

| Rota | O que devolve a quem tem o id |
|---|---|
| `/api/cliente/agenda/:id` | próximos agendamentos, unidade, colaborador |
| `/api/cupons/selecionar/:id` | cupons ativos e onde valem |
| `/api/sorteios/selecionarCupons/:id` | participações em sorteios |
| `/api/cliente/indicacoes/:id` | **nome e telefone de terceiros indicados** |

A última linha é diferente das outras: as três primeiras expõem dados de quem tem
o id; **esta expõe dados de quem nem sequer é o dono do id** — a pessoa
convidada, que não participou daquela chamada e não escolheu estar ali.

Os ids também são inteiros sequenciais, e são 243 mil clientes — de modo que há
duas formas de chegar: pelo telefone, uma pessoa por vez, ou pelo id, em
sequência.

Registrado como matéria de LGPD, junto dos demais itens já marcados como dívida
futura.

**Por que não corrigi:**

Corrigir aqui é **mudar o contrato do mundo congelado** (D5). Exigir token nas
rotas atuais derruba todo aplicativo instalado na hora, e não há como atualizar
o parque de uma vez — é exatamente o que os 28 testes de contrato existem para
impedir que aconteça por acidente.

**O caminho que funciona, e a ordem importa:**

1. **Saber quais versões do aplicativo estão em uso.** É a pendência da W3 que
   nunca foi resolvida, e sai dos logs do nginx. Sem isso não há como definir
   prazo de aposentadoria para nada — só chute.
2. **Endpoints novos, autenticados, em versão nova** (`/api/v4/...`), enquanto os
   atuais seguem funcionando.
3. **Aplicativo novo passa a usá-los**, e o parque roda naturalmente.
4. **Aposentar os antigos** quando o número do passo 1 disser que dá.

O passo 1 é o único que não depende de decisão nenhuma — é leitura de log, e
destrava os outros três. Ele já estava pendente por outro motivo: sem ele
também não se sabe quais contratos podem ser aposentados com segurança.

**A ferramenta do passo 1 está pronta:** `scripts/inventariar-versoes-app.php`.
Roda com `--dir=/var/log/nginx`, aceita `.gz`, não escreve nada e não toca em
banco — é leitura pura, e por isso pode rodar em produção sem cerimônia. Cruza
os três sinais de versão (`?versao=`, prefixo da rota, User-Agent) e lista as
rotas declaradas que não apareceram.

Duas coisas nela são deliberadas. Ela classifica rota sem chamada como
**SUSPEITA e nunca como morta**, e imprime a janela observada em destaque: *"não
apareceu no log" não é "está morto", é "não apareceu na janela"* — um cliente que
vai à barbearia a cada dois meses é invisível num log de sete dias. E ela **nunca
copia valor de parâmetro para o relatório**, porque a query string traz telefone;
conta quantas requisições carregam dado pessoal, e só.

**Duas contenções que NÃO quebram o parque instalado**, se a espera pelo caminho
completo parecer longa demais:

1. **Limite de taxa no nginx** para `/api/agendas/getCliente`. A colheita em
   massa depende de repetição; o uso legítimo é uma consulta por abertura de
   aplicativo. Não toca no contrato, porque não muda resposta nenhuma.

2. **Mascarar `cpf` e `rg` na resposta.** E há um detalhe que torna isso mais
   barato do que parece: **o snapshot de contrato registra o TIPO, não o valor**
   — `cpf` continua sendo `string`. A suíte não acusa, e nenhum aplicativo que
   apenas repassa o campo quebra. O que pode quebrar é uma tela que exiba o CPF
   inteiro, e saber se existe tal tela exige olhar o aplicativo.

Nenhuma das duas substitui a autenticação. As duas encurtam a janela enquanto ela
não vem, e nenhuma delas espera pelo inventário de versões.

**Um efeito colateral bom:** resolver isto resolve junto o **D45** — o inquilino
é fixo na configuração porque não há de onde tirá-lo; com autenticação de
verdade, ele passa a vir de quem está autenticado.

#### O ALCANCE É MAIOR DO QUE ESTE REGISTRO DIZIA — medido em 03/09/2026

A consulta é `appSelecionarClientesPorTelefone`, e ela **não tem limite**: um
`getResult()` sem `setMaxResults`. O controller percorre tudo o que voltar
(`AplicativoController:290`) e monta um item por cliente.

**Uma requisição pode devolver o documento de muitas pessoas**, e o número já
estava medido — no **D41**, sobre a réplica de produção:

| | |
|---|---:|
| Telefones com mais de um cliente ativo | **5.670** |
| Clientes envolvidos | 13.379 |
| **Pior caso, num único número** | **354 clientes** |

Este registro dizia *"um número de telefone devolve o CPF e o RG da pessoa"*.
Devolve o de **até 354 pessoas** — e quem pergunta não precisa ser nenhuma delas.

*Isso muda a ordem das duas contenções.* Limite de taxa reduz **quantas vezes**
se pergunta; não reduz **quanto volta em cada resposta**. Contra a amplificação,
mascarar vale mais.

#### CONTENÇÃO 1 APLICADA em 03/09/2026 — limite de taxa

`docker/nginx/default.conf`: zona `busca_cliente`, 30 requisições por minuto por
endereço, com `burst=20 nodelay` e `429` como recusa. Só em
`/api/agendas/getCliente`; as outras rotas não foram tocadas.

**Não muda resposta nenhuma**, então não toca no contrato (**D5**) — conferido:
a suíte de contrato passa com o limite ativo, 35 de 35.

*Posto à prova, e o primeiro teste não provou nada.* Quarenta requisições
sequenciais passaram todas — porque cada `curl` demora o bastante para o balde
reabastecer. Só com **60 em paralelo** a recusa apareceu: 36 servidas, 24 com
429. Com um limite absurdo (1/min) a terceira já era recusada, o que confirmou
que o mecanismo funcionava e que o problema era a minha medição.

**O valor ainda não é o certo, e está escrito no arquivo.** A conta possível
hoje: ~800 clientes/dia em 32 unidades, ~3 requisições por minuto somando todo
mundo; 30 por endereço deixa dez vezes de folga. O que ela não cobre é o
**CGNAT** — milhares de clientes legítimos saem do mesmo endereço numa operadora
móvel. O número definitivo sai do log do nginx, que é a pendência **W3**.

**E ele não resolve o D59.** A 30 por minuto ainda se colhe muito, e uma única
requisição já pode trazer 354 pessoas. Limite encurta a janela; quem fecha a
porta é a autenticação.

#### DECIDIDO em 03/09/2026: a rota passa a exigir autenticação

> *"o /api/agendas/getCliente deve responder apenas com autenticação, seja do
> próprio cliente, seja de algum usuário do sistema logado."*

Implementado. Sem sessão, a rota responde **401**; com sessão do ERP, responde
como antes. Medido contra a aplicação de pé.

**Das duas autenticações, só uma existe hoje.** O ERP tem sessão, e é ela que
está sendo conferida. **O cliente não tem credencial nenhuma** — não há login de
cliente, não há token, e o `$tokenPadrao` da classe é declarado e nunca comparado
(**D13**). A metade do cliente continua por construir, e não é trabalho só de
código: exige uma **prova de identidade** que hoje não existe. As duas candidatas
naturais — SMS e e-mail — esbarram numa decisão sua anterior, de ignorar tudo de
SMS por enquanto.

**O QUE ISSO CUSTA, e é preciso dizer sem rodeio.** O aplicativo instalado **não
consegue se autenticar**. No instante do deploy, a busca por telefone deixa de
responder a ele — em todo o parque, de uma vez. Não há meio-termo: exigir
credencial de quem não tem nenhuma é negar.

Por isso o deploy depende do **passo 2** deste débito (aplicativo novo em campo),
que depende do **passo 1** (saber quais versões estão em uso, W3). A ordem não
mudou; o que mudou é que o destino agora está decidido.

**A suíte de contrato acusou, e o vermelho era verdadeiro.** Dois testes —
`testClienteSelecionarSemDados` e `testClienteSelecionarComDados` — e só eles. Os
snapshots foram regravados de propósito, e o diff mostra o que o contrato antigo
era:

```
-        "cpf": "string",   "email": "string",   "nome": "string",
-        "rg": "string",    "telefone": "string"
+    "status": 401
```

*O contrato antigo era o da exposição.* A suíte existe para impedir quebra por
**acidente**; esta é por decisão, e está escrita no próprio teste para o próximo
leitor não confundir as duas.

**Guardado por `conferir-autorizacao-privilegiada.php`**, sexta entrada. Ela é de
natureza diferente das outras cinco — não reconfirma senha, só exige que exista
sessão —, mas o risco é o mesmo: é uma linha que, apagada, não quebra teste
nenhum, e a suíte passaria a gravar a exposição de volta como se fosse o contrato
certo.

#### E A ROTA DE INDICAÇÕES, no mesmo dia — a que expõe terceiros

`/api/cliente/indicacoes/:id` passou a exigir sessão também. Ela era a mais grave
das quatro alcançáveis pelo `id` sequencial, e por um motivo de **natureza**: as
outras três expõem dado de quem *tem* o id; esta devolve `convidado_nome` e
`convidado_telefone` — dado de **terceiro**, que não participou da chamada e não
tem como saber que está ali.

**Nenhum campo precisou sair, e vale dizer por quê:** o telefone do convidado foi
**digitado pelo próprio indicador** ao convidar (`FidelidadeService:75`).
Devolvê-lo a ele não revela nada que ele não saiba. O defeito nunca foi o campo —
era responder a qualquer um que soubesse um número inteiro.

**Um agravante que não estava registrado, medido em 03/09:** essa ação manda
`Access-Control-Allow-Origin: *`, e **19 ações** desta classe fazem o mesmo (mais
uma no `VisagismoController`, 20 no total). Com `*`, qualquer página em qualquer
navegador lia a resposta entre origens — a colheita não precisava nem de servidor
próprio, podia pegar carona no navegador de quem visitasse a página.

*Exigir sessão fecha isso pelo próprio protocolo:* navegador não envia credencial
para resposta com `Access-Control-Allow-Origin: *`. As outras 18 ações continuam
como estão, e o CORS aberto nelas fica registrado aqui como coisa a decidir.

#### O PREÇO DESTA DECISÃO: duas cargas ficaram sem contrato — **pago em 03/09**

Exigir sessão tirou dois endpoints da proteção da suíte de contrato, porque **ela
não sabia autenticar**: não havia porta-cookies em `ContratoTestCase`, e o usuário
das fixtures do banco de contrato tinha um bcrypt de mentira, então não fazia
login.

O que a suíte alcançava nessas rotas era a **recusa**, e não a carga. O formato
entregue a um chamador autenticado deixou de ser congelado — em
`cliente-selecionar` e em `indicacoes-listar`.

**A suíte ganhou login no mesmo dia**, e as duas cargas voltaram a ser congeladas:
`cliente-selecionar-autenticado` (10 campos) e `indicacoes-listar-autenticado`
(9 campos). São 37 testes, e cada rota agora tem **dois**: um prova que anônimo é
recusado, o outro prova o formato que quem tem credencial recebe. Só o primeiro
deixaria a porta fechada sem ninguém olhar o que há atrás dela.

**O HASH não é versionado — e a distinção não é cosmética.** O texto claro está
no `scripts/definir-senha-contrato.php`, e não adianta fingir o contrário. O que
muda é *onde ele passa a valer*: um bcrypt dentro do `fixtures.sql` valeria em
TODO banco montado a partir das fixtures, e o de desenvolvimento é um deles — o
que tem a cópia anonimizada da produção, com 243 mil clientes. O usuário de
fixture ganharia lá uma senha que funciona, sem ninguém pedir.

O roteiro **recusa qualquer banco com mais de 10 unidades**, e o de contrato nasce
só das fixtures. É esse guarda que faz a senha ser inofensiva, não o segredo dela.

Ele é chamado de dois lugares — o `scripts/rodar-contrato.sh` e um passo do CI —,
porque o CI monta o banco só com `mysql.sql` mais `fixtures.sql`, sem passar pelo
roteiro. Os dois precisam existir.

##### Três defeitos que só um login faria aparecer

**1. O ACL lança exceção para funcionalidade que não existe.** O banco de contrato
nasce de um dump de ESTRUTURA: `funcionalidades` estava vazia, e
`Acl::isAllowed()` não devolve `false` para recurso desconhecido — ele *lança*. O
login respondia 500 sem dizer por quê. Marcar o grupo como `administrador` não
resolve: foi medido. As 63 funcionalidades que o `SessaoAcl::setAcl` pede por nome
passaram a ser fixture.

**2. O `fixtures.sql` entrava em latin1.** O cliente `mysql` do contêiner abre a
conexão em latin1; o `mysql.sql` se protege com um `SET NAMES` do próprio
`mysqldump`, e o `fixtures.sql` não tinha nenhum. Os 61 nomes acentuados entraram
DOBRADOS, e o ACL — que compara nome por nome — não achava nenhum deles. A
declaração ficou dentro do arquivo, e não no roteiro que o carrega, porque o CI
carrega o arquivo direto, com outro cliente e outro padrão: corrigir só o roteiro
deixaria os dois bancos diferentes, que é a pior forma de estar errado.

*E a medição do estrago quase mentiu de volta:* contei os nomes dobrados com
`LIKE '%Ã%'`, e a coluna é `utf8mb3_general_ci` — **insensível a acento**. O
`LIKE` casava com qualquer nome que tivesse a letra "a", e disse "60 dobrados"
depois da correção já estar feita. A contagem honesta é sobre `HEX()`.

**3. O porta-cookies estático poluiu os snapshots públicos.** A primeira versão
mandava sessão sempre que o login já tivesse acontecido *alguma vez*. Como o campo
é estático, o primeiro login valia para o resto da classe: `cliente-selecionar` —
que existe para provar que a rota recusa anônimo — foi regravado com 200 e a carga
inteira. O interruptor passou a ser um parâmetro de cada requisição, e a suíte
roda verde também com `--order-by=reverse`, que é exatamente a condição que a
quebrou.

*E a própria suíte me obrigou a nomear isso corretamente.* Tentar gravar o 401 sob
o nome antigo falhou, com a mensagem certa: *"gravar assim daria um teste que
passa sem proteger o formato entregue ao aplicativo (D55)"*. O sufixo
`-sem-dados` existia para forçar a declaração de que o vazio é proposital; agora
há dois motivos distintos para um corpo vazio, e o segundo ganhou sufixo próprio —
`-sem-autenticacao`. Chamar recusa de "sem dados" seria descrever a causa errada,
e sufixo que mente é pior que sufixo nenhum.

#### A METADE DO CLIENTE FOI CONSTRUÍDA — 03/09/2026

> *"ataque a autenticação do próprio cliente no D59"*

O cliente final passou a ter credencial. Não era só código: exigia uma **prova
de identidade** que este sistema não tinha — e a busca por ela encontrou uma que
já existia.

##### O canal já estava em produção, e isso poupou a decisão que travava tudo

O registro anterior dizia que as duas candidatas naturais eram SMS e e-mail, e
que as duas esbarravam em decisão sua. **Havia uma terceira, e ela já roda:** o
resgate do programa de fidelidade manda um código de seis dígitos por WhatsApp
(`ClienteService::programaFidelidadeResgatarPontuacaoGerarCodigo`), pela fila
`push_app`, com as credenciais da unidade.

A autenticação do cliente usa exatamente esse caminho. Nenhum canal novo,
nenhuma decisão sua, nenhuma dependência do SMS que você excluiu.

*E a cobertura de e-mail continua immensurável aqui, o que reforça a escolha:* a
anonimização reescreve `clientes.email` para **todos** os clientes,
incondicionalmente (`anonimizar.sql.modelo:53`). Localmente 100% têm e-mail e
todos são únicos — nenhum desses dois números diz nada sobre a produção.

##### O sujeito da sessão é o TELEFONE, e não o cliente

A tentação é amarrar a sessão a um `cliente_id`. Está errada por dois motivos, e
o segundo é mais forte que o primeiro.

O primeiro é medido: **5.670 telefones da base têm mais de um cliente ativo**, e
o pior caso tem **354** (**D41**). Uma família compartilha o número, e o
aplicativo sempre listou todos para a pessoa escolher.

O segundo é de honestidade: um código entregue no WhatsApp prova **posse do
número**, e nada além disso. Dizer que ele prova "sou o cliente 149612" seria
afirmar mais do que se mediu. A sessão vale para os clientes daquele telefone
porque é exatamente isso que o código demonstrou.

##### O que foi construído

| | |
|---|---|
| `clientes_sessoes` | tabela nova, em `mysql.sql` e `postgres/schema.sql` |
| `POST /api/v4/sessoes` | pede o código; **202 sempre** |
| `POST /api/v4/sessoes/confirmacao` | troca o código pelo token; 201 ou 401 |
| `GET /api/v4/clientes` | os clientes do telefone autenticado |
| `GET /api/v4/clientes/:id/indicacoes` | só se o `:id` for daquele telefone |

**Nada disso toca nas rotas congeladas.** Versão nova, endereços novos, os
antigos vivos — é o passo 2 escrito neste débito desde 29/08. Os 46 testes de
contrato passam, e os 37 que já existiam não mudaram uma linha.

##### As três decisões que sustentam a segurança disto

**1. O endereço de pedir código não conta se o número existe.** Cadastrado e
desconhecido recebem **a mesma resposta, byte a byte** — há teste que compara os
dois corpos crus, e não a estrutura: `{"mensagem":"enviado"}` e
`{"mensagem":"nao encontrado"}` têm estrutura idêntica e entregam tudo. Sem
isso, este endereço seria um descobridor de clientes mais barato que a colheita
que este débito registra: um bit por número, em vez de uma resposta inteira.

**2. Seis dígitos só são defensáveis com teto.** São 10⁶ possibilidades, que sem
limite se esgotam em minutos. Cinco tentativas erradas matam a sessão — e depois
disso **nem o código certo passa**, que é o detalhe que faz o teto ser teto.
Dez minutos de validade, uso único.

**3. Nenhum dos dois segredos fica em claro.** O código é `password_hash`; o
token é `sha256` de 32 bytes de `random_bytes`. Algoritmos diferentes de
propósito: 10⁶ possibilidades precisam do custo do bcrypt, e 2²⁵⁶ não precisam —
ali o bcrypt só impediria a busca por índice.

##### O que o `/api/v4` deixou para trás, de propósito

**`cpf` e `rg` não vêm.** A contenção 2 deste débito discutia mascará-los na rota
antiga, e esbarrava em não saber se alguma tela do aplicativo instalado os
exibe. Aqui não há esse dilema — **este endereço não tem consumidor nenhum**.
Acrescentar um campo depois é aditivo; removê-lo seria quebra.

**Sem `Access-Control-Allow-Origin: *`.** Com `*` o navegador nem envia
credencial; as duas coisas são mutuamente exclusivas. As 19 ações da classe
antiga continuam com o cabeçalho aberto, e isso segue registrado abaixo.

**Datas em ISO 8601 com fuso**, e não `dd/mm/aaaa hh:mm:ss` — regra 6 da
convenção. Num aplicativo que roda no telefone de quem viaja, data sem fuso é
data ambígua.

##### Onde a prova está, e por que em dois lugares

- **`tests/Caracterizacao/AutenticacaoDoClienteTest.php`** — 12 testes, com
  `EntityManager` e a fila para ler. Prova o fluxo de **obter** o token: pedir
  código, ler da fila, confirmar, e o teto de tentativas. Inclui o que mais
  importa: a sessão alcança **todos** os clientes daquele telefone e **nenhum**
  de outro.
- **`tests/Contrato/ApiV4Test.php`** — por HTTP, o que vem **depois** do token.

*Snapshot de recusa não é preciosismo:* um endereço que devia responder 401 e
passa a responder 200 continua parecendo saudável em qualquer medição que olhe
só para "a API respondeu".

##### AS CARGAS ficaram sem congelamento por dois dias, e isso foi corrigido

Ao criar o `/api/v4` eu escrevi que o caminho feliz **não podia** ser provado
pela suíte de contrato — ela fala HTTP e não toca no banco, e o código de seis
dígitos não aparece em resposta nenhuma. Era verdade sobre *obter* o token, e eu
estendi a conclusão para o que vem depois dele.

**O resultado é que seis rotas tiveram suas cargas provadas à mão, com `curl`, e
mais nada.** Prova à mão não volta a rodar amanhã.

A saída não foi dar banco à suíte nem abrir atalho na aplicação: a **sessão de
cliente é criada na montagem do banco**, por
`scripts/definir-senha-contrato.php`, exatamente como já era feito com a senha do
gerente — e pelo mesmo motivo o token **não está versionado**. A suíte continua
sem tocar no banco; ela só conhece o valor.

As seis cargas estão congeladas desde 05/09/2026, e com elas duas asserções que
snapshot nenhum daria:

- **a lista tem DOIS clientes.** O snapshot congela tipo, e uma lista de um tem a
  mesma estrutura de uma lista de dois — a promessa "a sessão vale para a linha"
  não estaria verificada por ninguém.
- **um id de outro telefone responde 403 com token válido.** Até então essa regra
  era provada pelo serviço e vigiada pelo portão; agora é exercitada pela porta.

##### O QUE FALTA PARA ISTO ENTRAR EM CAMPO, e é seu

**1. O aplicativo novo.** Nada disto vale enquanto nenhum cliente falar `v4`. O
passo 1 (a W3 — quais versões estão em uso, que sai do log do nginx) continua
sendo o que destrava o resto.

**2. Oito unidades sem WhatsApp ativo.** Medido em 03/09: das 34 unidades ativas,
**26** têm `whatsapp_ativo`. Um cliente cuja unidade não tem canal **não
consegue autenticar** — o código não é enfileirado, e a sessão nem nasce, porque
uma linha ali seria promessa que ninguém cumpre. Ou se liga o WhatsApp nas oito,
ou se decide um segundo canal para elas.

**3. O par token de acesso + renovação não foi construído.** A convenção o
recomenda; aqui há um token só, de 30 dias. Sem aplicativo em campo não havia o
que exercitar, e a tabela tem espaço para o segundo. É acréscimo, não reforma.

#### AS TRÊS ÚLTIMAS, FECHADAS no mesmo dia

> *"ataque as três rotas restantes do D59"*

`/api/cliente/agenda/:id`, `/api/cupons/selecionar/:id` e
`/api/sorteios/selecionarCupons/:id` passaram a exigir sessão, e ganharam
sucessor autenticado em `/api/v4/clientes/:id/{agenda,cupons,sorteios}` — com a
mesma regra de propriedade: o `:id` só é aceito se pertencer ao telefone do
token, e um id de outro telefone responde **403**.

**A lista das quatro está encerrada.** Nenhuma rota da API do aplicativo aceita
mais um inteiro sequencial como credencial.

##### A DA AGENDA ERA DE OUTRA NATUREZA, e a diferença não é de grau

As outras expõem cadastro, cupom ou sorteio. Esta responde **onde uma pessoa vai
estar, e quando**: data, hora, o colaborador que vai atendê-la e a unidade com
estado, cidade e bairro. É localização futura de alguém, entregue a quem
escrevesse um número inteiro na URL.

Medido na cópia local em 03/09/2026: **77 pessoas** tinham agendamento futuro,
somando 615 marcações, a mais distante em 2030. Na produção o número é outro; a
forma é a mesma.

*O registro anterior chamava as três de "menos graves que as duas já
fechadas".* Para cupons e sorteios isso se sustenta. Para a agenda, não: dado de
localização não é da mesma família que dado de cadastro.

##### A carga não mudou, e isso foi conferido

Os três snapshots autenticados são **idênticos, byte a byte**, aos contratos que
existiam antes — só a porta mudou. O que se comparou foi o arquivo antigo contra
o novo, e não a impressão de que nada mudou.

##### O que o `/api/v4` corrigiu de passagem

| | rota antiga | `/api/v4` |
|---|---|---|
| dinheiro | `"R$ 89,90"` | `"89.90"` — regra 7 |
| data e hora | `24/12/2026 09:00:00` | ISO 8601 com fuso |
| data sem hora | `31/12/2030` | `2030-12-31` |
| endereço | uma string montada | campos separados |
| relógio | `date()` no meio da consulta | lido uma vez, e daqui |

*Validade de cupom e data de sorteio ficaram sem fuso, de propósito:* são
**dias**, não instantes, e carimbar fuso neles inventaria uma precisão que o
dado não tem.

##### UMA COLISÃO QUE SÓ O CENÁRIO DO CI PODIA ACUSAR

As fixtures novas criaram uma venda com id `90001200` — e `AutorizacaoFiadoTest`
cria a venda **dele** com esse mesmo id. Localmente a suíte de caracterização
roda contra o banco de desenvolvimento, onde as fixtures **não** estão
carregadas: a colisão era invisível aqui e certa no CI.

Duas correções, e a segunda vale mais que a primeira:

1. **Uma faixa declarada.** `90001000–90001199` é das fixtures; de `90001200`
   para cima é dos testes, que inserem linhas próprias na transação revertida. O
   cenário novo mudou para `9000115x`, e a regra está escrita no `fixtures.sql`.

2. **`scripts/rodar-caracterizacao-como-ci.sh`.** Monta o mesmo banco que o CI
   monta — `mysql.sql` mais as fixtures, e nada mais — e roda a suíte contra ele.
   Foi ele que achou a colisão, depois de a execução normal passar 184 de 184.

*Ele não substitui a execução normal:* contra fixtures, os testes que dependem
do histórico da produção se declaram pulados, e são justamente os que a execução
normal cobre. São dois cenários, e um defeito que aparece num não aparece no
outro.

#### E HAVIA UMA QUINTA, que esta lista nunca teve

Encontrada em 03/09/2026, logo depois de fechar as quatro — ao varrer as ações
que mandam `Access-Control-Allow-Origin: *`.

`GET /api/agendas/getConsumoCliente?id=<n>` devolvia, **sem cabeçalho nenhum**:

    CPF e RG, nome, telefone, e-mail, convênio
    o HISTÓRICO DE COMPRAS inteiro -- data, unidade, serviço,
      o colaborador que atendeu e o valor de cada venda
    a pontuação do programa de fidelidade

É tudo o que o `getCliente` entregava, **mais** para onde a pessoa vai, com quem
se atende e quanto gasta. Medido contra a aplicação de pé.

##### O D13 JÁ A LISTAVA, e eu não olhei antes de chamá-la de achado

Antes de qualquer explicação: **isto não foi uma descoberta.** O **D13**, escrito
em 16/08/2026, diz textualmente que *"`/api/cliente/agenda/:id` e
`/api/agendas/getConsumoCliente` expõem dados de clientes por enumeração de id"*.
Estava lá o tempo todo, com o nome da rota.

O que aconteceu é que eu varri o código e reportei o resultado sem reler o débito
que cobre a área. **É a segunda vez no mesmo dia** — a primeira foi o D89, que
registrei como descoberta quando já era a oitava linha do D3. Nos dois casos foi
um portão, e não eu, que corrigiu o registro.

##### POR QUE A LISTA DO D59 NÃO A TINHA, que é outra pergunta

A lista *deste* débito foi montada a partir das rotas com **`:id` no caminho**.
Esta recebe o id na **query string**. Forma diferente, mesmo defeito — o
inventário procurava uma forma, não uma propriedade.

*A varredura de agora foi por propriedade*, e não por forma: **toda ação que
recebe um identificador de quem chama**. Deu nove, e o veredito de cada uma está
medido:

| | |
|---|---|
| já trancadas | 5 |
| **a trancar** | **1** — `clienteConsumoSelecionar` |
| catálogo público (unidade, serviços) | 3 |

`agendaSelecionar` merecia o exame e passou: recebe id de **colaborador**, não de
cliente, e com `$app = true` devolve **só os horários livres** — sem nome, sem
telefone. Grade de disponibilidade não é dado de pessoa.

##### O que foi feito

A rota exige sessão, e ganhou sucessor em `GET /api/v4/clientes/:id/consumo`,
com a mesma barreira das outras quatro. **`cpf` e `rg` não vão na carga nova**:
uma tela de histórico não precisa do documento de quem já está autenticado.

A carga autenticada da rota antiga é **idêntica, byte a byte**, ao contrato que
existia — conferido, e não presumido.

##### Um teste que passou a provar outra coisa

`testClienteConsumoSemDados` congelava a resposta de um POST sem parâmetro.
Agora a recusa acontece **antes** de olhar parâmetro nenhum, então o corpo é o
mesmo do `-sem-autenticacao`. O que ele ainda prova não é nada: **a recusa não
depende do verbo** — um guarda posto só no ramo do GET deixaria o POST passar.
Fica registrado no próprio teste, que é onde quem o lê está olhando.

#### O CORS: DECIDIDO ação por ação, em 03/09/2026

O registro tratava as vinte ações como um bloco — *"o CORS aberto nas outras
18"*. Olhadas uma a uma, elas são três grupos com vereditos diferentes, e o
raciocínio que separa os grupos corrige uma frase deste próprio débito.

**O que `Access-Control-Allow-Origin: *` faz, com precisão:** ele diz ao
navegador que qualquer origem pode **ler a resposta**. Ele **não** decide se o
pedido é enviado. Um POST de formulário é "requisição simples": o navegador
manda de qualquer jeito, e o CORS só esconde a resposta do script.

Isso importa porque a frase que este débito repetia — *"com `*`, qualquer página
lia a resposta entre origens"* — é verdadeira para **leitura**. Para **escrita**,
o risco não é ler.

##### Grupo 1 — as nove autenticadas: o cabeçalho SAIU

`*` e credencial são mutuamente exclusivos: o navegador se recusa a mandar cookie
para uma resposta que diz `*`. Manter os dois era anunciar "qualquer um pode ler"
ao lado de um guarda que exige sessão.

Não quebra nada: um chamador de navegador já estava impedido, e o aplicativo
nativo não olha CORS.

##### Grupo 2 — as sete de catálogo: o cabeçalho FICA

`unidades`, `unidade`, `unidadeV2`, `servicos`, `banners`, `paises`, `versao` e a
grade de horários livres devolvem dado público — é exatamente para isso que `*`
existe. Tirá-lo não protegeria nada e quebraria qualquer página que consulte
unidades.

##### Grupo 3 — as duas escritas abertas: CORS não era a pergunta

`POST /api/clientes/novo` cria linha na tabela de 243 mil clientes.
`POST /api/clientes/sac` dispara e-mail para a unidade. As duas **não podem**
exigir credencial — quem se cadastra ainda não é cliente, e o SAC é formulário
de contato.

Tirar o `*` delas seria teatro, pelo motivo acima: o navegador manda o POST
assim mesmo. **O que faltava não era CORS, era teto de taxa** — e não havia
nenhum.

**Contenção aplicada**, mesmo desenho do `getCliente`: zona `escrita_publica`,
10 por minuto por endereço, `burst=5 nodelay`, `429` na recusa. Não muda resposta
nenhuma, então não toca no contrato.

*Posto à prova:* 30 POSTs em paralelo no cadastro — **6 atendidos, 24 recusados**.
Um pedido isolado logo depois: 200. E a ressalva de sempre continua sendo o
**CGNAT** — o teto encurta a janela, não fecha a porta.

#### O que continua aberto neste débito

Nada da lista original. O que ficou é trabalho de construção, registrado no
**D91**: os sucessores autenticados de *marcar* e *indicar*, que dependem de
reescrever regra de negócio de verdade na superfície nova.

#### CONTENÇÃO 2 — mascarar `cpf` e `rg`: superada pela decisão acima

*Ficou sem efeito prático para esta rota*: quem não se autentica não recebe
resposta nenhuma, mascarada ou não. Ela volta a fazer sentido se um dia a rota
precisar responder a um cliente autenticado com menos do que responde a um
operador — que é uma pergunta para quando a metade do cliente existir.

O que estava medido a favor dela, e continua valendo como registro:

Não apliquei, e o motivo é que ela muda o que o aplicativo instalado recebe.

**O que está medido a favor:** o snapshot de contrato registra `"cpf": "string"`
e `"rg": "string"` — **tipo, não valor**. A suíte não acusaria, e nenhum
aplicativo que apenas repassa o campo quebra.

**O que não dá para medir aqui:** se alguma tela do aplicativo exibe o CPF
inteiro. Isso exige olhar o aplicativo, e ele não está neste repositório.

**Um argumento a favor que não estava escrito:** máscara parcial preserva a
função que o campo tem nesse fluxo. Quando várias pessoas dividem o telefone — e
são 5.670 números —, a tela precisa que o cliente se reconheça na lista; alguns
dígitos bastam para isso, e o documento completo não é necessário para escolher.

**Também não dá para medir aqui quantos clientes reais têm CPF e RG
preenchidos:** a anonimização gera CPF para todos e zera o RG
(`anonimizar.sql.modelo`, linhas 47 e 48). Qualquer contagem local seria
artefato — a mesma razão pela qual `medir-senhas-producao.sql` existe.


#### O APARELHO DO PUSH, em 10/10/2026 — mais uma sub-rota de `:id`, e a primeira para o aplicativo novo

`POST` e `DELETE /api/v4/clientes/:id/aparelhos` (PUSH-FCM-APARELHOS, **D461**): o aplicativo novo registra e retira o
aparelho do Firebase com a sessão deste débito, e o `:id` passa pela mesma `barreira()` (401 sem token, 403 para o id
de outro telefone). O aparelho vai para o cliente da rota, e não para todos do telefone. **E o aparelho segue a regra
deste débito no tempo** (achado da verificação, 10/10/2026): a sessão alcança o cliente só enquanto o telefone dele for
o dela, e o aparelho só recebe o push do cliente enquanto o telefone dele for o da sessão que o registrou (a linha o
guarda). O telefone errado que a unidade corrige deixava o aparelho de quem tinha o número com o push do cliente para
sempre — o `DELETE` dele já é 403 depois da correção. Não muda o estado deste
débito: as rotas congeladas continuam abertas até o aplicativo novo, e **o aplicativo instalado não tem este login**
-- a pergunta de adotá-lo no aplicativo novo, com a recomendação (sim), está no lote. Nada de rota sem autenticação
para o aparelho: com o id valendo como credencial, qualquer um poria o próprio aparelho no lugar do de outra pessoa.

#### A RENOVAÇÃO DA SESSÃO V4, em 11/10/2026 (PUSH-TERCEIRA-RODADA: a resposta 71 do dono)

**Decidido pelo dono** (a resposta 71, a opção (a)): o aplicativo novo adota o login V4. O que era o item 3 de "O que
falta para isto entrar em campo" -- "o par token de acesso + renovação não foi construído" -- ficou assim, **no molde
da V4 que existe e sem tabela nova**: `POST /api/v4/sessoes/renovacao`, com o `Authorization: Bearer` de agora e sem
corpo, troca o token que ainda vale por um novo, de mais 30 dias **a partir do pedido**, e devolve 201 com a forma da
confirmação (`token`, `tipo`, `expiraEmDias`). O de antes morre na troca: é a mesma linha de `clientes_sessoes`, com
o `token_hash` e o `token_expira_em` novos, e o telefone, a confirmação e o teto de tentativas como estavam. Sem token,
sem o prefixo `Bearer`, noutro esquema, com o vencido, o encerrado ou o inventado: 401 mudo, como em toda a V4.
**A troca é uma conta condicionada no banco** (`ClienteSessaoRepository::trocarToken`: o `UPDATE` só acha a linha com
o hash de antes, não encerrada e não vencida): dois pedidos com o mesmo token renovam uma vez só, e o que perde recebe
401 -- regravar a entidade lida daria um token aos dois, e só o último gravado valeria. O aplicativo instalado não
muda: ele não fala V4. O guia (`docs/push-guia-do-aplicativo.md`, "O login") diz quando renovar e o que fazer com a
resposta perdida (o token de antes já não vale: o caminho é o login).

**Provado** no `RenovacaoDaSessaoTest` (9 casos, nos dois bancos): o token que vale renova, o novo abre a mesma sessão
e os mesmos dois clientes do telefone, e o de antes não abre mais; a validade conta do pedido (renovada no 20º dia,
vale no 45º e vence no 51º); o vencido, o encerrado, o inventado, o vazio e o ausente não renovam, e a linha fica como
estava; dois pedidos com o mesmo token trocam uma vez só, pelo repositório e pelo serviço que perde a corrida; a
troca não alcança a vencida nem a encerrada; e a rota, pela fábrica e pela entrada do ERP (`api-v4-sessoes/renovacao/
renovar`), 201 com o token novo e 401 para o de antes, já trocado, e para as cinco credenciais que não valem. No
`ApiV4Test`, pela porta HTTP: o 401 sem token (o snapshot `v4-sessoes-renovar-sem-autenticacao`), o token inventado
e o da suíte sem o `Bearer` ou noutro esquema, e o GET que não casa rota; a carga não, porque trocaria o token de que
as outras cargas dependem. Os mutantes e os portões estão no roadmap de 11/10/2026.

**O que segue, e é do dono** (as perguntas estão no lote, PUSH-TERCEIRA-RODADA): **as unidades sem WhatsApp**, que não
mandam o código -- medido na cópia em 11/10/2026, **6 das 32 unidades ativas** (ids 22, 25, 32, 58, 60 e 61), com
18.735 clientes ativos; o "oito" de 03/09 contava as duas unidades de fixture sem WhatsApp, e se as 26 restantes têm a
chave preenchida a cópia não diz (o anonimizador apaga as chaves). As rotas congeladas seguem abertas até o
aplicativo novo, como antes.

**O prazo máximo da sessão renovada, decidido pelo dono** (a pergunta 148, respondida em 11/10/2026, às 14:39, com a
opção (a), *"Sem prazo máximo, como está"*): **a sessão renovada fica sem prazo máximo, por decisão dele.** Ela renova
sem fim, 30 dias a partir de cada pedido, como o `RenovacaoDaSessaoTest` prova; o código não mudou. O que essa escolha
aceita, e foi dito na pergunta: o número que a operadora repassa a outra pessoa mantém o aparelho antigo, que segue
renovando, alcançando os clientes daquele telefone -- inclusive o da pessoa nova, se ela se cadastrar com ele. Se um dia
o prazo vier, ele sai da `confirmado_em`, sem mudar a tabela.

#### O CÓDIGO PELO WHATSAPP CENTRAL DA FRANQUEADORA, em 12/10/2026 (o item WHATSAPP-F0-E-CENTRAL: a resposta 147 do dono)

**Decidido pelo dono** (a pergunta 147, "as unidades sem WhatsApp não mandam o código de entrada do aplicativo novo",
respondida sem opção, com a observação): *"Para o envio de codigo, utilizar um WhatsApp centralizado, da franqueadora e
nao da unidade"*. **Só o código de entrada muda de canal:** o código do resgate, o parabéns, o aviso e as campanhas seguem
pelo WhatsApp da unidade.

- **A franqueadora não é uma unidade** no banco: na cópia, nenhuma unidade marcada nem com nome de franqueadora, matriz ou
  escritório, e a 1 é uma barbearia como as outras; no código ela aparece como configuração (o destino do SAC é uma
  constante). Por isso **a credencial central mora na configuração do ambiente**, como as outras chaves: o
  `whatsapp.central` do `config/autoload/local.php` (o provedor, a instância e a chave), que não é versionado. O
  `local.php.dist` traz o bloco vazio e o passo; o `scripts/verificar-local-php.php` o lista como GRAVE.
- **`ClienteAutenticacaoService::pedirCodigo`** cria a sessão e enfileira o código quando o telefone é de um cliente ativo
  (não o "SEM CADASTRO") e o central está pronto (`ISBase\Util\WhatsAppCentral::daConfiguracao`: a instância e a chave
  preenchidas, só de espaços contando como vazia, e o provedor `evolution` ou `waha`). A unidade do cliente não decide mais:
  **o cliente de unidade sem WhatsApp passa a receber o código.** Sem o central, nenhum código sai e nenhuma sessão nasce
  -- a mesma regra de antes para "sem canal". A resposta continua 202 nos dois casos (o D59 não conta se o número existe).
- **A chave não vai para a fila:** a linha leva o provedor `central` e a chave e a instância vazias, e
  `CronJobService::marketingPush` lê a credencial do `local.php` na hora do envio. Sem ela, a linha falha e fica na fila
  marcada (o D302).

**Provado** no `AutenticacaoDoClienteTest` (13 casos, nos dois bancos; o serviço montado com um central inventado) e no
`WhatsAppQueFalhaTest`: a linha do código com o provedor `central` e sem chave nem instância; o central vazio, só de
espaços ou de provedor desconhecido, sem sessão e sem linha; o cliente de unidade sem WhatsApp com sessão; a rotina da
fila mandando a linha do central com a credencial da configuração, e a da unidade com a dela; e, sem o central, a linha
dele marcada como falha, sem envio. No `SegredosForaDoCodigoTest`, o verificador do `local.php` com as duas chaves do
central como GRAVE, e sem imprimir o valor. Seis mutantes, um de cada vez, vermelhos (a tabela no roadmap de 12/10/2026).

**O que é do dono, e não foi decidido aqui:** qual número e qual credencial. Para ligar: criar (ou escolher) a instância da
franqueadora no provedor -- o Evolution ou o WAHA, os das unidades --, conectar nela o telefone da franqueadora, e
preencher os três valores no `local.php` da produção (o passo está no `local.php.dist`). Até lá, o login V4 não manda
código a ninguém -- o aplicativo instalado não usa o login V4, e as rotas congeladas seguem abertas até o aplicativo novo,
como antes. No servidor de teste, o passo 9.1 de `docs/ambiente-teste.md` (a resposta 149) passou a pôr um central de
mentira no `local.php` do teste, no lugar de ligar o WhatsApp de uma unidade.

**A verificação do item (12/10/2026)** achou e consertou duas coisas. **O provedor em maiúsculas:** o formulário de
Unidades rotula a opção como "WAHA", e `'provedor' => 'WAHA'` no `local.php` dava o central como não configurado -- o
login V4 parava de mandar código em silêncio, com o `verificar-local-php.php` dizendo `ok` nas duas chaves. Agora
`WhatsAppCentral::daConfiguracao` tira a caixa e os espaços do provedor, e o verificador ganhou a linha
`whatsapp.central.provedor`: com a instância e a chave preenchidas, o provedor que nenhum cliente conhece sai GRAVE (pela
mesma classe, e não por uma cópia da conta; o recusado não é impresso). **Cada metade sozinha:** os casos tinham as duas
vazias juntas, e cada checagem saía sem vermelho; o `testSemOWhatsAppCentralNaoHaSessaoNemMensagem` mede agora só a
instância vazia e só a chave vazia. Casos novos: `AutenticacaoDoClienteTest::testOProvedorDoCentralEmMaiusculasValeComoOMinusculo`
(o " WAHA " cria a sessão), `WhatsAppQueFalhaTest::testOCentralComOProvedorEmMaiusculasSaiPeloWaha` (a linha sai pelo
cliente do WAHA) e `SegredosForaDoCodigoTest::testOVerificadorRecusaOProvedorDoCentralQueNinguemConhece`; quatro mutantes,
um de cada vez, vermelhos (a tabela no roadmap). **Qual número e qual credencial** foi ao lote como pergunta
(WHATSAPP-F0-E-CENTRAL, a verificação): até a resposta, a configuração fica vazia, e o login V4 não manda código a
ninguém -- nem aos clientes das 26 unidades que recebiam pelo WhatsApp da própria unidade.

#### PARCIAL em 14/10/2026 -- o número central (a resposta 169; o item WHATSAPP-E-APLICATIVO)

**Decidido pelo dono em 14/10/2026** (a pergunta 169 da página do lote, a opção (a), a recomendada): *"Um número novo da
franqueadora, só para os códigos -- numa instância nova no provedor, conectada a um aparelho da franqueadora; o passo de
preencher está escrito no modelo da configuração."* Registrado, sem código.

**Conferido o passo, e faltava uma metade.** O `local.php.dist` traz o bloco `whatsapp.central` vazio e o passo desde
12/10/2026, e o `scripts/verificar-local-php.php` lista a instância e a chave como GRAVE. **O corte não o levava:** o
passo 7 de `docs/procedimento-de-corte.md` troca o `local.php` pelo `local.postgres.php.dist`, que não tinha o bloco, e a
lista do que levar do `local.mysql.php.guardado` tinha só a chave do Asaas e o token das rotinas. Quem preenchesse o
central antes do corte o perderia no passo 7, e o login V4 voltaria a não mandar código a ninguém, com o verificador
dizendo GRAVE numa lista que "não reprova". Agora:

- o `config/autoload/local.postgres.php.dist` traz o bloco vazio, com o passo (o número novo da 169, a instância nova, de
  onde vêm os valores no corte); o `rodar-caracterizacao-postgres.sh`, que monta o `local.php` dos testes dele, passou
  verde com o bloco;
- o passo 7 do procedimento ganhou a terceira entrada, `whatsapp.central` -- do guardado, se a instância já estiver
  conectada e preenchida; senão, da instância nova, depois --, e a conferência do `verificar-local-php.php` fala das três.

Nenhum valor real em lugar nenhum: os três campos estão vazios nos dois modelos, e o procedimento não traz número nem
chave.

**O que resta (por isso PARCIAL), do dono:** mandar criar a instância nova no provedor (o Evolution ou o WAHA, os das
unidades), conectar nela o aparelho da franqueadora, e os três valores no `local.php` da produção. Até lá, 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.

#### ANOTADO em 14/10/2026 — as seis leituras reabertas ao aplicativo instalado, pela resposta 189 (b) do dono

> *"A etapa 1 sobe com as dez rotas abertas ao aplicativo de hoje: como estão hoje na produção, e elas fecham quando o
> aplicativo novo estiver em campo. A exposição fica a de hoje, nem maior nem menor."*

**A exposição deste débito volta, por decisão do dono, até o aplicativo novo estar em campo.** As seis leituras —
`/api/agendas/getCliente` (o telefone que devolve o CPF e o RG de até 354 pessoas), `/api/cliente/indicacoes/:id` (o
telefone de terceiros), `/api/cliente/agenda/:id` (onde a pessoa vai estar), `/api/cupons/selecionar/:id`,
`/api/sorteios/selecionarCupons/:id` e `/api/agendas/getConsumoCliente` (o histórico de compras) — respondem de novo sem
sessão, com o `Access-Control-Allow-Origin: *` de julho, como a produção de hoje. A contenção 1 (o teto de taxa do
`getCliente` no nginx) não muda.

O interruptor, o que fecha de novo e o provado estão no **D91** ("ANOTADO em 14/10/2026"): é a mesma constante para as
dez, `AplicativoController::ROTAS_DO_APLICATIVO_INSTALADO`. **Fecha quando o aplicativo novo, que fala o `/api/v4`,
estiver em campo** — o passo 2 deste débito —, trocando a constante para `fechadas`: o 401 volta, o cabeçalho sai, e a
suíte de contrato passa a conferir os `-sem-autenticacao`, guardados. Quantos pedidos por dia as dez recebem (o número
que diz quantos clientes o fechar atinge) é a leitura 19 do procedimento da etapa 1, na produção. O que fica diferente
de julho nas dez, e o que fica igual e pode surpreender (o colaborador da unidade inativa no marcar, a data que não existe,
o corpo do V3 em disco), está no D91.

---

### D60 — A integração fiscal por unidade manda o segredo na query string

Registrado em 29/08/2026, ao fechar a última lacuna da suíte de contrato.

`/api/unidade/vendas`, `vendasV2` e `vendasV3` são a **única parte da API que
autentica**: exigem um `hash` que bata com o da unidade. É uma boa notícia no
meio do D59 — mas o segredo viaja onde não devia.

```
GET /api/unidade/vendas?id=7&hash=<segredo-da-unidade>&inicio=...&fim=...
```

**O hash é a credencial, e a credencial vai na URL.** Isso significa que ela é
gravada em texto claro no log de acesso do nginx, de toda requisição, para
sempre — e o log é rotacionado, copiado, e às vezes enviado para fora. Também
entra em `Referer`, em histórico de proxy e em qualquer captura de tráfego que
registre a linha de requisição.

É o mesmo defeito de forma do D59, que expõe telefone na query, com uma
diferença: **aqui o que vaza não é dado de uma pessoa, é a chave que abre o
faturamento de uma unidade inteira**.

**O que essa chave abre**, medido pela resposta dos três endpoints: número da
nota, data e hora, valor bruto e líquido, forma de pagamento, itens vendidos,
colaborador, e — no `vendasV3` — nome, telefone, CEP, endereço e data de
nascimento dos clientes.

**O que já melhorou:** os três ganharam teste de contrato em 29/08 e a checagem
do hash ganhou uma asserção própria
(`testUnidadeVendasRecusaHashErrado`). Antes, afrouxar a verificação não faria
teste nenhum acusar.

**A correção, quando houver janela:** o segredo passa a viajar em cabeçalho
(`Authorization`), não em query. É mudança de contrato e portanto precisa ser
combinada com quem consome — o que exige, primeiro, **descobrir quem consome**.
As três versões coexistindo sugerem mais de um consumidor, e
`scripts/inventariar-versoes-app.php` responde isso a partir do log: mostra
quais das três estão vivas e com que frequência.

Enquanto isso não vem, vale saber que **rotacionar o hash de uma unidade é
barato** — é uma coluna — e que qualquer suspeita de vazamento de log se resolve
com uma rotação.

---

### D61 — Seis endpoints que mudam estado só têm contrato do caso vazio — OS SEIS COBERTOS em 11/09/2026, com resíduo medido ZERO — PARCIAL em 06/10/2026 — PARCIAL em 08/11/2026: os 22 roteiros que entram no ERP devolvem o login; ficam o fechamento da agenda e a variante do DDI

Registrado em 29/08/2026, ao varrer os snapshots atrás do defeito que o D55
tinha ensinado a procurar.

**A varredura foi feita e a maior parte já está fechada.** Dez snapshots
terminavam em `-sem-dados` sem par: o formato que o aplicativo realmente
interpreta não estava congelado em lugar nenhum. Três eram de leitura e ganharam
o caso com dados no mesmo dia — `cliente-selecionar`, `cliente-consumo` e
`agenda-selecionar-v2`.

Este débito é o que sobrou, e sobrou por um motivo que vale escrever.

| Endpoint | O que faz |
|---|---|
| `/api/agendas/cancelar` | desmarca um agendamento |
| `/api/v2/agendas/agendar` | cria agendamento |
| `/api/v3/agendas/agendar` | cria agendamento |
| `/api/clientes/novo` | cadastra cliente |
| `/api/cliente/indicacao` | registra indicação |
| `/api/clientes/sac` | abre chamado de SAC |

> **QUATRO DOS SEIS MUDARAM DE PORTA em 03 e 04/09/2026.** `cancelar`, as duas
> versões de `agendar` e `indicacao` passaram a exigir sessão (**D91**), e seus
> snapshots foram renomeados de `-sem-dados` para `-sem-autenticacao` — porque o
> corpo vazio passou a ser recusa, e não falta de dado.
>
> *Isso não resolve este débito, e vale dizer por quê.* A suíte ganhou login
> (`getAutenticado()`), então a barreira deixou de ser "não sabe autenticar" e
> passou a ser só a outra — cobrir o caso com dados continua significando
> **escrever no banco**. A decisão abaixo continua inteira.
>
> Os outros dois — `clientes/novo` e `clientes/sac` — continuam abertos por
> desenho: quem se cadastra ainda não é cliente. Eles ganharam teto de taxa no
> nginx (**D59**), que não é cobertura de contrato.

**Os seis mudam estado, e é isso que os torna diferentes.** Cobrir o caso com
dados significa a suíte de contrato passar a **escrever no banco** — e aí ela
deixa de ser repetível: rodar duas vezes seguidas sem recriar o banco daria
resultados diferentes na segunda.

Não é impeditivo, e há caminho: o CI monta o banco do zero a cada execução, e
localmente `scripts/montar-banco-contrato.sh` faz o mesmo em um comando. Mas
**muda o que a suíte é** — de "leitura que confere formato" para "roteiro que
exige banco limpo" — e isso merece decisão, não uma escolha minha no meio de
outra tarefa.

**O que está em jogo, dos dois lados:**

*A favor de cobrir:* é exatamente onde a quebra dói mais. Um cliente que não
consegue agendar pelo aplicativo é pior que uma tela de consumo com formato
estranho, e a Fase 2 vai mexer nesses endpoints.

*Contra:* uma suíte que exige preparação é uma suíte que alguém vai rodar sem a
preparação, ver falhar, e aprender a ignorar. Suíte que se ignora não protege
nada — é a mesma lição do D55 por outro caminho.

**~~Uma terceira via, se a decisão for difícil:~~ cobrir só `cancelar`, que muda
estado de forma reversível.** — **ESTA FRASE ESTAVA ERRADA, e o erro era meu.**

#### A correção, medida em 31/08/2026

`cancelar` **não** muda estado de forma reversível. `AgendaService::desmarcar`
arquiva a linha e depois a **apaga**:

```php
$horariosFilhos = $repo->selecionarHorariosFilhos($idPai);   // casa `age.id = :idPai`
foreach ($horariosFilhos as $horarioFilho) {
    $this->arquivarAgendaExclusao($horarioFilho, ...);
    $this->em->remove($horarioFilho);
    $this->em->flush();
}
return true;
```

Escrevi "reversível" sem ter lido o serviço. O efeito prático desmonta a terceira
via inteira: numa segunda execução sem recriar o banco, a agenda não existe mais,
`AplicativoController::agendaDesmarcarAction` cai em `empty($agenda)` e devolve
`{sucesso: false, mensagem: "ID inválido."}` — e o teste falha.

**Ou seja: cobrir o `cancelar` também transforma a suíte em "roteiro que exige
banco limpo".** Era exatamente o custo que a terceira via prometia evitar.

#### DECIDIDO em 11/09/2026 — e a premissa que eu ofereci estava errada

> *"Cobrir com restauração"*

Eu levei a decisão ao dono com um argumento novo: os ensaios provaram o padrão de
**restaurar-o-que-escreveu**, então a suíte de contrato poderia cobrir os seis e
continuar repetível. **A premissa era minha e estava errada**, e o código a
desmentiu em uma linha:

```php
abstract class ContratoTestCase extends TestCase
```

PHPUnit puro. A suíte **não tem conexão com o banco**, por desenho — até a
conferência de *"o banco de fixtures está no lugar"* é feita por HTTP, chamando
`/api/agendas/getUnidades` e olhando o JSON. Restaurar exige ler e escrever no
banco. Dar conexão àquela suíte muda o que ela **é** — que é precisamente a
decisão que este débito queria evitar tomar de passagem.

#### A via que existe, e que é melhor que as duas originais

A cobertura foi para um **ensaio**: `scripts/ensaiar-escritas-do-aplicativo.php`.
Lá o padrão já mora, e há uma razão que não era visível quando o débito foi
escrito:

**No PostgreSQL, a declaração de inquilino vem de graça num ensaio.**

`agendas` e `agendas_exclusoes` têm `FORCE ROW LEVEL SECURITY` com política que
compara `inquilino_id` com `current_setting('app.inquilino_id')`
(`postgres/schema.sql:1766-1778`), e a aplicação conecta como `bvip_app` — papel
comum, RLS em vigor. Numa conexão que não declara inquilino, **toda consulta
volta vazia: sem erro, sem aviso, sem exceção** — o próprio
`ISBase\Doctrine\DeclararInquilino:14-20` diz isso em voz alta.

Um teste que abrisse conexão crua para ler *"o que havia antes"* leria **zero
linhas** e restauraria **nada**, em silêncio. E seria **invisível no CI**: o job
de PostgreSQL conecta como o dono do banco, que ignora RLS, e o job diz que isso
é deliberado. Verde no CI e inútil na máquina de quem desenvolve é pior que
vermelho nos dois.

No ensaio a conexão vem de `Application::init()`, e `DeclararInquilino` é ouvinte
de `postConnect`: a GUC é ajustada sem ninguém pedir.

#### O primeiro dos seis, e o que ele afirma

`POST /api/agendas/cancelar`. Três afirmações, como as recusas do D170 — o código
HTTP, **as chaves exatas** do corpo, e o **efeito no banco**:

| afirmação | por que ela e não outra |
|---|---|
| HTTP 200 | — |
| corpo é exatamente `{sucesso}` | o erro devolve `{sucesso, mensagem}`; *"contém sucesso"* não distinguiria os dois, e é o primeiro que o aplicativo recebe |
| nenhuma linha de agenda em pé | `desmarcar()` devolve `true` **mesmo quando o laço não roda** — agenda com `status = 0` não casa `selecionarHorariosFilhos` |
| arquivou tantas quantas apagou | uma implementação que apagasse sem arquivar perderia o rastro e responderia o mesmo `{sucesso:true}` |

Escreve em **três** tabelas, e devolve as três pelo valor lido antes:
`agendas` (re-`INSERT` das linhas lidas), `agendas_exclusoes` (ancorado no
`MAX(id)` lido antes — as fixtures **nunca** limpam essa tabela) e
`clientes.ultimo_acesso_app`.

**Medido:** resíduo **ZERO**, e **três execuções seguidas sem remontar o banco**
passam igual — que era a objeção original deste débito, respondida com número.

**Falsificado nas duas pontas:** desligada a restauração, a primeira execução
acusa *"agendas: voltaram 0 de 1 linhas"*, e a segunda é **barrada antes de
medir** — *"a agenda não está no cenário"*. A segunda rede recusa medir o vazio
em vez de dar falso verde.

#### O segundo: `POST /api/cliente/indicacao`

Este cria **duas** linhas — um cliente (o convidado) e a indicação que liga
indicador e convidado —, e as duas se referenciam **cruzado**:
`clientes.indicacao` aponta para a indicação, `indicacoes.convidado` aponta para
o cliente. A ordem da remoção não é preferência.

O sucesso devolve `{sucesso, indicacao_id, url}`; a recusa devolve
`{sucesso, mensagem}`. Muda o status **e** todos os campos menos `sucesso` — e o
snapshot que existia congelava só a segunda forma.

Quatro afirmações, e as duas últimas são as que distinguem *"respondeu"* de
*"fez"*:

| afirmação | por que ela |
|---|---|
| HTTP 200, corpo exatamente `{indicacao_id, sucesso, url}` | — |
| o cliente convidado existe | — |
| a `url` do convite aponta para **esse** convidado | uma url montada com o id errado manda o convite para outra pessoa |
| `clientes.indicacao` aponta de volta para a indicação | sem a ligação de volta, a indicação existe e não rende nada ao indicador — e o endpoint responde o mesmo `{sucesso:true}` |

**O mesmo método de serviço atende a rota nova** `POST
/api/v4/clientes/:id/indicacoes`: dois controladores, um escritor. O que se
congela aqui vale para os dois.

#### Duas coisas que o vermelho da primeira execução ensinou

**1. A coluna não guarda o que o serviço monta.** O serviço compõe
`'+' . $ddi . ' ' . $telefoneLimpo` — com espaço — e passa ao setter. Mas
`Cliente::setTelefoneSemMascara()` aplica `removerMascaraTelefone()` **de novo**,
e o espaço cai: a coluna guarda `+5547988880001`. Minha busca procurava a grafia
com espaço e não achava o cliente que o endpoint acabara de criar — o ensaio
ficou vermelho dizendo *"respondeu sucesso e não criou o cliente"*, que era falso.
A asserção estava certa em existir; quem estava errado era eu.

**2. E isso tem consequência fora do ensaio.** A checagem de duplicata do próprio
serviço prova **duas** grafias (`AplicativoService:262-265`), e a coluna só tem
uma — a variante com espaço **nunca** casa, porque nunca esteve lá. Não é defeito
de resultado: a segunda variante pega a forma real. É um pedido por chamada que
não pode achar nada, e uma linha que faz quem lê concluir que as duas grafias são
conferidas.

**A cascata também apareceu:** `FFK_INDICACOES_convidado` é `ON DELETE CASCADE`
(`mysql.sql:854`), então apagar o convidado leva a indicação junto. O `DELETE`
explícito da indicação ficou assim mesmo — depender da cascata seria depender de
o passo seguinte dar certo, e se o cliente não for encontrado a indicação ficaria
órfã apontando para uma linha que existe.

**Medido:** resíduo **ZERO** nas cinco tabelas, e três execuções seguidas sem
remontar o banco.

#### O terceiro: `POST /api/clientes/novo`

**Este não exige sessão, e é por desenho:** quem se cadastra ainda não é cliente.
O que o protege é teto de taxa no nginx — e é o **mesmo balde** do
`/api/clientes/sac`. O ensaio gasta um crédito por execução.

A forma do sucesso é a mais traiçoeira dos três: **`mensagem` vem no sucesso**,
vazia. A ação a atribui sempre, e o validador devolve string vazia quando tudo
passa. Então:

| | corpo |
|---|---|
| sucesso | `{id, mensagem, sucesso}` |
| recusa | `{mensagem, sucesso}` |

A diferença é **uma chave**. Afirmar *"tem `sucesso`"* não distinguiria os dois —
e o snapshot que existe congela só a recusa.

Além das chaves, o caso afirma que o `id` é **inteiro de verdade** e não texto —
o snapshot de estrutura não distingue inteiro de decimal, mas distingue de
`string` —, que a linha existe, que o e-mail foi para minúsculo e que a unidade
gravada é a do corpo.

**O e-mail em minúsculo não é detalhe.** A consulta de duplicidade compara o
valor **cru** do pedido, e a entidade grava `strtolower()`: com maiúscula a
guarda nunca casaria, e um resíduo deixado para trás não bloquearia a execução
seguinte — ela criaria a **segunda** linha em silêncio.

**O contador de `AUTO_INCREMENT` não volta**, e é deliberado: rebobiná-lo faria o
próximo `INSERT` pedir um id que pode já ter sido entregue, trocando um resíduo
inofensivo por colisão de chave primária — o defeito que
`postgres/repontar-sequencias.sql` existe para impedir.

**Falsificado nas duas pontas**, com os códigos de saída: desligada a remoção, a
primeira execução sai com **1** e acusa *"sobrou 1 cadastro"*; a segunda sai com
**2**, barrada antes de medir — *"o e-mail ou o telefone já existem neste banco"*.

#### O quarto e o quinto: marcar, v2 e v3 — e elas não são a mesma rota

A v3 **não é um salto de versão**: é outro contrato dos dois lados.

| | entra | sai |
|---|---|---|
| v2 | form-urlencoded | `{id, mensagem, sucesso}` |
| v3 | **JSON cru**, tudo dentro de um nó `query` | `{id, sucesso}` — **sem `mensagem`** |

As duas chamam o **mesmo** `agendaSalvarV2` por dentro. Um aplicativo que lesse
`mensagem` da resposta da v3 leria nulo sempre — e é o tipo de diferença que só
aparece quando alguém congela a forma do **sucesso**, que é o que o D61 pedia.

São **três** leituras de corpo entre as rotas cobertas: a v2 lê `getPost()`, a v3
lê o conteúdo cru, e a indicação aceita as duas. Não dá para ter um `pedir()` só,
e mandar o formato errado faz o ensaio medir *"JSON inválido"* achando que mediu
o agendamento.

**A data é lida por posição de caractere**, não por separador:
`dataHoraBr_to_dateTime()` toma 0-1 como dia, 3-4 como mês e 6-9 como ano. ISO
(`2030-06-15`) vira dia 20, mês 30 — data inválida, e sem dizer por quê.

**E a v3 escreve fora do banco:** `logarJsonRecebido` grava
`data/logs/agenda_json/json_<Ymd_His>.log` com o corpo recebido, **antes** de
validar qualquer coisa. Nenhum `finally` de SQL o alcança, e a suíte de contrato
não teria como limpá-lo — ela fala só HTTP. O ensaio roda dentro do contêiner e
limpa por **diferença** da listagem tirada antes; apagar o diretório seria trocar
o resíduo por perda de rastro alheio.

#### E o resíduo que eu não estava contando

`autenticar()` grava `usuarios.token_web` e insere uma linha em
`usuarios_acessos`, e o `fixtures.sql` não limpa nenhuma das duas. Até aqui este
ensaio dizia *"resíduo ZERO"* contando só as tabelas dos casos: o número estava
certo para o que media, e a frase dava a entender mais do que media.

Agora conta as duas, e o rótulo diz o que abrange — **sete tabelas e o diretório
de log**. Os ensaios irmãos continuam deixando o deles: `ensaiar-recusas-adm-api`
e `ensaiar-fluxo-adm-api` também entram no ERP, e consertar aqui não conserta lá.

**Falsificado:** desligadas a remoção por faixa e a limpeza do log, a execução
acusa *"agendas: sobraram 2 linha(s)"* e *"agenda_json: sobraram 1 arquivo(s)"*,
e sai com 1.

#### Faltava um, e ele estava quebrado — consertado em 11/09/2026

O **SAC** não tinha como ser coberto: ele respondia **500** por um template
trocado, e nunca funcionou. Ver o **D172** — foi esta cobertura que o encontrou.

Em 11/09/2026 ele foi consertado, por decisão sua sobre o destinatário, e passou a
responder `HTTP 200` com `{"sucesso":true}` entregando o e-mail à franqueadora —
provado por execução, com a caixa do Mailpit medida antes e depois. O sexto caso
deste ensaio ficou possível, e o que falta para ele existir está escrito no D172.

#### Os seis, fechados em 11/09/2026

O sexto caso entrou, e com ele o ensaio passou a ter **seis de seis**. Resíduo
**ZERO** nas seis tabelas, no diretório de log e na caixa de e-mail.

**Ele não grava linha nenhuma, e isso mudou o que "resíduo" significa aqui.** Os
cinco primeiros escrevem em tabela; o SAC entrega uma **mensagem**. O banco dele é
a caixa de captura, lida por HTTP, e quatro funções novas —
`caixaDeEmail`, `idsNaCaixa`, `mensagemDaCaixa`, `apagarDaCaixa` — são o
equivalente do `$conexao` para ela. A restauração é por **id**, nunca apagando a
caixa: `DELETE /api/v1/messages` sem corpo apaga tudo, e tudo inclui o que outra
pessoa deixou ali para olhar.

**O caso afirma três coisas que `{sucesso:true}` não diz.** O endpoint devolve
`true` literal — a flag só significa "nenhuma exceção subiu". Então o caso abre a
caixa e exige: que exista **exatamente uma** mensagem nova; que ela tenha ido para
a franqueadora; e que o texto do cliente esteja dentro dela. O `Reply-To` e o
assunto também são conferidos.

**E ele manda `unidade_id` de propósito** — a unidade `90001200` do cenário, que
**tem** e-mail (`decima@teste.invalid`). Exigir que a mensagem chegue na
franqueadora *mesmo assim* é o que distingue "o caminho padrão funciona" de "a
sobrescrita morreu". **Falsificado:** com a sobrescrita plantada de volta, o caso
acusa *"6. sac: entregou para [decima@teste.invalid] — tinha de ser só a
franqueadora"* e sai com 1. Sem o `unidade_id`, ele passaria com o defeito de volta.

#### A guarda nova é a primeira deste projeto que protege um TERCEIRO

Todas as outras guardas destes ensaios protegem o **cenário**. Esta protege quem
ia receber.

O caso 6 manda um chamado de SAC de verdade, e o SAC manda e-mail. Se a seção
`mail` apontar para um servidor real em vez de um capturador, o ensaio entrega uma
mensagem de teste na caixa da franqueadora — e **e-mail entregue nenhum `finally`
desfaz**. Então, sem caixa de captura que responda, ele **recusa rodar**, com saída
2, antes de qualquer caso.

Ela também impede o avesso: caixa que não responde significa caso que não pode
conferir a entrega, e que passaria afirmando o que não mediu. **Falsificado:**
apontando o e-mail para um host inexistente, ele para com saída 2 e nenhuma
mensagem sai.

#### E AÍ VEIO O ACHADO QUE VALE MAIS QUE O SEXTO CASO: nada disso rodava no CI

Ao conferir se o sexto caso ia executar, descobri que **nenhum dos dois ensaios
que escrevem jamais rodou no CI** — e que o CI estava **vermelho** por isso.
Medido, não suposto: as cinco execuções mais recentes do workflow, todas
`failure`, e o log do run `34651184927` nomeando o passo.

O job `caracterizacao-postgres` roda contra **PostgreSQL**, e os dois ensaios
foram escritos em SQL do **MySQL**. Eram **quatro** construtos:

| construto | onde | o que o PostgreSQL diz |
|---|---|---|
| `GET_LOCK` / `RELEASE_LOCK` | os **dois** ensaios | `function get_lock(unknown, integer) does not exist` |
| `DATABASE()` e `REFERENCED_TABLE_NAME` | `limpar()` do ensaio de recusas | `function database() does not exist` |
| crase como citação de nome | o `DELETE` da mesma função | erro de sintaxe |
| `ON DUPLICATE KEY UPDATE` (×2) | os casos 14 e 21 das recusas | não existe; lá é `ON CONFLICT` |

E havia um quinto, este no ensaio do D61 e **mais caro que os outros**: a
restauração das agendas re-inseria a linha lida com `$conexao->insert()`, e no
PostgreSQL `SELECT *` devolve coluna booleana como `bool` do PHP. Sem dizer o
tipo, o DBAL liga `false` como **texto**:

```
ERROR: invalid input syntax for type boolean: ""
unnamed portal parameter $12 = ''
```

O `$12` era `agendas.checkin`. E o erro acontecia **dentro do `finally`**, depois
de o `DELETE` de faixa já ter corrido: a agenda do cenário ia embora e **não
voltava**. Eu vi isso acontecer — a primeira execução no PostgreSQL destruiu a
agenda de fixture do `franquiabv_pgcontrato`, e a execução seguinte recusou rodar
por resíduo. É a família do **D81/D82** no lugar mais caro possível: o código que
existe para não deixar resíduo era o que deixava.

**O que isso significa sobre os débitos:** o D61 dizia que a cobertura estava no
CI, e estava escrita no CI, e não executava. O D170 dizia o mesmo dos seus 23
casos. *Portão que não roda é pior que portão que não existe* — e **verde por
ausência de execução também não é verde**.

**Consertado, e medido nos dois bancos:**

| | MySQL | PostgreSQL |
|---|---|---|
| recusas da comanda (D170), 23 casos | 23 de 23 | **23 de 23** |
| escritas do aplicativo (D61), 6 casos | 6 de 6 | **6 de 6** |
| resíduo | ZERO | ZERO |

A trava saiu para `scripts/trava-de-banco.php`, que emite `GET_LOCK` no MySQL e
`pg_try_advisory_lock(CAST(? AS bigint))` no PostgreSQL — o `CAST` não é enfeite:
o PDO manda parâmetro como texto e o PostgreSQL não infere tipo de função
sobrecarregada. As duas execuções seguidas saem 0, que é o que prova que a
restauração restaurou.

**E um segundo vermelho, independente, no outro job.** `conferir-rotas-sem-login.php`
é o único portão do job `verificacao` que precisa de `vendor/autoload.php` — ele
mescla a configuração do Laminas para saber quais rotas existem. Aquele job nunca
instalou dependência nenhuma, porque é o dos portões textuais. Resultado medido:
`Failed opening required 'vendor/autoload.php'`, saída 255, **em toda execução
desde que o passo entrou** (D146). O portão passa localmente, onde o `vendor/`
existe. Entrou um `composer install` naquele job, antes do passo.

#### O CI ganhou um capturador de e-mail, e a alternativa era pior

O job `caracterizacao-postgres` — onde este ensaio roda — tinha **só** o serviço
`postgres`, e o `local.php` que ele gera mantém `'host' => 'mailpit'`, porque o
`sed` é deliberadamente endereçado ao bloco do Doctrine para **não** tocar na seção
de e-mail. No runner esse host não existe: o sexto caso ficaria vermelho sem haver
defeito nenhum.

Entrou o serviço `axllent/mailpit` no job, com as portas 1025 (SMTP) e 8025 (a API
que o ensaio lê), e um **segundo `sed`**, com endereço próprio no bloco `'mail'`,
trocando o host por `127.0.0.1` — serviço do Actions responde em `localhost`, e não
pelo nome, porque o job roda direto no runner e não em contêiner. Os dois `sed`
ficam separados de propósito: juntá-los desfaria a proteção que o comentário do
primeiro explica. Conferido por simulação local — as duas substituições pegam
exatamente duas linhas, e o resto do bloco fica intacto.

**A alternativa era pior:** deixar a seção de e-mail cair no `global.php`, que diz
`mail.barbearia.vip`, entregaria uma mensagem de teste na caixa da franqueadora a
cada execução do CI.

**Não entrou teste de contrato para o caminho de sucesso**, e é por desenho: o
sucesso e a recusa deste endpoint têm a **mesma forma** (`{sucesso}`), só o valor
muda, então um instantâneo de estrutura não distingue os dois — é exatamente por
isso que `sac-sem-dados.json` passava enquanto o endpoint estava inteiramente
quebrado. A afirmação que vale é o efeito, e o efeito só se vê na caixa. O ensaio é
o lugar dele.

#### Um fato que sobrou da conferência, e vale por si

O snapshot de hoje congela `{mensagem, sucesso}` — a forma do **erro**. O sucesso
real devolve **só `{sucesso}`**, sem `mensagem`. As duas formas são diferentes, e
o que está congelado é a que não interessa ao aplicativo. É o defeito do **D55**
vivo, num endpoint que a Fase 2 vai mexer.

#### A DECISÃO, tomada em 31/08/2026: adiar até o D66 fechar

**Por decisão sua, nada muda agora — e o motivo não é custo, é diagnóstico.** A
suíte de contrato já falha sozinha, sem causa conhecida (**D66**). Somar
dependência de estado do banco criaria um segundo modo de falha, e na próxima
falha haveria duas explicações possíveis e nenhuma forma barata de separá-las.

**A dependência fica escrita:** quando o D66 fechar, este débito volta à mesa com
as opções reais, que são duas e não três — cobrir os seis com o roteiro
recriando o banco, ou não cobrir nenhum. A terceira via não existe.

`/api/visagismo/dados-processar` também está sem o par, e fica fora desta
conta por decisão sua: visagismo é dívida futura declarada.

#### PARCIAL em 06/10/2026 -- os ensaios irmãos devolvem o que o login grava

O parágrafo "E o resíduo que eu não estava contando" deixou escrito que `ensaiar-recusas-adm-api.php` e
`ensaiar-fluxo-adm-api.php` também entram no ERP e não devolvem o deles. Os dois passaram a devolver, no
molde do ensaio das escritas: leem, **antes do primeiro login**, o `token_web` de cada usuário da
fixture (`id >= 90001000`) e o maior id de `usuarios_acessos`, e no `finally` regravam cada token pelo
valor lido e apagam os acessos acima daquele id. Depois do `finally`, e antes da saída, conferem: token
diferente ou acesso a mais sai com 1, nomeando o usuário.

- **O de recusas** já lançava em vez de `exit` (o D171) e já tinha o `finally`; a devolução entrou como o
  primeiro passo dele -- uma falha num dos passos de baixo não a deixa para trás.
- **O de fluxo** não abria o banco: era só HTTP, e cada passo que falhava saía por `exit(1)`, que em PHP
  não roda `finally`. Passou a abrir a conexão pelo `Application::init` (e a recusar rodar com mais de 10
  unidades, como os irmãos: a devolução apaga acessos), e a parada virou a exceção `FluxoParou`, com o
  código de saída -- o passo que falha, o login recusado, o dia com um horário só. O corpo do fluxo foi
  para dentro do `try` sem reindentar.

**Medido, com o banco de contrato montado do zero:**

| banco | ensaio | saída | resíduo dos logins |
|---|---|---:|---|
| PostgreSQL, na ordem do CI | recusas, 201 de 201 | 0 | ZERO |
| PostgreSQL, na ordem do CI | fluxo | 0 | ZERO |
| MySQL | recusas, 201 de 201 | 0 | ZERO |
| MySQL | fluxo, duas vezes seguidas | 0 e 0 | ZERO |
| MySQL | fluxo com a senha errada | 2 | ZERO |
| MySQL | fluxo contra um servidor que não responde | 2 | ZERO |
| MySQL | fluxo parado no passo 5, depois do login (o serviço desativado no banco) | 1 | ZERO |
| MySQL | recusas com a senha errada | 2 | ZERO |
| MySQL | recusas contra um servidor que não responde | 2 | ZERO |
| MySQL | recusas, rodada parcial (`ENSAIO_SO=1`) | 3 | ZERO |

E por fora dos dois ensaios, lido no banco: depois das oito primeiras execuções no MySQL, `usuarios_acessos`
tinha as mesmas zero linhas e os tokens da fixture o mesmo resumo (o sha1 da lista de sha1 por usuário --
nenhum token impresso) que antes da primeira.

**Os mutantes, um de cada vez, no arquivo, restaurados pelo sha1:**

| mutante | saída | o que acusou |
|---|---:|---|
| recusas sem a devolução no `finally` | 1 | o token do 90001000 e do 90001001 trocados, 2 acessos sobrando |
| fluxo sem a devolução no `finally` | 1 | o token do 90001001 trocado, 1 acesso sobrando |
| fluxo com o `exit(1)` de volta no passo, parado no passo 5 | 1 | nada: o `finally` e a conferência não rodam -- e o banco, lido por fora, ficou com 1 acesso a mais e o token trocado |

O terceiro é o que o aviso do lote pedia para provar: com `exit`, o ensaio interrompido deixa o resíduo
calado.

#### A revisão de 06/10/2026: a rodada depois de uma morta, a interrupção depois dos logins, e os irmãos que faltam

A revisão da seção de cima achou três coisas, e mediu as duas primeiras contra o banco de contrato do MySQL:

**1. A rodada que limpa a sobra de uma rodada morta saía com 1.** A foto dos tokens (`id >= 90001000`)
incluía o administrador do ensaio (90007501) quando uma rodada morta o deixava no banco; o bloco "A SOBRA
DE UMA RODADA MORTA" o apagava, e a conferência lia `false` na linha que não existe mais e dizia "o login
trocou o token e ele não voltou", sobre o banco limpo. **Conserto:** o administrador do ensaio fica fora da
foto -- ele não é da fixture, o ensaio o cria e o apaga, e os acessos dele saem pelo `DELETE ... WHERE
usuario` e pelo id --; e a conferência, quando a linha da foto some, diz que ela sumiu, em vez de culpar o
token.

**2. A devolução depois de um login nunca tinha sido exercida por uma interrupção.** As linhas "senha
errada" e "servidor que não responde" da tabela de cima param antes do primeiro login -- o login que falha
não grava nada, e o ZERO delas é de quem não tinha o que devolver --, e a rodada parcial (`ENSAIO_SO=1`)
termina pelo caminho normal, sem passar pelo `catch`. **Medido agora**, cada linha sozinha, com cópias do
ensaio em `/tmp/ens` no contêiner (o arquivo do repositório não foi tocado) e o retrato agregado do banco
lido por fora entre uma e outra (o sha1 da lista de tokens dos cinco usuários da fixture, nunca um token):

| rodada | saída | o que disse | o banco depois |
|---|---:|---|---|
| exceção lançada logo depois dos três logins | **2** | "resíduo dos logins: ZERO" | tokens e acessos iguais aos de antes |
| morta por SIGKILL logo depois dos três logins | 137 | -- | o administrador ficou, 3 acessos, os tokens trocados |
| a 1ª versão do D61, parcial, sem remontar | **1** | "token do usuário 90007501 ... não voltou" | limpo: o vermelho é falso |
| outra morta, e a revisão SEM tirar o administrador da foto | **1** | "usuarios 90007501: a linha estava na foto e sumiu" | limpo |
| outra morta, e a revisão, parcial | **3** | "resíduo dos logins: ZERO" | os tokens que encontrou, o administrador e o acesso dele apagados |

E a revisão inteira, com o banco montado do zero: **MySQL 201 de 201, saída 0**, resíduo ZERO e o retrato
do banco igual antes e depois; **PostgreSQL (o `franquiabv_pgcontrato` montado do zero) 201 de 201, saída
0**, resíduo ZERO.

A linha da morta mostra o limite que fica: a rodada morta por SIGKILL não roda `finally` nenhum, e o que
ela gravou -- os tokens trocados e os acessos dos logins dela -- fica no banco; a rodada seguinte devolve só o
dela, porque não sabe o "antes" da outra. Remontar o banco continua sendo o que zera.

**3. Os irmãos que também entram no ERP**, levantados pelo `grep` de `autenticacao/entrar` em `scripts/`,
e não de memória. Entram com usuário da fixture e não mencionam `token_web` nem `usuarios_acessos`:
`ensaiar-busca-de-cliente-da-agenda`, `ensaiar-fidelidade-adm-api`, `ensaiar-usuarios-adm-api`,
`ensaiar-permissao-adm-api`, `ensaiar-sessao-do-erp` (o cabeçalho dele diz que "os logins escrevem o que o
login sempre escreve"), `ensaiar-links-consertados-do-erp` (fora do CI), `ensaiar-fechamento-da-agenda`
(que não se roda aqui), `medir-adm-api-no-postgres`, `medir-relatorios-no-postgres` e
`medir-telas-no-postgres`; e o `ensaiar-cupons-adm-api` apaga só os acessos do próprio administrador. Na
ordem do CI vários deles rodam antes e depois das recusas e do fluxo, e o banco de contrato continua
juntando acessos e trocando o token do gerente e do colaborador. Eles ficaram fora do item, que pedia os
dois.

**O que fica deste débito:**
- os dez roteiros acima, mais a sobra do de cupons, que entram no ERP e não devolvem o que o login grava --
  o molde é o do ensaio das escritas e o destes dois (a foto antes do primeiro login, a devolução no
  `finally`, a conferência depois, e a parada por exceção, nunca por `exit`);
- a variante `'+' . $ddi . ' ' . $telefone` da checagem de duplicata do `AplicativoService` (o "Duas coisas
  que o vermelho da primeira execução ensinou", item 2) -- um pedido por chamada que não pode achar nada.
  Fora deste item por decisão do levantamento.

#### PARCIAL em 08/11/2026 -- os 22 roteiros que entram no ERP devolvem o que o login grava (a fatia 127)

A fatia 127 da fila dos débitos de pé (ARNES-DOS-TESTES-E-ENSAIOS) levou o molde a todos os roteiros que entram no ERP,
derivados pelo `grep` de `autenticacao/entrar` em `scripts/` (30 arquivos; quatro só citam a rota num comentário -- o
`conferir-mensagem-sem-redirecionar`, o `conferir-registro-de-acesso`, o `conferir-rotas-sem-login` e o
`ensaiar-rotas-publicas-do-erp` --, três já devolviam -- o das escritas, o das recusas e o do fluxo -- e o
`ensaiar-fechamento-da-agenda` não foi tocado: é proibido rodá-lo aqui, e o conserto sem prova não entra).

**O ajudante comum: `scripts/residuo-do-login.php`**, no molde do `trava-de-banco.php` (o `require` devolve as funções).
A foto é o `token_web` de cada usuário com `id >= 90001000`, pelo valor, menos os que o próprio ensaio cria e apaga (o
administrador, o leitor), e o maior id de `usuarios_acessos`; a devolução regrava cada token e apaga os acessos acima do
maior; a conferência diz, nomeando o usuário, o token trocado, a linha que sumiu e os acessos que sobraram, e o ensaio sai
com 1. Sem foto (a recusa antes da guarda), nada: a devolução apaga acessos, e a foto vem sempre depois da guarda das 10
unidades.

**Dois jeitos, conforme o roteiro:**
- **no `finally`, com a parada por exceção** -- os 17 que já tinham o `try`/`finally` em volta dos logins e paravam por
  exceção: arquivos, banners, cupons, cursos, etiquetas, fidelidade, mensagens, pesquisas, saídas, sorteios, usuários,
  VIP Class, WhatsApp, o provedor de mentira do WhatsApp, notificações, a busca de cliente da agenda e os links
  consertados. A devolução é o primeiro passo do `finally` (nos dois que desfazem passo a passo -- notificações e o
  provedor de mentira --, o primeiro que precisa do banco, no `$desfazer`); nos links consertados é o ÚLTIMO, porque o
  "Sair" do `finally` apaga o `token_web` de quem sai. A conferência vem logo depois do `finally`, antes das outras saídas.
- **no fim do encerramento do PHP** -- os cinco cujo corpo não cabe num `try`: o `ensaiar-permissao-adm-api` (a primeira
  parte sai por `exit` e a terceira declara `const`, que o PHP não aceita dentro de bloco), o `ensaiar-sessao-do-erp` (os
  ajudantes saem por `exit`, e as devoluções dos casos 19 e 22 a 24 já moravam no encerramento) e os três medidores do
  PostgreSQL (`medir-adm-api`, `medir-relatorios`, `medir-telas`). A função registrada registra, quando o encerramento
  começa, a que devolve e confere -- vai para o fim da fila, depois das devoluções do roteiro, e roda também depois de
  `exit`, de exceção não apanhada e de erro fatal; com resíduo, a saída vira 1.

**O que mudou junto:** o `ensaiar-permissao-adm-api` e o `ensaiar-sessao-do-erp` abriam o banco só na parte que escreve,
depois dos primeiros logins; a conexão e a guarda das 10 unidades subiram para antes do primeiro login, e os dois recusam
rodar inteiros fora do banco de fixtures (antes, as primeiras partes entravam em qualquer banco). O
`medir-relatorios-no-postgres` e o `medir-telas-no-postgres` não abriam o banco: passam a abrir pela raiz do repositório,
com a mesma guarda. Nenhum caso de nenhum ensaio mudou.

**Medido, com o banco de contrato montado do zero, e com um retrato de fora** (o sha1, no PostgreSQL o md5, da lista
`id:token` dos usuários da fixture -- nenhum token impresso -- e a contagem de `usuarios_acessos`):

| banco | rodada | saídas | retrato antes | retrato depois |
|---|---|---|---|---|
| MySQL | 24 ensaios, na ordem do CI: os 23 do CI (os 18 tocados, as recusas, as escritas, o fluxo, as rotas públicas, o erro JSON) e o dos links consertados, que é tocado e fica fora do CI | 24 de 24 com 0 | tokens b91f4477…, 0 acessos | tokens b91f4477…, 0 acessos |
| PostgreSQL | os mesmos 24 e o `medir-adm-api` | 25 de 25 com 0 | tokens cb06fbdf…, 0 acessos | tokens cb06fbdf…, 0 acessos |
| PostgreSQL | o `medir-telas` (187 telas) e o `medir-relatorios` (17 relatórios), com a concessão do CI e o cache | 2 de 2 com 0 | tokens cb06fbdf…, 0 acessos | tokens cb06fbdf…, 0 acessos |

Os 22 roteiros tocados disseram "resíduo do login: ZERO" (os 19 do MySQL e do PostgreSQL, o `medir-adm-api` e os dois
medidores). A saída do `medir-relatorios` é a mesma linha a linha da rodada do `p48-medir-relatorios-pg.sh` de 08/11/2026,
mais a linha do resíduo.

**Os mutantes, um por roteiro**, cada um uma cópia do roteiro em `scripts/` (o arquivo do repositório não foi tocado),
sem a devolução e com a conferência, apagadas no fim:

| roteiro sem a devolução | banco | saída | o que a conferência acusou |
|---|---|---:|---|
| arquivos | MySQL | 1 | token do 90001001, 3 acessos dele |
| banners, cupons, cursos, etiquetas, fidelidade, mensagens, pesquisas, saídas, sorteios, VIP Class (dez) | MySQL | 1 cada | tokens do 90001000 e do 90001001, 1 acesso de cada |
| busca de cliente da agenda | MySQL | 1 | token do 90001000, 4 acessos |
| links consertados | MySQL | 1 | token do 90001001, 2 acessos |
| notificações | MySQL | 1 | token do 90001001, 5 acessos |
| permissão (no encerramento) | MySQL | 1 | token do 90001000, 5 acessos |
| sessão do ERP (no encerramento) | MySQL | 1 | token do 90001001, 30 acessos |
| usuários | MySQL | 1 | tokens do 90001000 (a desativação, não o login) e do 90001001, 1 acesso |
| WhatsApp | MySQL | 1 | tokens do 90001000 e do 90001001, 1 e 3 acessos |
| provedor de mentira do WhatsApp | MySQL | 1 | token do 90001001, 1 acesso |
| `medir-adm-api` (no encerramento) | PostgreSQL | 1 | token do 90001001, 2 acessos |
| `medir-telas` (no encerramento) | PostgreSQL | 1 | token do 90001001, 1 acesso |
| `medir-relatorios` (no encerramento) | PostgreSQL | 1 | token do 90001001, 1 acesso |
| o ajudante sem o `DELETE` dos acessos (H1, pelo de cupons) | PostgreSQL | 1 | 1 acesso do 90001000 e 1 do 90001001 |
| o ajudante sem a volta dos tokens (H2, pelo de cupons) | PostgreSQL | 1 | tokens do 90001000 e do 90001001 |

Nenhum mutante errou caso do próprio ensaio: a saída 1 é só a do resíduo. Depois dos 19 mutantes do MySQL o retrato de fora
mostrava 75 acessos e os tokens trocados -- o que a rodada inteira deixava no banco de contrato até aqui.

**O que fica deste débito:**
- o `ensaiar-fechamento-da-agenda`, que entra no ERP e não devolve o login -- não foi tocado: rodá-lo aqui é proibido, e o
  conserto sem prova não entra; quem puder rodá-lo leva o molde (`residuo-do-login.php`, no `finally` dele);
- a variante `'+' . $ddi . ' ' . $telefone` do `AplicativoService`, fora por decisão do levantamento, como antes;
- a rodada morta por SIGKILL, que não roda `finally` nem encerramento: o limite de 06/10/2026 continua, e remontar o banco
  continua sendo o que zera.

**A verificação de 08/11/2026 (três lentes)** confirmou cinco achados baixos e um sexto foi refutado:
- **A `ParadaDoEnsaio` era peça morta** (dois achados iguais): o ajudante declarava a classe e o cabeçalho mandava lançá-la, e
  nenhum roteiro a usava -- os 17 do `finally` param pelas exceções que já tinham. A classe saiu, e o cabeçalho diz que a parada
  é a exceção do próprio roteiro, nunca `exit`.
- **As guardas das 10 unidades não tinham mutante nem portão**, e nem a exclusão dos usuários do ensaio. Agora: (1) a própria
  foto confere de novo e LANÇA `DomainException` com mais de 10 unidades, antes de ler qualquer outra coisa -- o roteiro que
  perder a guarda, ou tirar a foto antes dela, morre antes do primeiro login; (2) o `D61ResiduoDoLoginTest` (6 casos, nos dois
  bancos) prova o ajudante contra o banco da suíte (o token trocado e o acesso a mais acusados nomeando o usuário, a devolução
  que zera, o usuário do ensaio fora da foto, a linha que some acusada, a devolução sem foto que não toca o banco) e, com uma
  conexão dublê, a recusa da foto com 11 unidades e a foto com 10; (3) o mesmo teste lê do disco, sem comentários, todo roteiro
  que cita `autenticacao/entrar` no código (27; cinco fora, nomeados com o motivo: o fechamento da agenda, os três do molde de
  06/10 e o portão das mensagens) e exige, nos 22, a guarda que sai por `exit(2)` ou lança ANTES da foto, e a devolução no
  `finally` com a conferência depois e nenhum `exit` entre a foto e a devolução -- ou a devolução no encerramento com a foto
  como argumento. E a recusa rodou de verdade: os cinco roteiros com guarda nova ou subida (a permissão, a sessão do ERP e os
  três medidores) contra o `franquiabv_dev` (64 unidades; só a contagem), saída 2 em todos, e a contagem e o maior id de
  `usuarios_acessos` e o sha1 dos tokens da faixa iguais antes e depois.
- **Os cinco roteiros sem `try` param por `exit`**, contra a letra "nunca por `exit`": é o desvio já declarado acima (o
  encerramento roda depois de `exit`, e os mutantes deles saíram 1). Não mudou; o coordenador decide se aceita.
- **Refutado: "o `medir-telas` apaga um `$cookies` que nunca existiu".** Existe: `$cookies = tempnam(...)` na linha 61, antes da
  guarda, e o `@unlink` dela é a mesma limpeza das outras saídas. Medido: com o `@unlink` arrancado (U1), a recusa deixou um
  `/tmp/telas-*` no contêiner; com ele, nenhum.

**Os mutantes da verificação**, um por vez, devolvidos pelo sha1: N1 (a foto sem a exclusão do ensaio), N2 (sem a recusa da
foto), N3 (`>=` no lugar de `>`) e N4 (a devolução sem a guarda do nulo -- ela apagou todos os acessos) vermelhos nos dois
bancos; R1 (a permissão sem a guarda), R2 (a foto do `medir-telas` antes da guarda), R3 (um `exit` no `try` dos banners) e R4
(os cupons sem a devolução) vermelhos no portão do teste, nomeando o roteiro. Depois, os 19 ensaios do ajudante no MySQL (o banco
de contrato montado do zero; o retrato de fora igual antes e depois, zero acessos) e os 19 mais o `medir-adm-api` no PostgreSQL
(zero acessos no fim), saída 0 e "resíduo do login: ZERO" em todos. O `medir-telas` e o `medir-relatorios` não rodaram de novo:
o caminho deles (o quarto elemento) é o da permissão, da sessão do ERP e do `medir-adm-api`, que rodaram.

**Nota sobre os quatro `null` que restam nos snapshots**, para quem varrer de
novo e estranhar: são legítimos. `mensagem` só é preenchida em erro, e o caminho
de sucesso a devolve nula; `programa_fidelidade_url` é literalmente `null` no
código; e `convenio` é campo genuinamente anulável, que ficaria arbitrário em
qualquer um dos dois estados. **O snapshot registra tipo, e tipo não sabe dizer
"texto ou nulo"** — é um limite da ferramenta, não um defeito do cenário.

---

### D62 — `TIMESTAMPDIFF` estava quebrado, e com ele o filtro de idade — CORRIGIDO

Encontrado em 30/08/2026 pelo teste escrito para o **D25**, não por leitura.

`ISBase\Doctrine\TimestampDiff::parse()` lia o token do lexer como array:

```php
$this->unit = $lexer->token['value'];
```

Em `doctrine/lexer` 2.x o token é um **objeto** `Doctrine\Common\Lexer\Token`,
com a propriedade `value`. O acesso por índice dá erro fatal — *"Cannot use
object of type Doctrine\Common\Lexer\Token as array"* — **ao parsear a
consulta**, antes de chegar ao banco.

**A consequência é uma tela que quebra hoje:** o único uso de `TIMESTAMPDIFF` no
sistema é o filtro de faixa de idade do relatório de clientes
(`ClienteRepository`, por volta da linha 658). Preencher os dois campos de idade
e filtrar produz erro fatal.

**Por que ninguém tropeçou nisso antes**, e é o mais interessante deste débito: a
função só é registrada **dentro do `if` que monta esse filtro**. Quem não
preenchesse os dois campos nunca chegava ao parse, e nunca via o erro. O defeito
estava protegido pela própria raridade do caminho.

E foi assim que ele apareceu: ao centralizar as funções na configuração (D25),
escrevi um teste que exercita **todas** elas em DQL real. Sete funções passaram;
`TIMESTAMPDIFF` estourou. Nenhum teste anterior a tocava, porque nenhum teste
cobre aquele filtro.

**A correção é uma linha** — `$lexer->token->value`. Fica a observação que ela
sugere: o mesmo idioma antigo pode existir em outras classes de função. As demais
sete foram exercitadas e passam, então o problema estava só aqui.

---

### D45 — O inquilino é fixo na configuração, não vem de quem está autenticado

Descoberto em 27/08/2026, ao apontar a aplicação para o PostgreSQL (F1.8).

O schema PostgreSQL tem Row-Level Security com `FORCE` em 75 tabelas, e a
política compara `inquilino_id` com `current_setting('app.inquilino_id')`.
`ISBase\Doctrine\DeclararInquilino` declara esse valor depois de cada conexão,
lendo-o de `module/ISBase/config/module.config.php` — onde ele está **fixo em
`1`**, a Barbearia VIP.

**Isso está certo hoje.** A instalação tem um inquilino só, e um valor de
implantação numa configuração de implantação é o lugar dele.

**Deixa de estar certo no dia em que houver o segundo.** Aí o inquilino passa a
ser propriedade de *quem está autenticado*, e a troca passa a ser **por
requisição**, não por conexão — o que colide com o reaproveitamento de conexões
do pool. Não é uma linha de configuração: é decidir onde a identidade do
inquilino vive e como ela sobrevive ao pool.

**O risco de esquecer é assimétrico.** Um inquilino declarado a menos devolve
**zero linhas, sem erro** — uma tela em branco que parece falta de dado. Um
inquilino declarado a mais vaza dado de um cliente para outro. O primeiro
sintoma se investiga; o segundo não se percebe.

**Ao resolver:** ler o inquilino da identidade autenticada e emiti-lo por
requisição, com um teste que prove que duas sessões simultâneas de inquilinos
diferentes não se enxergam.

---

### D46 — Ordenações por coluna anulável: o defeito saiu, e o vigia passava no caso exato que ele existia para pegar — CORRIGIDO em 14/09/2026

Descoberto em 27/08/2026, medindo a F1.8.

MySQL e PostgreSQL discordam sobre onde o nulo entra numa ordenação — o MySQL o
trata como o **menor** valor, o PostgreSQL como o **maior**. Não há erro: a
consulta roda nos dois e devolve as mesmas linhas **em ordem diferente**.

A varredura encontrou **28 ordenações por coluna anulável, em 17 colunas**, de
157 métodos com `ORDER BY`. **Sete foram corrigidas** com
`ISBase\Doctrine\Ordenacao::comoMysql()` — as que têm nulos em quantidade
(`clientes.nps` com 228.924 de 242.587) ou que um teste expôs.

**As 21 restantes ordenam por coluna que hoje não tem nulo nenhum.** Por isso
não foram tocadas: a correção acrescenta trabalho ao banco sem mudar resultado.

**O que faz disto um débito e não um não-problema:** a coluna *aceita* nulo. No
dia em que a primeira linha nula aparecer — um cadastro incompleto, uma
importação, um campo que virou opcional — a listagem muda de ordem sozinha, e
nada avisa. O sintoma chega como "o relatório está errado".

**A varredura virou roteiro, em 29/08/2026:**
`scripts/conferir-ordenacao-nulos.php`. Lê as ordenações em DQL, cruza com as
colunas anuláveis do banco, conta os nulos de cada uma, e **sai com 1 se alguma
coluna sem correção tiver nulo** — pode virar portão. Enquanto a contagem
dependia de alguém lembrar, dependia de alguém lembrar.

**Rodá-lo pela primeira vez achou cinco colunas expostas**, e a premissa deste
débito não se sustentou: nem todas as restantes tinham zero nulo.

| Coluna | Nulos | Usos |
|---|---:|---:|
| `usuarios.data_contratacao` | **959** de 2.430 | 1 |
| `unidades.data_inauguracao` | 14 de 62 | 1 |
| `arquivos.subcategoria` | 7 de 109 | 2 |
| `dashboard_colaboradores.total_vendas` | 7 de 18.257 | 2 |
| `usuarios.data_nascimento` | 3 de 2.430 | 1 |

As sete foram corrigidas, e a varredura passou a sair limpa.

**Duas coisas que a primeira versão do roteiro errou**, e valem mais que o
conserto:

**Falso positivo.** Ele procurava só a palavra `comoMysql` para saber se uma
ordenação estava corrigida, e acusou seis ordenações por `unidades.estado` que
**já estavam certas** — escritas com o `CASE WHEN ... IS NULL` inline, à mão,
antes de o auxiliar existir. Eu quase "consertei" código correto, e só não o fiz
porque abri os arquivos antes de aplicar o script. Agora ele reconhece as duas
formas.

**Correção errada.** Três das cinco ordenam por `DAY(coluna)`, não pela coluna:
o auxiliar precisa envolver a expressão inteira, e aplicá-lo ao campo teria
produzido DQL que não corresponde à ordenação real. O relatório passou a mostrar
o termo cru justamente para que quem o lê veja o `DAY(...)`.

Nos três casos o filtro por mês é **condicional** — sem mês, as linhas nulas
entram, e é aí que a divergência aparece.

---

#### O vigia ficava VERDE sobre o defeito dele — fechado em 14/09/2026

O defeito original está pago: as cinco ordenações expostas carregam
`Ordenacao::comoMysql()` hoje, e o portão confirma por execução. O que sobrava
era ele, e ele não aguentou a primeira falsificação séria.

**A dispensa era por CLÁUSULA, e o motivo escrito é por COLUNA.** Bastava a
marca `@ordenacao-nulos-conferida` aparecer na janela de 900 caracteres e
**todos** os termos daquele `ORDER BY` viravam "corrigida". A única dispensa da
casa fala de `uni.estado` — *"1 nulo em 63"* — e calava as vizinhas `uni.pais`,
`uni.cidade`, `uni.bairro` e qualquer coluna acrescentada depois.

Medido: acrescentar `uni.dataInauguracao DESC` — **15 nulos de 64**, sem
correção — àquela cláusula deixava o portão **verde**, imprimindo *"0 sem
correção"* três linhas abaixo da própria medida. A mesma mudança **sem** a
linha da dispensa dava vermelho. A dispensa era a causa única.

Hoje a marca exige alvo: `@ordenacao-nulos-conferida(uni.estado) motivo…`.
Dispensa sem alvo não dispensa nada **e é acusada** — calar sobre ela
transformaria a sintaxe antiga em "nenhuma dispensa", e o portão ficaria
vermelho sem dizer por quê. E dispensa cujo alvo não está na cláusula também é
acusada: marca que descola do que ela dispensa é lista que não encolhe.

#### O alias de JOIN não resolvia, e o descarte era mudo

Havia um comentário dizendo que *"a resolução real vem do Doctrine mais
abaixo"*. Abaixo havia um `continue;`. **A resolução prometida nunca foi
escrita** — comentário que promete o que o código não faz custa o mesmo que nome
que mente.

Eram **44 ocorrências descartadas em silêncio**: `uni.cidade`, `cli.nome`,
`pro.tipo`, `pag.nome` e outras, num portão que se anunciava varrendo o sistema.
Duas das 44 nem eram consulta — eram `ORDER BY` recortado de comentário, e saem
pelo conserto descrito abaixo; as outras **42 eram DQL de verdade** que ninguém
estava olhando. Resolvi as 44 à mão antes de consertar: **nenhuma esconde
defeito vivo hoje** — mas a cegueira era permanente.

O conserto tem três partes: o **dono do JOIN** entra na captura (`JOIN
uni.cidade c` só diz que `c` é a propriedade `cidade` de alguém — sem o dono não
há entidade), as **associações** das entidades passam a ser lidas das anotações
`targetEntity`, e a corrente é seguida até um `FROM`, com limite de
profundidade. Depois disso: **0 não resolvidas**, e o censo subiu de 9 colunas
em 17 usos para **10 em 20** — `unidades.nome` estava invisível desde sempre.

#### E ele lia `ORDER BY` dentro de comentário

Duas "ocorrências" tinham como termo `dito com numero: …` e `ate cinco`,
recortados da documentação dos consertos anteriores — a prosa desta casa cita
cláusulas de `ORDER BY` para contar história. As faixas de comentário agora são
calculadas por token e puladas.

#### A falsificação, com o controle que quase me enganou

| O que foi quebrado | Saída |
|---|---|
| `uni.dataInauguracao DESC` na cláusula dispensada | 1 — `>>> unidades.data_inauguracao 15 nulos de 64` |
| a marca volta a não ter alvo | 1 — DISPENSA SEM ALVO |
| o alvo aponta para coluna fora da cláusula | 1 — DISPENSA QUE NÃO DISPENSA NADA |
| `uni.pais` vira `zzz.pais` | 1 — NÃO CONSEGUI RESOLVER O ALIAS |
| **controle:** `cli.nps` volta a ser cru | 1 — `>>> clientes.nps 228.934 nulos de 242.597` |
| `ORDER BY` anulável escrito como **comentário** | **0** — prosa não é consulta |

**O controle quase virou um achado falso.** A primeira tentativa trocou só o
nome da função PHP e deu **verde** — eu escrevi que a culpa era da janela de
±120 caracteres alcançar um `comoMysql` vizinho. Estava errado: com
`" ORDER BY " . comoMysql(…)` o trecho capturado termina na aspa e **não sobra
termo nenhum** para examinar, então não havia ocorrência. Reconstruído o defeito
dentro da string, o portão acusou. A receita das seis está escrita no cabeçalho
do roteiro, para a próxima pessoa não repetir o engano.

#### O que continua valendo, e precisa estar escrito

**Ele não roda no CI, e isso não se resolve escrevendo que roda.** Contar nulos
exige a cópia de produção, e o CI só tem o schema — é o mesmo motivo dos irmãos
`conferir-relacoes-doctrine` e `conferir-booleano-em-coluna-inteira`. O único
chamador é o `conferir-tudo.sh`. Dizer "agora há vigia" sem essa linha seria
repetir a frase que o próprio D46 usou contra si.

**E a dispensa continua sendo uma dispensa.** `unidades.estado` tem 1 nulo em
64 e segue ordenando sem correção, porque a listagem é paginada e o paginador
sem output walkers exige que toda expressão do `ORDER BY` esteja no `SELECT` —
um `CASE` nunca está (D97). Onde há nulo, a ordem **difere** entre os bancos, e
isso já foi medido na comparação de telas (D109).


### D47 — 36 listagens paginadas sem desempate (FECHADO em 30/08/2026) — CORRIGIDO em 06/10/2026

Descoberto em 27/08/2026, ao provar a correção do D46.

Corrigida a colocação dos nulos, os dois bancos passam a devolver o **mesmo
conjunto** de linhas no topo — mas não necessariamente na mesma ordem entre si:

```
ORDER BY nps DESC (com a correção)
  MySQL:       36, 58, 59
  PostgreSQL:  58, 36, 59
```

Os três têm NPS 10. Nenhum `ORDER BY` do sistema tem critério de desempate, e
sem ele **nenhum dos dois bancos promete ordem estável** — nem entre bancos, nem
entre duas execuções da mesma consulta no mesmo banco.

**Isso é anterior à migração e vale igual no MySQL de hoje.** O que o
PostgreSQL faz é tornar visível o que já era verdade.

**Onde machuca é na paginação.** Com `LIMIT/OFFSET` sobre uma ordem instável,
uma linha pode aparecer em duas páginas ou em nenhuma. Numa listagem de 242.587
clientes ordenada por NPS — onde 228.924 empatam em nulo e o resto se concentra
em poucos valores inteiros — o empate é a regra, não a exceção.

**Medido em 29/08/2026, e o número é 36.** A varredura virou
`scripts/conferir-ordenacao-empates.php`, que sai com 1 enquanto houver alguma.

**A correção parece uniforme e não é** — é o principal achado da medição, e o
motivo de o roteiro classificar caso a caso em vez de mandar corrigir tudo:

| | |
|---|---:|
| Listagens paginadas sem desempate | **36** |
| Seguras de corrigir mecanicamente | **33** |
| A conferir uma a uma | **3** |

**Por que 12 exigem conferência:** `ISBase\Paginator\AdapterQuery` chama
`setUseOutputWalkers($selectFields)`, e `$selectFields` é `false` por padrão.
Nesse modo o Doctrine pagina por subconsulta e **exige que o campo ordenado
esteja no SELECT** — acrescentar `raiz.id` a uma consulta que não o seleciona
pode devolver resultado errado, sem erro. E onde há `GROUP BY`, a coluna do
desempate precisa estar no agrupamento.

**As 33 foram aplicadas em 29/08/2026**, por decisão sua, e o roteiro ganhou
`--aplicar` — a ferramenta que identifica passou a ser a que corrige, e pode ser
reexecutada.

Restam as 3 a conferir, e elas são legítimas: uma monta a DQL num método
auxiliar (o `FROM` não está no texto), e as outras duas têm raiz cuja chave não
está no `SELECT`.

**Duas foram recusadas pela própria ferramenta**, e a recusa vale registrar:
*"a ordenação aparece 2 vezes no método; não sei qual"*. Eram listagens com
**um ramo de ordenação por coluna clicável da tela** — `ClienteRepository::listagemIndexRelatorio`
tem nove, `ConvenioRepository::listagemIndex` tem seis. Corrigir só o último
ramo deixaria a listagem determinística ao ordenar por nome e indeterminística
ao ordenar por e-mail.

Os quinze ramos foram corrigidos à mão. **E isso expôs que ramos escritos com
`comoMysql()` são invisíveis ao roteiro** — o `ORDER BY` deixa de ser seguido do
nome da coluna. Medido: hoje são zero, porque os únicos estavam nesses dois
métodos. Mas o ponto cego existe, e um ramo novo escrito assim não seria
acusado.

**Três medições erradas antes da certa**, e cada uma errou de um jeito diferente:

1. A primeira contou **comentários** como `ORDER BY` — inclusive comentários
   meus explicando por que um `ORDER BY` tinha sido removido.
2. A segunda procurou `setFirstResult` e achou **zero**, o que parecia dizer
   "não há paginação". `setFirstResult` **não existe no projeto**: quem aplica o
   offset é o paginador do Doctrine, por dentro. Zero não era ausência de
   problema, era sinal errado.
3. A terceira, pelo `getPaginator()`, achou os 36.

O passo 2 é o mais instrutivo: **uma medição que devolve zero parece uma boa
notícia**, e é quando ela merece mais desconfiança, não menos.


#### As 3 que restavam, fechadas em 30/08/2026

E a lição está em **como** elas restavam. O classificador marcou as três com o
mesmo `CONFERIR`, e pelo mesmo motivo trivial: não conseguia enxergar o `FROM`
— numa ele estava num método auxiliar, noutra havia cinco `SELECT` alternativos
no mesmo método. **`CONFERIR` era um estado do classificador, não uma medida de
gravidade** — e o pior defeito dos 36 estava exatamente nessa pilha de três.

**A pior era a de cupons gerados, e não era falta de desempate: era `ORDER BY`
por constante.**

```
FROM CupomCliente cupcli JOIN cupcli.cupom cup
WHERE cup.id = :cupom
ORDER BY cup.validade, cup.dataCriacao      <-- cup é UM só
```

O `WHERE` fixa `cup` numa única linha, então **todas as linhas da consulta
compartilhavam os mesmos dois valores de ordenação**. A cláusula existia, era
lida por qualquer revisor como ordem definida, e não ordenava nada.

Nem era o mesmo dado: `Cupom::$validade` é um **inteiro** de dias de duração,
enquanto `CupomCliente::$validade` é a **data** de vencimento daquele cupom
entregue — que é o que a projeção seleciona e o que a tela formata com
`dateToString()`. Um `cup` onde cabia `cupcli`.

**O tamanho, medido:** 100.374 linhas em 32 cupons, o maior com **18.658** — 933
páginas de 20 em ordem arbitrária.

**A dos descontos trazia uma armadilha de colisão.** A listagem é enraizada no
item e ordenava pela data da **venda**: uma venda com três itens descontados
gera três linhas com a mesma data, empatadas entre si (18.047 vendas assim só em
2025). O desempate é `pro.id` — mas `pro.id` sem apelido vira a chave `id`, que a
projeção **já usa** para `ven.id`, e a view monta o link de detalhes com
`$venda['id']`. Sem o `AS itemId`, o conserto do desempate trocaria o link da
venda pelo id do item, calado.

**A de indicações era o caso simples** que as outras duas não eram: raiz visível,
`ind.id` já na projeção, uma linha de mudança. São 7 indicações na base, então o
desempate não muda nada hoje — entrou porque é grátis.

**As três precisaram de `useOutputWalkers`.** `AdapterQuery` nasce com
`setUseOutputWalkers(false)`, que exige na projeção todo campo citado no `ORDER
BY`. Foi por isso que duas delas exigiram mexer no `SELECT`, e é onde a colisão
de chave apareceu.

**36 de 36.** O classificador não acusa mais nenhuma.

#### O ponto cego dos auxiliares, CORRIGIDO em 06/10/2026

O parágrafo de cima registrou que "ramos escritos com `comoMysql()` são invisíveis ao
roteiro", e o portão continuou assim por mais de um mês. Lido no código, o ponto cego
era maior do que o escrito: `conferir-ordenacao-empates.php` lia **só o último**
`ORDER BY` do método, e só até a primeira aspa. Dois buracos, nenhum acusado:

- o ramo `" ORDER BY " . Ordenacao::comoMysql('cli.email', $dir)` deixa o `ORDER BY`
  seguido de aspa -- a expressão regular não via termo nenhum, e um método que só
  ordenasse pelo auxiliar saía como "sem ordenação: o D47 não se aplica";
- os ramos de coluna clicável que não eram o último -- oito dos nove de
  `ClienteRepository::listagemIndexRelatorio`, cinco dos seis de
  `ConvenioRepository::listagemIndex` -- não eram lidos, com auxiliar ou sem.

**O conserto:** cada `ORDER BY` do método é lido pelo tokenizador do PHP, seguindo a
concatenação até o fim da expressão: o texto das strings, `ASC` no lugar de
`Ordenacao::direcao()`, e o **primeiro argumento** de `comoMysql()` e de
`nulosPorUltimo()` como o termo ordenado. Toda cláusula tem de terminar no `.id`; a que
termina numa variável ou numa chamada qualquer sai CONFERIR ("não sei ler"), e não como
desempate. O `--aplicar` recusa o ramo montado por concatenação -- ele edita texto, e a
cláusula não existe inteira no texto.

**Medido:** 41 listagens paginadas, **54 ramos de `ORDER BY` lidos** (eram 41, um por
método), e a árvore limpa verde, saída 0. O `--aplicar` continua corrigindo o ramo
simples (conferido numa cópia: o `cup.id` tirado de `CupomRepository::listagemIndex`
voltou, e o arquivo ficou idêntico ao do repositório).

**Falsificado numa cópia fora do repositório** (`/tmp/fals-d47`, o `module/` copiado),
cada mutante sozinho, e a cópia conferida idêntica ao repositório no fim:

| mutante na árvore | portão novo | portão de antes |
|---|---|---|
| ramo `comoMysql` do convênio sem `, con.id` | **1**, CONFERIR | 0 |
| ramo `comoMysql` do e-mail do cliente sem `, cli.id` | **1** | 0 |
| a única ordenação pelo auxiliar, sem desempate (cupons gerados) | **1** | 0 |
| a mesma, com `. ", cupcli.id"` | 0 (sem falso vermelho) | 0 |
| ramo do meio com `direcao()`, sem desempate | **1** | 0 |
| ramo terminando em `$desempate` | **1**, "não sei ler" | 0 |

E o código novo arrancado, no portão, um pedaço de cada vez: lendo só o último ramo, os
ramos do meio voltam a passar (0 nos dois `comoMysql` e no `direcao()`); sem seguir a
concatenação, a **árvore limpa** fica vermelha (o `listagemIndexRelatorio` sai
"acrescentar `, cli.id`", que já está lá); e sem ler o termo do auxiliar a cor não
muda -- o ramo sai CONFERIR por "não sei ler" em vez de por falta de desempate. Ler o
termo muda o diagnóstico, e aceita o auxiliar que envolva o próprio `.id`; quem acusa é
ler cada ramo pela concatenação.

**O que ele ainda não lê:** a cláusula partida em duas instruções (`$dql .= " ORDER BY
a.x";` e depois `$dql .= ", a.id";`). Ela sairia acusada sem defeito -- o erro é para o
lado do vermelho. Zero assim nas 41.

#### A revisão de 06/10/2026: o `.id` tem de ser o da raiz, e havia uma listagem viva sem ele

A revisão da versão acima achou que o portão novo herdou o critério de antes -- "termina em
**qualquer** `.id`" -- e, com ele, um defeito vivo: `ClienteDebitoRepository::listagemIndex`
(a lista de devedores, do ERP antigo e da API nova) termina em `ORDER BY ven.id DESC` sobre a
raiz `deb`, e uma venda tem vários débitos. Medido no `franquiabv_dev`, só SELECT: 15 vendas
com 2 ou 3 débitos ativos, 32 débitos empatados, em unidades com 296, 114 e 59 débitos ativos
-- mais de uma página de 20. Na virada da página, um débito empatado sai em duas ou em nenhuma.
É a classe do `cup` em vez de `cupcli` que este débito já registrava.

**Os consertos:**

- **a listagem:** `ORDER BY ven.id DESC, deb.id DESC` (`deb.id` já estava no SELECT, sem
  GROUP BY). Caso novo `DevedorTest::testNaMesmaVendaODebitoMaisNovoVemPrimeiro`: dois
  débitos a mais na venda 90001010, e a ordem cobrada é o de id maior primeiro. **Vermelho
  sem o conserto nos dois bancos** (os dois devolveram `90001000, 90001001, 90001002`), verde
  com ele;
- **o portão:** a cláusula principal tem de terminar no `.id` **da raiz** -- a do `FROM` do
  método, ou, quando o `FROM` mora num auxiliar do mesmo arquivo
  (`IndicacaoRepository::listagemRelatorioIndex` chama `$this->buildRelatorioDqlSelect()`), a
  do auxiliar. O `.id` de outro alias sai acusado com o motivo ("termina em `ven.id`, que não
  é a raiz `deb` e pode se repetir nela");
- **o pedaço da concatenação** passou a ser montado só com os tokens significativos: o `//`
  do fim da linha ficava no pedaço, e `" ORDER BY " . $ordem // antes:
  Ordenacao::comoMysql('cli.id', $d)` passava, lendo o termo no comentário;
- **todo `ORDER BY` de cada literal**, e não só o primeiro: a subconsulta ordenada antes do
  `ORDER BY` principal, no mesmo literal, escondia o principal. A cláusula de subconsulta (a
  cortada no parêntese que a fecha) fica com a regra de antes -- termina em algum `.id` --,
  porque a raiz dela é outra e a ordem dela não pagina nada (DQL não tem `LIMIT` em
  subconsulta).

Medido de novo na árvore limpa: as mesmas 41 listagens e 54 ramos, saída 0; com a listagem dos
devedores de antes, saída 1, só ela.

**Falsificado numa cópia fora do repositório** (`/tmp/fals-d47b`), cada mutante sozinho, a
cópia conferida idêntica ao repositório no fim. "Antes de 06/10" é o portão do HEAD; "1ª versão"
é a da seção de cima:

| mutante na árvore | revisão | 1ª versão | antes de 06/10 |
|---|---:|---:|---:|
| devedores com `ORDER BY ven.id DESC` só | **1** | 0 | 0 |
| subconsulta `ORDER BY x.id` antes do `ORDER BY cupcli.validade`, no mesmo literal | **1** | 0 | 1 |
| a mesma, com `, cupcli.id` no principal | 0 | 0 | 0 |
| ramo por variável com `comoMysql('con.id', ...)` num `//` do fim da linha | **1**, "não sei ler" | 0 | 0 |

E o código novo arrancado do portão, um pedaço de cada vez: aceitando qualquer alias, os
devedores voltam a passar (0); com o comentário no pedaço, o ramo por variável volta a passar
(0); lendo só o primeiro `ORDER BY` do literal, a subconsulta volta a esconder o principal (0);
sem seguir o auxiliar, a **árvore limpa** fica vermelha -- "não achei o alias da entidade raiz"
nas indicações, o falso vermelho que ele evita; e cobrando a subconsulta pela raiz do método, a
subconsulta com desempate próprio sai vermelha sem defeito.

---

### D44 — A trava contra execução simultânea é usada em 6 rotinas, não 4 — e o teste do dashboard ficava verde com a trava arrancada — PARCIAL, lido em 05/10/2026 — PARCIAL em 13/10/2026 — PREPARADO em 07/11/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: a trava em `relatoriosRoyaltiesFaturasGerar`, à espera de decisão sua; trocar `flock` por `pg_advisory_lock` depois do corte; a unicidade de royalties, DDL em produção.

`CronJobService::acquireLock()` implementa exclusão mútua com `flock`, e está
aplicada apenas em `marketingEnvios` e `marketingPush`.

**O D40 é o preço disso**: 107 linhas duplicadas no dashboard, 84 com valores
divergentes, porque a rotina que as grava não usa a trava que o próprio arquivo
oferece vinte linhas acima.

A de **royalties** grava com o mesmo padrão de ler-e-criar-se-não-existe, também
sem trava — e é a única do conjunto que mexe em dinheiro.

**Duas limitações que precisam ser ditas antes de espalhar a trava por aí:**

1. **É trava de arquivo**, em `sys_get_temp_dir()`. Protege contra dois processos
   na mesma máquina e **não** contra duas máquinas. Com a F1.7 e mais de um nó,
   deixa de proteger — e o sintoma volta a ser duplicata silenciosa.
2. **Trava não substitui restrição de unicidade.** A do dashboard entrou no
   schema PostgreSQL; a de royalties ainda não tem.

**Duas rotinas a mais ganharam a trava em 30/08/2026**, e agora são 4 de 29:

- **dashboard de colaboradores**, que era a causa do D40. A trava mora no
  serviço, junto do trabalho, e tem teste.
- **royalties**, a única do conjunto que mexe em dinheiro. A trava ficou no
  *controller* do cron, porque `FranquiaService` recebe só o EntityManager e não
  alcança `acquireLock()`. Funciona, e é meio-caminho: a cobertura é por leitura,
  não por execução.

#### A extração foi feita em 30/08/2026

A exclusão mútua virou `ISBase\Util\TravaDeExecucao`, com `obter()` e
`liberar()`. `CronJobService::acquireLock()` e `releaseLock()` continuam
existindo como delegação, para não quebrar quem já chamava.

**A trava de royalties voltou do controller para dentro do serviço**, que é onde
devia estar desde o começo — estava fora por alcance, não por desenho. E trava
longe do trabalho que protege é trava que a próxima pessoa esquece de levar
junto.

**A extração trouxe a cobertura junto, e essa é a razão prática de fazê-la.**
Enquanto a trava morava no controller, testá-la exigiria subir HTTP com o token
do cron; com ela no serviço, o teste é uma chamada. `CronTravaSimultaneaTest`
passou a ter o caso de royalties — a rotina onde a trava mais importa, porque a
tabela não tem restrição de unicidade em banco nenhum e a trava é a única
proteção que existe.

Extrair não foi arrumação: foi o que tornou a proteção verificável.

#### Espalhar não era espalhar por todas — eram duas

Classifiquei as 27 rotinas públicas do `CronJobService` pelo padrão que a corrida
estraga, em vez de aplicar a trava a todas:

| | |
|---|---:|
| Já com trava | 3 |
| **Lê-e-cria-se-não-existe, sem trava** | **2** |
| Escrevem sem esse padrão | 11 |
| Não escrevem | 11 |

As duas do meio ganharam trava em 30/08/2026, e **são 5 de 27**. As outras 22 não
precisam: rotina que só lê não corre risco, e rotina que só insere não duplica
*por corrida* — pode duplicar de outro jeito, o que é outro assunto.

**`relatoriosDashboardFaturamentoAtualizar`** tinha o padrão e nenhuma duplicata
na base. Isso não é proteção: é sorte de agendamento, com a mesma corrida na
mesma janela noturna e sem restrição de unicidade para barrar a segunda gravação.

**`npsContabilizar` é o caso mais exposto de todos, e não por ser cron.**
`VendasController` a chama **toda vez que alguém abre o relatório de vendas
realizadas** — dois usuários abrindo junto disputam, e um usuário abrindo durante
a execução noturna também.

E a base confirma: `nps_resultados_colaboradores` tem **5 combinações
(colaborador, mês, ano) repetidas**, todas com `data_criacao` idêntica ao segundo
— a assinatura de duas execuções gravando juntas. A mais recente é de **12/2025**.

**Um preço visível ao usuário, que vale dizer:** com a trava detida, o relatório
abre sem consolidar os NPS pendentes daquele instante, e a próxima abertura os
atualiza. É menos ruim que a alternativa atual, em que a tela lê uma de duas
linhas duplicadas sem que ninguém saiba qual — mas é mudança de comportamento
numa tela, e não só numa rotina noturna.

**O que continua pendente:** trocar `flock` por `pg_advisory_lock` na migração. O
`flock` protege contra dois processos na mesma máquina e não contra duas
máquinas, e a F1.7 torna isso necessário.

**Decidido em 31/08/2026: adiado, e agora com um número ao lado.** Ao decidir
este débito perguntei se a corrida da tabela de royalties já tinha acontecido.
Tinha: **87 pares duplicados, 29 unidades, entre 25/11 e 14/12/2025** — virou o
**D71**. O dinheiro está correto hoje por acidente, e o D71 explica qual acidente
e o que o desfaz. A troca da trava e a unicidade que falta voltam à mesa juntas,
no desenho do corte.

**Ao resolver:** extrair a trava para um utilitário compartilhado, trocar
`flock` por `pg_advisory_lock` na migração — que vale entre nós, e a F1.7 torna
isso necessário — e acrescentar a unicidade que falta. A trava evita o trabalho
repetido; a restrição garante que, se ele acontecer, o banco recusa.

**A de `royalties` não existe em banco nenhum**, nem MySQL nem PostgreSQL. Ali a
trava é a única proteção, e é por isso que ela entrou mesmo pelo caminho
imperfeito.

---

#### O vigia ficava verde com a trava arrancada — e é a segunda vez — 14/09/2026

**Este débito não fecha**, e o motivo é o que o próprio registro já dizia: o que
sobra dele é trocar `flock` por `pg_advisory_lock` — que só faz sentido com a
aplicação em PostgreSQL, depois do corte — e a unicidade que falta no MySQL, que
é DDL em produção congelada. Há decisão sua de 31/08/2026 sobre isso. O que muda
hoje é o vigia, que não vigiava.

**`testODashboardDesisteQuandoJaHaExecucaoEmCurso` passava com a guarda da trava
arrancada.** Medido em 14/09/2026: removida a guarda de
`relatoriosDashboardColaboradoresAtualizar`, a rotina roda apesar da trava
detida — e o teste continuava **verde**.

A causa é onde ele olhava. A rotina percorre `selecionarUsuariosRelatorio`, que
filtra `status = 1 AND grupo = 1 AND DATE(relatorio_dashboard_colaboradores) < hoje`
— **180 colaboradores** hoje. A linha vigiada era a `90001516`, da fixture, cujo
colaborador é o `90001000`, de `grupo = 90001000`. Perguntado com o filtro da
própria rotina, o número é **zero**: aquele colaborador não está no conjunto que
ela percorre. `data_alteracao` não mudava nem quando a rotina rodava, e a
asserção era verdadeira nos dois mundos.

**Foram dois caminhos para o mesmo buraco.** O remendo de 01/09/2026 — que
consertou uma dependência de calendário real — passou a inserir a linha sintética
`90001500` com `colaborador 90001000`, igualmente inalcançável. E hoje nem chega
lá, porque a fixture já traz a `90001516`, do mesmo colaborador, que o `SELECT`
encontra primeiro. Trocou-se um vermelho falso por um verde falso, e ele durou
treze dias.

**E é a segunda cegueira do mesmo teste.** O comentário vizinho, escrito quando a
primeira foi consertada, diz com todas as letras: *"contar linhas não serve, e a
primeira versão deste teste fazia isso — e passava mesmo com a trava
desligada"*. Duas vezes o conserto pareceu certo e o vigia continuou olhando para
o lugar errado.

O conserto: o colaborador passa a sair da **consulta da própria rotina**, com o
mesmo filtro escrito igual, e a linha vigiada é a daquele colaborador — a de
menor `id`, que é a que `selecionarPorMesAnoColaborador` devolve. Não há como o
vigia olhar onde o vigiado não passa.

**E o conserto nasceu com um defeito meu, achado horas depois.** O caso passou a
escolher o colaborador pela consulta da própria rotina — e **no banco do CI não
existe nenhum**: ele nasce de `mysql.sql` mais as fixtures, e a fixture tem
`grupo = 90001000`. Com o `assertNotFalse` duro, o caso **reprovava** no banco
limpo: eu teria deixado o CI vermelho para sempre no dia em que ele voltasse.

Apareceu rodando `scripts/rodar-caracterizacao-como-ci.sh`, que monta o banco do
jeito que o CI monta — 641 testes, **1 falha**, e a falha era minha. O conserto é
o que a base já oferece: `exigeHistoricoDaProducao()`, que **pula dizendo o
motivo** quando a cópia da produção foi declarada ausente, e continua falhando
quando ela deveria estar lá. Falhar diria que há regressão onde há ausência de
dado; passar calado seria verde sobre nada.

| | |
|---|---|
| teste antigo, guarda arrancada | **verde** — por treze dias |
| teste novo, guarda arrancada | **vermelho**: `2000-01-01 00:00:00` virou `2026-09-14 04:58:31` |
| teste novo, guarda no lugar | verde, e em 3 segundos |

#### O número do título estava velho, e a contagem é 6

`grep` por `acquireLock` e `TravaDeExecucao::obter` em `module/` devolve **seis**
pontos de aquisição, em dois arquivos: `npsContabilizar`,
`relatoriosDashboardFaturamento`, `relatoriosDashboardColaboradores`,
`marketingEnvios` e `marketingPush` (nome montado, serve mais de uma ação) no
`CronJobService`, e `relatoriosRoyaltiesAtualizar` no `FranquiaService`. O título
dizia **4**.

#### Uma rotina de cron com o padrão do D44, sem trava, que emite boleto — PARA DECISÃO SUA

Este é o achado que **não** foi consertado esta noite, e a razão está escrita
abaixo.

`module/ISRelatorio/src/Service/FranquiaService.php`, método
`relatoriosRoyaltiesFaturasGerar()`. Ele tem o ler-e-criar-se-não-existe exato
que o D44 descreve — `selecionarPorUnidadeVencimento()`, `if (empty($fatura))`,
`persist`/`flush` — e **não tem trava**. `royalties_faturas` não tem restrição de
unicidade em banco nenhum, nem no MySQL nem em `postgres/schema.sql`.

Ela escapou da classificação porque o D44 diz *"classifiquei as 27 rotinas
públicas do `CronJobService`"* — e as rotinas de cron que moram no
`FranquiaService` ficaram de fora da varredura.

**Por que não foi consertada:** o corpo do método termina chamando
`gerarFatura()`, que fala com a API **de produção** do Asaas, com a chave escrita
no construtor de `module/ISBase/src/Util/Asaas.php` — que o **D3** já registra
como sendo a de produção. E essa chamada está **fora** do `if (date('d') == 16)`
que envolve o resto do método: ela roda em **toda** invocação, qualquer dia.

*Dito com a precisão que eu tenho, e não mais que ela:* a busca que alimenta o
laço é `DATE(vencimento) = :vencimento AND asaasStatus = 'WAITING'`, com o
vencimento montado como `"29/" . date('m/Y')` — hoje, `2026-09-29`. Na cópia há
**5** faturas em `WAITING`, e nenhuma vence nessa data (a mais recente é
2026-01-29). Então **hoje**, nesta cópia, a rotina não acharia nada para cobrar.
Isso é acidente do dado, não garantia: no dia 16 ela mesma cria as faturas com
vencimento `29/mm/aaaa` e **no mesmo fôlego** entra no laço que as cobra. Eu não
executei a rotina, e não vou executá-la para descobrir a diferença.

**E ela era chamada por nós, todo dia, sem ninguém saber.** Medido em
14/09/2026: `scripts/medir-cron-no-postgres.php` pedia as 22 rotinas de cron uma
a uma — e `relatoriosRoyaltiesFaturasGerar` estava entre elas, assim como
`relatoriosRoyaltiesFaturasAtualizar`. O CI também as pediu, na última corrida
verde. As duas saíram da medição no mesmo dia, com o motivo escrito; o registro
disso está no **D98**. Isso não conserta o D44 — a rotina continua sem trava —,
mas tira do caminho a única coisa que, aqui, poderia transformar uma medição em
cobrança.

A guarda em si é PHP puro e cabe em cinco linhas, no molde da irmã vinte linhas
acima. Mas pôr guarda em código que mexe em cobrança **sem poder falsificá-la**
contraria a regra desta casa — e a alternativa custa apenas esperar você acordar.
O trecho, pronto para entrar:

    $trava = Util\TravaDeExecucao::obter('relatoriosRoyaltiesFaturasGerar');

    if ($trava === false) {
        return false;
    }

    try { ... } finally { Util\TravaDeExecucao::liberar($trava); }

#### A trava das faturas do dia 16 — 13/10/2026 (a resposta 98 do dono)

O dono respondeu a pergunta 98 do lote, em 11/10/2026, com a opção (a): **"A trava, e a emissão continua em qualquer
chamada"** -- repetir a chamada no dia 17 emite só as que falharam no dia 16.

**O conserto** (o item UNIDADES-GRUPOS-ROYALTIES). `FranquiaService::relatoriosRoyaltiesFaturasGerar` pega a trava
`relatoriosRoyaltiesFaturasGerar` (a `TravaDeExecucao` da rotina irmã das 05:25, com o nome desta) antes de tudo, e ela
cobre a criação e a emissão; com a trava detida, a rotina devolve `false` sem tocar em nada, e o `CronJobController`
responde `{"sucesso": false, "mensagem": "Processo já em execução."}`, como o da apuração. A emissão continua onde estava,
fora do `if` do dia 16: qualquer chamada emite as faturas do mês ainda `WAITING`, e a emitida sai do `WAITING` com o
status do Asaas. Duas mudanças pequenas para o teste: o dia da conta virou argumento (`$hoje`; a rotina usa o de agora,
e as contas são as de antes, com o mês anterior pelo primeiro dia dele), e o cliente do Asaas (`asaas()`) ficou
protegido, e não privado, para o teste trocá-lo por um dublê numa subclasse.

**Provado sem falar com o Asaas** (`tests/Caracterizacao/FaturasDeRoyaltiesTravaTest.php`, 3 casos, nos dois bancos, em
série): com a trava detida, a rotina devolve `false`, o dublê não é chamado e a fatura do caso continua `WAITING`; com a
trava livre, a emissão chega na fatura do caso num dia que não é 16, e a segunda chamada roda (a trava foi liberada) e não
a reemite; com o dublê recusando uma das duas, a recusada fica `WAITING` com o erro, e a segunda chamada tenta só ela. A
rotina nunca rodou fora da suíte, e a configuração passada ao serviço do teste é vazia: o Asaas de verdade lançaria no
construtor, antes de qualquer `curl`. **Mutante:** o `if ($trava === false)` arrancado deixa o primeiro caso vermelho.

**O que continua pendente** é o de antes, e por isso o débito segue PARCIAL: trocar `flock` por `pg_advisory_lock` depois
do corte (a trava de arquivo não protege entre duas máquinas), e a unicidade de `royalties` e de `royalties_faturas`, que é
DDL em produção. Até lá a trava é a única proteção das duas tabelas.

#### A revisão da trava das faturas — 13/10/2026

A verificação do item achou quatro lacunas na prova acima e uma no que ela afirmava; as cinco foram fechadas ou registradas:

- **A trava cobria a criação só na leitura.** Os três casos passavam com ela movida para depois do `if` do dia 16: a
  contenção só era medida na emissão, e o ler-e-criar das faturas -- o defeito que o D44 descreve -- não estava preso por
  teste. O caso novo `testComATravaDetidaNoDia16NenhumaFaturaNasce` põe um dia de retrato no ciclo da unidade do caso e passa
  o dia 16 deste mês: com a trava detida, nenhuma fatura nasce e o dublê não é chamado; solta, nasce uma só e é emitida.
- **Os casos rodavam no dia de hoje.** Sem `$hoje`, a força do "em qualquer dia" dependia do calendário: num dia 16, a
  emissão movida para dentro do `if` ficava verde. Agora os casos passam o dia (o 17 deste mês para "um dia que não é 16", o
  16 deste mês para a criação); o mês continua o corrente, porque as fixtures gravam nele.
- **A resposta do controller não era medida.** `testOControllerDoCronDizQueARotinaJaEstaEmExecucao` despacha
  `relatoriosRoyaltiesFaturasGerarAction` com um contêiner que só entrega o serviço com o Asaas dublado: com a trava detida,
  `{"sucesso": false, "mensagem": "Processo já em execução."}`; livre, `{"sucesso": true}`.
- **"Emite só as que falharam" vale quando a resposta chega.** Se o `curl` passa dos 30 s (ou o corpo não é JSON) depois de
  o Asaas criar a cobrança, `gerarFatura` não via nem id nem erro, e a fatura ficava `WAITING`, sem nada, igual à que nunca
  foi tentada: a chamada repetida do dia 17 manda outro `POST /payments`, e são dois boletos para a mesma unidade. O defeito
  é anterior ao item (a emissão em qualquer chamada já existia). **Consertado o que não pede decisão:** a resposta perdida
  fica anotada em `asaas_erro` ("sem resposta do Asaas ao gerar boleto (a cobranca pode ter saido: confira no Asaas antes de
  emitir de novo)"). **O resto -- como conferir antes de reemitir -- é o D484**, que foi ao lote; o caso
  `testARespostaPerdidaFicaAnotadaEAChamadaRepetidaReemite` mede o aviso e caracteriza a reemissão, sem decidir.

**Mutantes**, um por vez, no MySQL, todos vermelhos: a trava só depois da criação, a emissão só no dia 16, o controller
sempre com sucesso e a resposta perdida sem o aviso. A rotina continua sem falar com o Asaas em caso nenhum.

#### A trava no banco e a fatura única por vencimento — 07/11/2026 (a fatia 105, ROYALTIES-E-TRAVAS-DO-CRON)

**O que faltava, nas palavras de 13/10/2026:** trocar `flock` por `pg_advisory_lock` (a trava de arquivo não protege entre
duas máquinas) e a unicidade de `royalties_faturas` (a de `royalties` já estava no `postgres/schema.sql`). As duas entraram,
no PostgreSQL, e por isso o débito fica **PREPARADO**: no MySQL de produção nada muda até o corte, e o que entrou só vale com
a aplicação no PostgreSQL.

- **A trava no banco.** `TravaDeExecucao::obter($nome, $conexao)` ganhou a conexão, opcional. Com ela, e no PostgreSQL,
  depois do `flock` a trava pede `pg_try_advisory_lock` pela conexão do EntityManager (a chave: os 8 primeiros bytes do
  sha256 do nome, `chaveNoBanco`), sem esperar, como o `LOCK_NB`; detida por outra sessão, o arquivo é solto e a resposta é
  `false`, a mesma da execução em curso. O `liberar` solta a do banco antes do arquivo e não reclama se o banco recusar (a
  transação abortada): a trava de sessão morre com a conexão, no fim do pedido. **O arquivo continua na frente**: é ele quem
  recusa a segunda execução neste processo (a trava do banco é reentrante na mesma sessão, e o EntityManager é um só). No
  MySQL fica o `flock`: uma máquina só até o corte, e o `GET_LOCK` valeria para o servidor inteiro, entre bancos.
- **As seis rotinas passam a conexão:** `relatoriosRoyaltiesAtualizar` e `relatoriosRoyaltiesFaturasGerar` (no
  `FranquiaService`), `relatoriosDashboardFaturamento` e `npsContabilizar` (no `CronJobService`), e `marketingEnvios` e
  `marketingPush` (pelo `CronJobService::acquireLock`, que agora passa a do serviço). As telas e os relatórios que usam a
  `TravaDeExecucao` sem conexão (as travas por pessoa) seguem só com o arquivo.
- **Uma fatura por unidade e vencimento.** Medido antes, só com `SELECT` de contagem: na `franquiabv_dev` do MySQL, 890
  faturas, nenhuma sem vencimento e **zero** pares (unidade, vencimento) repetidos; na cópia local do PostgreSQL, também 890
  e zero. Sem duplicata, nada a apagar: `royalties_faturas` entrou na lista `$unicidades` de `scripts/gerar-ddl-postgres.php`
  (com a evidência escrita), o `postgres/schema.sql` foi regerado (uma linha: a restrição
  `royalties_faturas_inquilino_id_unidade_vencimento_unico`) e o portão `scripts/conferir-unicidade-do-schema.php` a declara
  (onze unicidades). O `conferir-limpeza-da-unicidade.php` a lê "limpa, e não precisa de bloco". No MySQL ela não entra,
  como a de `royalties`. **Se até o corte a produção ganhar um par repetido**, a carga da tabela recusa e o `conferir()` do
  `migrar-dados.sh` aponta; o `scripts/conferir-unicidade.php` já pergunta por ela (esperado 0) — e limpar seria apagar
  linha, do dono.
- **Dois testes mudaram por causa da restrição:** o `FaturasDeRoyaltiesTravaTest` punha duas faturas na mesma unidade e no
  mesmo vencimento (a segunda foi para a 90001000, que o dublê cadastra no Asaas antes do boleto), e o
  `RelatorioDeRoyaltiesTest` semeava a repetida da desativada (a 90006905) — agora só no MySQL, onde ela ainda entra e o
  relatório a conta; no PostgreSQL a conta é zero.

**Provado** (os casos novos `D44TravaNoBancoTest`, 16, e três em `FaturasDeRoyaltiesTravaTest`, nos dois bancos, em série):
para cada uma das seis rotinas, com a trava detida por outra conexão nesta máquina (nos dois bancos) e só no banco, como a
rotina de outra máquina (no PostgreSQL; no MySQL o caso pula dizendo que lá a trava é de arquivo), a rotina desiste; o par
das campanhas e da fila do push roda com a trava livre. A própria trava: no PostgreSQL o handle traz a do banco e outra
sessão a vê, liberada ela volta, a de outra sessão recusa e solta o arquivo, um nome só trava o nome dele, e com a transação
abortada o `liberar` não lança. No PostgreSQL a segunda fatura do mesmo vencimento é recusada pelo banco; no MySQL ainda
entra. **Nada saiu da máquina**: o marketing roda com o push e o WhatsApp dublados e as campanhas num instante de 2000; as
faturas, com o Asaas dublado.

**Mutantes**, um por vez, devolvidos pelo sha1, no PostgreSQL: a trava do banco arrancada da `TravaDeExecucao` (9 casos
vermelhos), o `pg_advisory_unlock` arrancado, e a conexão tirada de cada uma das seis rotinas (um vermelho cada, o da outra
máquina); a restrição tirada do `schema.sql` (o caso da fatura repetida vermelho, e o portão da unicidade sai 1). O de soltar
o arquivo ao desistir ficou verde, e é equivalente: o PHP fecha o arquivo, e solta o `flock`, ao sair do escopo.

**Não provado:** duas máquinas de verdade (a outra sessão do banco faz o papel dela); o PgBouncer em modo transação, que
quebraria a trava de sessão (hoje não há pooler no desenho da F1.7, e o `DeclararInquilino` já depende da sessão).

**A cópia local do PostgreSQL** (a correção da fatia 105, o achado da verificação): a chave entrou só no `schema.sql`, e os
bancos que nascem dele (o da suíte, o do contrato e, na janela do corte, o de produção) a tinham; a `franquiabv_dev` do
contêiner `franquiabv-postgres-1`, nascida antes, não. Ela a recebeu por `postgres/migracao-royalties-faturas-unica.sql`
(só acrescenta: sem par repetido o `ALTER` cria a chave, com par repetido falha e a transação volta; idempotente), rodado
duas vezes em 07/11/2026: 890 faturas, zero pares, e a chave `UNIQUE (inquilino_id, unidade, vencimento)`. O caso
`FaturasDeRoyaltiesTravaTest::testORoteiroDaCopiaDoPostgresCriaAChave` roda o roteiro duas vezes sobre uma tabela sem a chave
(uma temporária com o mesmo nome, como a das conclusões da VIP Class) e confere o nome e as colunas contra o `schema.sql` e a
recusa da fatura repetida; com o `ALTER` arrancado do roteiro, vermelho. Não é passo do corte: o MySQL de produção não
recebe a chave, e o PostgreSQL de produção nasce do `schema.sql`. **A de `royalties` continua fora da cópia local do
PostgreSQL** (o mesmo vão, de 13/09/2026): ali estão os 87 pares do D110 (contados em 07/11/2026), e acrescentá-la pede
apagar linha.

### D42 — PHP e MySQL discordam sobre que dia é, três horas por dia — CORRIGIDO E COM PORTÃO em 13/09/2026 — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: ler o relógio do servidor de produção, que nunca foi medido, na janela do corte.

Descoberto em 27/08/2026, investigando quatro testes de caracterização que
passaram às 19h34 e falharam às 21h.

| | |
|---|---|
| MySQL | `2026-08-27 03:05` — **UTC** |
| PHP | `2026-08-27 00:05` — America/Sao_Paulo |

São três horas de diferença. Entre **21h e meia-noite** no horário de Brasília, o
`CURDATE()` do banco já é o dia seguinte enquanto o `date()` do PHP ainda é hoje.

**Qualquer código que misture as duas fontes tem três horas por dia em que elas
discordam** — e o sintoma é um resultado vazio, não um erro. Foi assim que
apareceu: `royaltiesAtualizar` grava a linha com a data do PHP, e o teste a
procurava com `CURDATE()`.

#### Onde isso pode doer de verdade

**Os cronjobs rodam às 23h**, medido pelos horários das duplicatas do D40 — ou
seja, **02h UTC do dia seguinte** para o banco. Todo cron que use `NOW()`,
`CURDATE()` ou `CURRENT_DATE` em SQL está calculando para o dia errado, todas as
noites.

Isso liga a três débitos já registrados. O **D28** — a venda não pode ser
registrada na data em que aconteceu — fica pior se o próprio "hoje" for ambíguo.
O **D40**, das duplicatas de dashboard, nasce num lote que roda exatamente nessa
janela. E a **F1.3** escolheu `timestamptz` justamente para o instante nunca ser
ambíguo; esta é a mesma questão, do lado da aplicação.

#### MEDIDO em 27/08/2026: produção está alinhada

A réplica respondeu `-03:00`, com `NOW()` batendo com o relógio de Brasília:

| | |
|---|---|
| `@@global.time_zone` | **-03:00** |
| `NOW()` | 06:15 — horário de Brasília |
| `UTC_TIMESTAMP()` | 09:15 |

**Não é problema vivo.** Os cronjobs das 23h calculam para o dia certo, e a
suspeita de que estivessem uma noite adiantados cai por terra.

O descompasso era do **contêiner**: a imagem oficial do MySQL roda em UTC, e o
ambiente estava mentindo sobre produção. Corrigido com `--default-time-zone=-03:00`
no `docker-compose.yml`, e conferido.

#### O que continua aberto, e por que ainda é débito

Alinhar o fuso conserta o sintoma e **deixa a armadilha armada**. O problema de
fundo é a aplicação misturar duas fontes de relógio: data que ela decide vem do
PHP, e `CURDATE()`/`NOW()` em SQL vêm do banco. Enquanto as duas convivem, basta
um servidor novo com fuso diferente para tudo voltar — sem erro, com resultado
vazio.

**Ao resolver:** varrer o código atrás de `NOW()`, `CURDATE()` e `CURRENT_DATE`
em SQL e trocá-los por parâmetro vindo do PHP. É pequeno e some com a classe
inteira.

**Severidade revista:** de "pode estar errado todas as noites" para "está certo
hoje, e depende de um detalhe de infraestrutura que ninguém documentou". O
`docker-compose.yml` agora documenta.

**Ao resolver:** a saída não é só alinhar o fuso do banco — é parar de misturar
as duas fontes. Data que a aplicação decide vem do PHP e vai como parâmetro;
`CURDATE()` em SQL some. Alinhar o fuso conserta o sintoma e deixa a armadilha
armada para o próximo servidor.

#### A VARREDURA FOI FEITA em 03/09/2026 — e o débito dizia a verdade: era pequeno

O registro previa *"varrer o código atrás de `NOW()`, `CURDATE()` e
`CURRENT_DATE` em SQL"* e dizia que era pequeno. Medido: no `module/` inteiro há
**três** ocorrências de relógio do banco, e **uma delas é comentário**. Sobram
duas:

| Onde | O que fazia |
|---|---|
| `ClienteRepository:712` | a idade, no filtro por faixa etária do relatório de clientes |
| `PesquisaRepository:57` | carimbava `dataAlteracao` ao desligar pesquisas do plano de fidelidade |

As duas passaram a receber o instante como parâmetro, vindo do PHP. A segunda
era a **última escrita do sistema que datava pelo relógio do banco** — todo o
resto usa `setDataAlteracao()`, que faz `new \DateTime('now')`.

**Não sobrou nenhum `NOW()`, `CURDATE()`, `CURRENT_DATE` ou `CURRENT_TIMESTAMP`
em SQL no `module/`.**

#### E A TROCA REVELOU UM DEFEITO QUE SÓ APARECIA COM PARÂMETRO

Ao pôr o parâmetro no filtro de idade, o PostgreSQL recusou:

> `bind message supplies 4 parameters, but prepared statement "" requires 5`

A tradução de `TIMESTAMPDIFF` para PostgreSQL escrevia `age(b, a)` **duas vezes**
para montar `anos * 12 + meses`. Com os dois lados resolvidos pelo próprio SQL —
que era o único uso que existia — nada quebrava. Basta um deles ser parâmetro
para o marcador sair duplicado no SQL enquanto o Doctrine liga o valor uma vez
só.

O defeito estava lá desde que a função foi escrita, e **nenhum teste o alcançava**
porque nenhum passava parâmetro. Foi o conserto do D42 que o trouxe à tona.

**A correção faz cada argumento aparecer uma vez.** Para `YEAR` a conta some:
`age()` já devolve os anos inteiros, respeitando dia e mês — que é exatamente o
que `trunc((anos*12 + meses)/12)` calculava. Para `MONTH`, uma subconsulta
escalar nomeia o intervalo uma vez e extrai as duas partes dali.

**A equivalência foi medida, não deduzida.** Sobre as 242.586 linhas de
`clientes` com data de nascimento, as duas fórmulas de `YEAR` divergem em
**zero** casos. E as três unidades, com parâmetro, dão o mesmo número nos dois
bancos: `YEAR` 60, `MONTH` 722, `DAY` 21.988.

**Coberto por `FuncoesDqlDeclaradasTest`**, que ganhou três casos passando
parâmetro — um por caminho da tradução (`YEAR` e `MONTH` usam `age()`, `DAY` usa
EPOCH). O caso antigo passava `ven.dataCriacao` dos dois lados, e é por isso que
nunca acusou.

#### FECHADO EM 13/09/2026 — e o que faltava não era o conserto, era o vigia

**O defeito não existe mais, e isso foi medido, não suposto.** Varredura por
token — não por texto — sobre o código que roda:

| alvo | arquivos | ocorrências |
|---|---:|---:|
| `module/` | 658 | **0** |
| `public/` | 4 | 0 |
| `config/` | 7 | 0 |
| `scripts/` | 70 | 5, e nenhuma é SQL executado |

**O que faltava era um portão, e o vão era específico.**
`conferir-relogio-nos-testes.php` varre **só `tests/Caracterizacao`** — ele cuida
do *momento* (a mesma fonte lida duas vezes, com a virada no meio: o D87). Nada
olhava para o código de produção, então um `CURDATE()` novo numa entidade
entraria sem ninguém ver. Agora há `scripts/conferir-relogio-do-banco.php`, o
**36º do conjunto**, que cuida da *fonte*.

**E havia uma ocorrência viva, fora de produção.** `ensaiar-rotas-publicas-do-erp`
inseria três vendas com `NOW()` no SQL. Virou parâmetro, e o motivo é duplo: a
fonte passa a ser a mesma do resto da casa, **e as três vendas passam a nascer no
mesmo instante** — `NOW()` reavalia a cada `INSERT`, e uma virada de meia-noite no
meio do laço dataria irmãos em dias diferentes. É o D42 e o D87 na mesma linha.

#### ELE LÊ POR TOKEN, e sem isso seria inútil

Os comentários desta casa **citam** `NOW()` e `CURRENT_TIMESTAMP()` para explicar
o próprio conserto — são **seis linhas em quatro arquivos** (`TimestampDiff.php`,
`ClienteRepository.php`, `PesquisaRepository.php`, `Unidade.php`).

Um portão escrito por `grep` nasceria **vermelho sobre a documentação do
conserto**, e o único jeito de deixá-lo verde seria apagar a explicação. Este olha
só literais de texto, que é onde o SQL mora. Conferido: com as seis lá, ele sai 0.

**E ele não se acusa.** Este arquivo carrega a lista dos símbolos que procura;
entrar na própria lista de exceções — como o irmão faz — obrigaria a recontar a
cada palavra mexida no cabeçalho, e portão que quebra quando se edita seu próprio
comentário ensina a ignorar o vermelho.

#### FALSIFICADO EM TRÊS SENTIDOS

| o que se estraga | o que ele faz |
|---|---|
| `CURRENT_TIMESTAMP()` volta em `ClienteRepository` | sai **1**, nomeando arquivo e linha |
| acrescenta-se uma exceção que ninguém usa | sai **1**: *"exceção que não descreve mais o código"* |
| o alvo encolhe para um diretório só | sai **2**: *"verde sobre nada não é verde"* |

#### O QUE CONTINUA ABERTO, e é o mesmo de antes

**O relógio de produção nunca foi medido.** Aqui os dois batem no segundo —
`@@global.time_zone` é `-03:00` e o PHP é `America/Sao_Paulo`, conferido hoje. O
servidor de produção não foi lido, e por decisão de 11/09/2026 não será até a
janela do corte.

O número que dimensiona o risco: **48.015 linhas** do banco de desenvolvimento
nasceram na faixa das 21h às 24h — 38.050 vendas (1,68%) e 9.965 clientes
(4,11%) —, que é a faixa em que duas fontes desalinhadas em três horas datariam o
dia errado. O estrago **hoje é zero**, porque não há duas fontes. O número diz o
que voltaria a ficar exposto se um servidor novo subisse com outro fuso **e** o
código voltasse a misturá-las — e é a segunda metade dessa frase que o portão
agora impede.

Desconfie de quem esperar 12,5% (3 de 24 horas): 1,68% em vendas é coerente com
barbearia que fecha cedo, e 4,11% em clientes também — cadastro pelo aplicativo à
noite.

---

### D41 — As campanhas por WhatsApp deixam 3.473 pessoas sem parabéns por ano — PARCIAL, lido em 05/10/2026 — PARCIAL em 12/10/2026: o parabéns não sai no dia de preenchimento (a resposta 114); falta a identidade por telefone (a F1.2) — PARCIAL em 13/10/2026 (a resposta 166)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decidir a campanha de parabéns diante das datas inventadas (4.880 mensagens todo 12/12); a identidade por telefone espera a tabela `pessoas` da F1.2.

Medido na réplica de produção em 26/08/2026, ao analisar as consultas da F1.4.

Três campanhas de cron — parabéns, pesquisa de cliente novo e "sentimos sua
falta" — agrupam por telefone para não mandar duas mensagens à mesma pessoa. A
consequência é que, quando dois clientes dividem o número, **um deles não
recebe**.

| | |
|---|---|
| Telefones com mais de um cliente ativo | **5.670** |
| Clientes envolvidos | 13.379 |
| Telefones em mais de uma unidade | 1.642 |
| Pior caso, num único número | **354 clientes** |
| Aniversariantes num dia típico | 557, com 549 mensagens — **8 não recebem** |
| **No ano** | **3.473 pessoas** |

#### A hipótese do número-lixo foi testada e CAIU

Os números mais repetidos não são famílias:

| Telefone | Clientes | Unidades |
|---|---:|---:|
| `+554733692043` | 354 | 2 |
| `+55` | 320 | 3 |
| `+554799999999` | 193 | 5 |
| `+5500000000000` | 61 | **20** |
| `+5548999999999` | 31 | 7 |

Código de país solto, dígito repetido, tudo zero. É o **D32** — telefone de
balcão e preenchimento de campo obrigatório — aparecendo por outro caminho.

O raciocínio era: duas pessoas só se anulam se fizerem aniversário no **mesmo
dia**; num telefone com dois clientes isso é cerca de 1 em 366, o que daria umas
quinze perdas no ano para os 5.670 telefones compartilhados, enquanto um telefone
com 354 clientes colidiria consigo mesmo o ano todo.

**Medido em 26/08/2026, está errado por duas ordens de grandeza:**

| | |
|---|---|
| Perda em telefones com 10+ clientes | **617** (18%) |
| Perda em telefones com menos de 10 | **2.817** (82%) |
| Telefones "grandes" | apenas 24 |

E os telefones inválidos pela forma são menos do que pareciam: 1.054 curtos
demais e 138 com assinante repetido, somando 1.192 números e 1.969 clientes.

Ou seja, **a maior parte da perda vem de telefones normais, com dois ou três
clientes**. O raciocínio do aniversário coincidente estava certo na aritmética e
errado na premissa: ele supõe que os dois cadastros são de **pessoas
diferentes**.

#### A causa real: `data_nascimento` está tomada de preenchimento automático

Medido em 26/08/2026, e não é nenhuma das duas hipóteses anteriores:

| | |
|---|---|
| Colisões que são a mesma pessoa (mesmo nome e data) | 148 |
| Mesma data, nomes diferentes | 49 |
| **Dia e mês iguais, ANO diferente** | **3.237 (94%)** |

Os exemplos dizem o resto:

```
Ulisses Galasse    1980-01-01 15:39:00     01/01 com HORA embutida
Wesley Moicano     0001-01-01 00:00:00     ano 1
Dimas Arnold       0000-11-30 10:03:19     data zero do MySQL
```

E a distribuição confirma: **12/12 tem 4.880 clientes** e 01/01 tem 2.012, contra
cerca de 660 por dia se fossem aniversários de verdade.

**As 3.434 "perdas" são ruído.** Não são pessoas sem parabéns — são cadastros com
data inventada colidindo entre si.

#### O problema que apareceu no lugar, e é maior

**Todo 12 de dezembro o sistema dispara 4.880 mensagens de parabéns**, quase
todas para quem não faz aniversário naquele dia. O mesmo em 01/01 e nas outras
datas concentradas.

Isso não é defeito de consulta: é dado ruim alimentando uma campanha. Cabe ao
D32/D41 e não à F1.4, mas a decisão é de negócio — mandar parabéns para quem tem
data plausível, ou parar de mandar até a base ser limpa.

#### O que isso desenterrou, e é urgente

`0000-11-30` em `clientes.data_nascimento` **quebra a carga no PostgreSQL**, que
não tem ano zero. Quando isto foi escrito, `scripts/pre-migracao-limpeza.sql`
**não cobria essa coluna** — cobria `agendas`, `agendas_fechamentos` e
`usuarios.data_contratacao`.

> **Fechado desde então**, e conferido em 30/08/2026: o commit `40eabe1`
> estendeu a limpeza para `clientes.data_nascimento` e `usuarios.data_nascimento`,
> anulando o que estiver abaixo de `1000-01-01`. São 2.395 clientes e 2
> colaboradores. O script anula em vez de inventar um ano, e a razão está escrita
> nele: *"não sei quando nasceu" é verdade; "nasceu em 2000" seria invenção — e
> invenção aqui manda parabéns no dia errado, que é pior que não mandar*.

Passou despercebido por um motivo que vale guardar: **a anonimização reescreve
`data_nascimento`**, então na base de desenvolvimento o defeito não existe. A
limpeza foi montada procurando coluna por coluna, e encontrou só o que já se
sabia.

`scripts/medir-datas-invalidas.sql` varre **todas** as colunas de data do
catálogo, em vez de procurar as conhecidas. Rodar antes do corte é barato;
descobrir no corte custa a janela de migração.

#### O que a escolha da linha decide, independentemente disso

Vão junto na projeção as credenciais de WhatsApp da unidade. Ou seja, ao escolher
a linha o banco decide **qual unidade manda a mensagem**, hoje por sorteio. Para
os 1.642 telefones que atravessam unidades, isso é real: quem dá os parabéns ao
cliente de Curitiba e Itajaí é aleatório.

**Ao resolver:** ver o **D24**/F1.4 para o conserto da consulta, e a tabela
`pessoas` da F1.2 para a resposta definitiva — telefone é chave de identidade
provisória, e a F1.2 já registrou por quê.

---

**Decidido pelo dono em 12/10/2026 (a resposta 114, opção a, a recomendada):** não mandar o parabéns nos dias de
preenchimento -- no dia em que os aniversariantes passam do dobro de um dia comum (na réplica, 12/12 e 01/01), a rotina
não manda. Quem faz anos de verdade nesse dia fica sem a mensagem.

#### O conserto (12/10/2026, o item PDV-E-CADASTRO)

`CronJobService::notificarAniversario` conta, antes de ler os aniversariantes do dia, os de cada dia do ano
(`ClienteRepository::contarAniversariantesPorDia`, a população da rotina: do Brasil, ativos, com a data, numa unidade
com as mensagens) e, no dia de preenchimento (`RegraDoParabens::eDiaDePreenchimento`), não enfileira nada. **O dia comum
é a mediana dos 366 dias** (o dia sem ninguém conta zero): o do meio, que os próprios dias de preenchimento não puxam --
a média subiria com eles. Na réplica, perto de 660, e o corte, 1.320: só 12/12 (4.880) e 01/01 (2.012) passam. **Com a
mediana em zero** (menos aniversariantes que a metade dos dias do ano), não há dia comum, e a rotina manda sempre: sem
isso, qualquer dia com um aniversariante passaria do dobro de zero. A rotina devolve quantos aniversariantes leu (zero
no dia de preenchimento), e recebe o dia da conta (a rotina chama sem ele: o de agora).

**Na cópia** (`franquiabv_dev`, MySQL, só contagens, 12/10/2026): a anonimização reescreve a data de nascimento, e nenhum
dia passa do dobro (o maior tem 794, a média é de uns 707): a rotina mandaria todo dia, como antes. Só a réplica tem o
12/12.

**Provado em processo, sem a rotina de envio** (`D41ParabensNoDiaDePreenchimentoTest`, novo, 6 casos), verdes no MySQL e
no PostgreSQL, em série: a regra com os números da réplica (só os dois dias passam; o dobro exato não "passa do dobro";
1.321 passa), o dia sem ninguém contando zero, e a mediana em zero sem dia de preenchimento; a contagem na população da
rotina (o de Portugal, o inativo e o sem data não contam, e a hora não importa), igual à que a rotina lê; e a rotina que
enfileira, chamada com o dia da conta num ano de um aniversariante por dia e mais cinco num dia: nesse dia ela não lê
ninguém, e num dia comum lê o do dia. A unidade do cenário tem as mensagens sem o texto do parabéns: nada vai à fila nos
dois casos, e a fila é conferida igual. Quem manda (`marketingPushOutros`) nunca é chamado.

**Falsificado** (`medir-mutantes-pdv-cadastro.py`, no rascunho do item: um mutante por vez, o arquivo devolvido e
conferido pelo sha256 depois de cada um, contra os 181 casos do filtro do item; o mesmo vermelho no MySQL e no PostgreSQL):

| mudança | vermelho |
|---|---|
| a rotina sem a conta do dia | 1: o dia de preenchimento |
| a média no lugar da mediana | 3: os números da réplica (1.321 deixa de passar), o dia sem ninguém e a mediana em zero |
| sem a guarda da mediana em zero | 1: a base pequena |
| o dia sem ninguém sem contar zero | 1: o dia sem ninguém |
| a contagem sem o país da rotina | 1: a população da rotina |

**O que fica -- o PARCIAL do título:** a identidade por telefone, que espera a tabela `pessoas` da F1.2 (o resto do corpo,
acima). E a base pequena (uma barbearia sozinha, com a conta só nos clientes dela, quando houver mais de um dono): o
acaso também passa do dobro, e a pergunta do corte foi ao lote (PDV-E-CADASTRO, o D41). Hoje a conta é a da rede inteira.

#### 13/10/2026 — NA BASE PEQUENA A ROTINA MANDA SEMPRE — pela resposta 166 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 166** (a opção "b", a recomendada): *"A regra do dia de preenchimento do parabéns (a 114) só vale quando o dia
comum (a mediana) tem pelo menos 30 aniversariantes; abaixo disso, a rotina manda sempre."*

##### O conserto

`RegraDoParabens::eDiaDePreenchimento` só diz "dia de preenchimento" com o dia comum de 30 aniversariantes ou mais (a
constante `MINIMO_DE_UM_DIA_COMUM`, o número do dono); abaixo disso, nunca, e a rotina
(`CronJobService::notificarAniversario`) manda como mandava antes da 114. A guarda da mediana em zero ficou dentro da nova.
O dobro e a mediana não mudaram. **Na rede inteira nada muda:** na réplica o dia comum é perto de 660; na cópia
(`franquiabv_dev`, só contagens, 13/10/2026), 708,5 (o maior dia, 794; o menor, 218).

##### Provado

`D41ParabensNoDiaDePreenchimentoTest` (de 6 a 9 casos), verdes no MySQL e no PostgreSQL, em série, em processo e **sem a
rotina de envio**: a regra com o dia comum de 29, 29,5, 5 e 1 (nenhum dia é de preenchimento, nem o de 1.000) e com o de
30 (61 passa, 60 é o dobro exato e não passa); e a rotina que enfileira, chamada com o dia da conta, numa unidade com as
mensagens sem o texto do parabéns (nada vai à fila, conferida igual): num ano de 30 aniversariantes por dia (10.980
clientes, num `INSERT` de várias linhas) e mais 40 num dia, nesse dia ela não lê ninguém e num dia comum lê os 30; num ano
de um por dia e mais cinco num dia -- a base pequena --, ela lê os seis desse dia (até esta data, nenhum). Quem manda
(`marketingPushOutros`) nunca é chamado.

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-r30.py`, no rascunho do item) | vermelho, no MySQL e no PostgreSQL |
|---|---|
| a regra de antes (só a mediana em zero desliga) | 2: o dia comum abaixo de 30; a base pequena na rotina |
| "mais de 30" no lugar de "pelo menos 30" | 2: o dia comum de 30; o dia de preenchimento na rotina (a base grande tem 30 por dia) |

**O que fica -- o PARCIAL do título:** a identidade por telefone, que espera a tabela `pessoas` da F1.2 (o resto do corpo,
acima). A base pequena está respondida.

### D39 — A view `v46` existe em produção e ninguém sabe para quê — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: executar em produção, na janela, o `DROP VIEW IF EXISTS v46`, que já está em `scripts/pre-migracao-limpeza.sql`.

Descoberta em 25/08/2026, comparando a homologação recém-carregada com o
`mysql.sql` do repositório: produção tem um objeto a mais, chamado **`v46`**.

Não é tabela — é uma **view**, e isso apareceu do jeito mais indireto possível.
Qualquer consulta a ela falha aqui com:

```
ERROR 1449: The user specified as a definer ('andre'@'%') does not exist
```

Ou seja, ela foi criada por um usuário que não existe na homologação, e o MySQL
recusa executá-la antes mesmo de olhar a definição. Em produção o `andre` existe,
então lá ela responde normalmente e nunca deu sinal de vida.

**Nenhum arquivo do repositório menciona `v46`** — nem código, nem script, nem
SQL. Pelo nome, cheira a sobra de versão: alguém congelou uma consulta antes de
um deploy e não voltou para apagar.

**O que impede de simplesmente apagar:** o código não é a única coisa que fala
com esse banco. Ferramenta de BI, planilha conectada, relatório externo — nada
disso apareceria numa busca no repositório. Antes de remover, é preciso
confirmar com quem opera.

**Por que importa na migração:** views não vêm no DDL gerado pela F1.3, que foi
construído a partir do `mysql.sql`. Se `v46` estiver em uso por algo fora do
código, ela some no corte da F1.8 e o sintoma aparece longe da causa — um
relatório que para de abrir, dias depois, sem ninguém ligar as duas coisas.

**RESPONDIDO em 25/08/2026:** o responsável confirmou — sobra de deploy antigo,
sem consumidor. O `DROP VIEW IF EXISTS v46` entrou em
`scripts/pre-migracao-limpeza.sql`, depois do `COMMIT`, porque DDL no MySQL
provoca commit implícito e estragaria a atomicidade dos UPDATEs acima.

Fica aberto só enquanto produção estiver congelada; é uma linha para executar,
não um problema para resolver.

**O que este débito ensina, e vale além dele:** o `mysql.sql` do repositório não
descreve produção inteira. Views, rotinas e eventos não estão lá, e a comparação
que expôs isso só existiu porque a homologação foi carregada a partir da réplica,
não do arquivo versionado. Antes do corte da F1.8, a mesma comparação precisa ser
feita para rotinas e gatilhos — ver **D37**.

**Ver a definição sem esbarrar no `DEFINER`:**

```sql
SELECT view_definition FROM information_schema.views
 WHERE table_schema = DATABASE() AND table_name = 'v46'\G
```

---

### D37 — Produção não está sob controle de versão — PARCIAL em 08/11/2026: o roteiro de publicar está escrito e ensaiado num contêiner; nunca rodou em servidor, e a produção segue sem git

Informado pelo responsável em 19/08/2026: o servidor de produção não tem git — o
que existe é cópia do código, com backup em drive.

Isso explica algo que vinha incomodando na redação de vários débitos. D1, D2 e D3
estão marcados como "corrigido no código, pendente em produção" desde o começo, e
o que faltava não era decisão: **é que não há caminho de entrega.** Deploy, aqui,
é cópia manual de arquivo.

#### O risco imediato, e é sério

O repositório nasceu de uma cópia de **julho de 2026**. Se alguém alterou
qualquer coisa direto no servidor desde então, essa alteração **não está no
repositório** — e um deploy por cima a apagaria, em silêncio, num sistema onde
defeito significa dinheiro errado.

**Nenhum deploy pode acontecer antes de saber se produção divergiu.**
`scripts/comparar-com-producao.sh` gera a impressão digital do código do servidor
para essa comparação. É somente leitura.

#### O que isso não bloqueia

Vale separar, porque a maior parte da remediação do Dia 0 **não depende de
deploy**:

| Passo | Precisa de deploy? |
|---|---|
| 1 — tirar os arquivos do webroot | **Não.** É `mv`, feito pelo script |
| 2 — apurar acesso nos logs | **Não** |
| 3 — criar o `local.php` | Não, é arquivo novo |
| 4 — código corrigido | **Sim** — e só depois da comparação |
| 5 — rotação de credenciais | Não, é painel de provedor |
| 6 — bloquear a rota no nginx | Não, é configuração de servidor |

Ou seja: **o que está exposto agora pode ser fechado hoje**, sem esperar deploy
nenhum. O passo 4 é o único que espera.

#### Ao resolver

Colocar produção sob git é o certo, e não é trabalho grande — mas exige decidir o
fluxo (quem faz merge, como se faz rollback, o que acontece com o `local.php`) e
comparar antes, para o primeiro commit registrar o que **está rodando**, não o
que se supõe que rode.

Enquanto não houver, todo deploy passa pela comparação de impressão digital, e
essa é a regra que evita perda silenciosa.

#### PARCIAL em 08/11/2026 -- o roteiro de publicar na produção (a fatia 129 da fila dos débitos, DEPLOY-TESTE-E-PUBLICAR)

Pelas respostas 200 (c), 201 (a) e 206 (a), a entrega na produção é por um roteiro de publicar, que não existia (o item 9
de "O que falta" da `docs/procedimento-de-corte-etapa-1.md`). **Escrito, e nunca rodado em servidor nenhum:**
`scripts/publicar-producao.sh`, no molde do `deploy-teste.sh`.

- `montar <commit> <sobre>`, na sua máquina: o `git archive` do commit; o `composer install` numa imagem do
  `docker/php/Dockerfile`, com o freio do teste (nada fora do `vendor/` muda); a build com `--base=/novo/` num contêiner
  `node:22-bookworm-slim`; a impressão digital do `<sobre>` (o que a produção roda: na janela da etapa 1, o `eaf1fca5`, a
  importação) pelo `comparar-com-producao.sh`; e recusa levar `local.php`, `.env`, `ensaios/` ou cópia anonimizada. Sai o
  pacote e o `.sha256`, e os comandos de copiá-lo.
- No servidor, como root: `levar` (o sha256, o disco, a release em `public_html.novo` com o dono root e o `local.php`
  `root:<grupo do pool>` 640); `trocar` (o da janela: o `data/` por `cp -aT`, os proxies do Doctrine apagados, as pastas de
  upload e o avatar por `mv -T`, as três imagens padrão comparadas, o `app.json` de lá, as graváveis do pool, a troca de nome
  com a árvore de antes inteira ao lado -- `public_html.antigo-<data>` na primeira vez, `public_html.anterior-<hora>` depois
  -- e a tela nova com a troca atômica do `trocar_front`; para antes do PHP-FPM, porque a janela tem os `.sql` do passo 7 e o
  vhost do 8 no meio); `entregar` (o das entregas: o `trocar`, o recarregar do PHP-FPM e a conferência sem escrever do passo
  10 -- o `verificar-local-php.php`, o 401 do `npsContabilizar` sem token e o `/novo/` com a build); e `voltar` (a liberação
  anterior, as pastas e a tela nova de volta; entre duas releases, com o PHP-FPM recarregado e a que sai em
  `public_html.devolvida-<hora>`; **na volta ao código antigo, na ordem de "A volta" do procedimento**: a que sai em
  `public_html.novo-<data>`, o nome de lá, e **sem** recarregar, que vem depois do `devolver-nomes` do item 4, cujo caminho
  ele imprime; e, se a troca parou nas pastas, só as pastas de volta, sem tocar o `app.json` de lá).
- **Antes de mexer, quem recusa o quê** (a primeira versão dizia "cada um recusa" tudo, e não era assim):

  |  | hora | manutenção | cron | rotinas | impressão |
  |---|---|---|---|---|---|
  | `levar` | sim | -- | -- | -- | -- |
  | `trocar`, `entregar` | sim | sim | sim | sim | sim |
  | `conferir` | sim | sim | sim | com a release ao lado | com a release ao lado |
  | `voltar` | -- | sim | sim | sim | -- |

  O `levar` é o passo 1, que vem antes da manutenção do 2; o `voltar` é o caminho de sair, a qualquer hora. **A hora:** depois
  das 00:05 e até a `HORA_LIMITE` da 206 (a), e a entrega que, pelo ensaio, não cabe (agora + `ENTREGA_MIN` > `HORA_LIMITE` −
  `VOLTA_MIN`). **A manutenção, carregada no `nginx -T`** -- e ela não existe (o item 10): **sem ela, nada troca**. **O cron:**
  o crontab do root sem linha ativa, o `/etc/cron.d` sem chamar o site (a linha que a recusa mostra sai sem o token, nas duas
  formas: `token=` e o `Authorization: Bearer` da forma nova), e o `CRONTAB_GUARDADO` **sem padrão**, desta noite (menos de 12
  horas), e numa entrega depois da etapa 1 **nunca** o `/root/crontab-antes-do-corte.txt` da janela, que a volta ao código
  antigo religa; na volta ao código antigo, só ele. **As rotinas:** conexão no `SHOW PROCESSLIST` do usuário do `local.php` da
  release ao lado, do da árvore no ar e, com o código antigo no ar, do `USUARIO_DB_NO_AR` (obrigatório: a leitura 10 diz que o
  `local.php` da release pode trazer outro usuário), ou do dump, ou um dump rodando, depois da espera (o sinal do passo 2).
  **A impressão:** a produção divergente da do `<sobre>`, listando cada arquivo (diferente, só no servidor, só no repositório)
  -- o que este débito pede ("nenhum deploy antes de saber se produção divergiu"); só passa o caminho que o dono aceitar num
  arquivo (`DIFERENCAS_ACEITAS`). E a volta ao código antigo só com o bloqueio do `migrar-senhas` no `nginx -T` (o item 2 de
  "A volta").

**O ensaio** (`scripts/ensaiar-publicar-producao.sh`, no `conferir-tudo.sh`; fora do CI, que não tem a imagem do app nem o
histórico): o `montar` de verdade com um `docker` de mentira (o `vendor/` daqui, sem rede), e o resto num contêiner
descartável, sem rede, com uma produção de mentira (a árvore do `eaf1fca5`, pastas de upload e `data/` inventados, fotos no
avatar, um `app.json` mudado) e dublês do nginx, do cron, do MySQL, do PHP-FPM e do relógio: 41 casos (eram 18; a
verificação da fatia acrescentou 23) -- as recusas do `levar` (às 23:50 e às 00:02, as duas metades da guarda de hora), do
`entregar`, **do `trocar`** (o subcomando da janela, que não tinha nenhuma) e **do `voltar`** (nas duas metades: sem a
manutenção, com o cron, com conexão, sem o bloqueio, com o crontab de uma entrega, com uma volta parada no meio); o guardado
sem padrão, velho e o da janela numa entrega; o `USUARIO_DB_NO_AR` e as conexões dele e do `local.php` no ar; a linha da
forma nova sem o token; a troca que para nas pastas e a volta dela, a entrega e a volta cronometradas (a volta ao código
antigo sem recarregar, com o `devolver-nomes` pela `public_html.novo-<data>`), e a segunda entrega sobre a primeira pelo
`trocar`, com a volta entre as duas recarregando.
**Os tempos daqui** (um pacote de cerca de 28 MB): desempacotar ao lado em cerca de 0,6 s, a entrega em cerca de 0,7 s e a
volta em cerca de 15 ms -- com 120 MB de upload e 40 MB de `data/`, e de novo com 340 MB e 300 MB (0,8 s e 19 ms): trocas de
nome e uma cópia do `data/`. **Não são os da produção:** o ensaio da sua 199 (a), no BVIP, dá os números que entram em
`ENTREGA_MIN` e `VOLTA_MIN`. Mutantes, um por vez, cada um vermelho: sem a manutenção, sem a hora no `levar`, sem a
impressão, `cp -a` no lugar do `mv -T`, o dono do pacote mantido, a volta sem as pastas, o `app.json` do repositório na volta
do meio, sem o cron, sem a conta da hora-limite, o `local.php` contado na impressão, e a espera sem as conexões. Os 19 da
verificação, cada um vermelho: o padrão do guardado de volta, sem a idade, sem a recusa do da janela numa entrega, sem a do
da entrega na volta ao antigo; sem exigir o `USUARIO_DB_NO_AR`, sem contá-lo, sem o `local.php` no ar; sem o bloqueio; a volta
ao antigo recarregando; o `devolver-nomes` pela árvore no lugar; o nome `devolvida-` na volta ao antigo; o `trocar` sem as
conferências; o `voltar` sem a manutenção, sem o cron, sem as rotinas (em cada metade), sem a recusa da volta parada; a hora
sem o "depois das 00:05"; e o `sed` de antes, que deixava o `Bearer` inteiro.

**O que falta, e por isso PARCIAL:** a produção segue sem git (o "Ao resolver" de cima); o roteiro nunca rodou num
servidor; **a página de manutenção** (o item 10), sem a qual ele recusa; o ensaio no BVIP (a 199 (a)), que dá os tempos
de verdade; os nomes a medir da leitura 7 (o serviço do PHP-FPM, o usuário e o grupo do pool), o usuário do banco do código
antigo (a leitura 10, o `USUARIO_DB_NO_AR`) e a raiz da tela nova do vhost novo (o item 5), que ele pede como variáveis e não
adivinha; e o "com ou sem as dependências de desenvolvimento" do
composer, que segue a medir (ele instala como o teste, com elas). O dono revisa o roteiro antes da janela.

### D36 — `app.json` é configuração morando em arquivo — PARCIAL em 20/10/2026: um gravador só, e a tela nova recria o arquivo

Levantado em 19/08/2026, ao terminar a F1.5.

`public/app.json` tem **77 bytes** e guarda quatro campos: versão do aplicativo
(major, minor, patch) e o liga-desliga do push.

```json
[{"push":"inativo","versao_major":"2","versao_minor":"0","versao_patch":"8"}]
```

É lido por `/api/versao/app` a cada abertura do aplicativo, e escrito por duas
telas de administração. **Não é upload de usuário — é configuração.**

**Por isso não deve ir para armazenamento de objetos.** Seria uma ida à rede, a
cada abertura de app, para buscar 77 bytes. O lugar certo de configuração é o
banco, que já é replicado e transacional.

#### Destravado: ninguém busca o arquivo pela URL

A dúvida era se o aplicativo buscava `/app.json` direto — o arquivo é servido,
já que a regra de bloqueio do nginx cobre `.txt`, `.log` e `.sql` mas não
`.json`. Medido em 14 dias de log de produção:

| | |
|---|---:|
| Requisições diretas a `/app.json` | **0** |
| Requisições a `/api/versao/app` | 11.032 |
| Requisições a qualquer `/api/` | 236.785 |

As duas últimas linhas são o que dá valor à primeira: sem elas, o zero poderia
significar log errado em vez de ausência de uso — e foi exatamente isso que
aconteceu na primeira tentativa de medir, quando o glob cobria só `access.log*`
e o site tem log próprio por vhost.

**Conclusão:** o arquivo pode sair do disco e virar configuração no banco, sem
período de transição e sem risco para as versões antigas do aplicativo.

**Prioridade baixa mesmo assim, pelo modo de falha.** Sumindo numa promoção,
`selecionarVersaoAction` devolve `sucesso: false` e o aplicativo fica sem
informação de atualização — degradado, não quebrado, e um administrador refaz em
trinta segundos pela tela. Comparar com o **D33**, onde o que some é contrato e
certificado, que ninguém refaz.

**Correção de registro (03/10/2026):** o "refaz em trinta segundos pela tela" está errado. As
duas telas que escrevem o arquivo só abrem se ele existir: o `file_exists` cerca a ação inteira
(`module/ISConfiguracao/src/Controller/AppConfiguracoesController.php:37` e `:85`), e sem o arquivo
as duas respondem 404 (`:78` e `:122`). Sumindo o arquivo, ninguém o refaz pela tela -- só no
disco. Achado pelo desenho da Versão do aplicativo (`docs/f3-telas-restantes/tela-versao-app.md`,
Dnovo-4), cuja fatia 1 faz a tela nova recriar o arquivo; e o **D354** registra as outras duas formas
de o arquivo ficar errado com a tela dizendo sucesso (o campo vazio publica 0.0.0, e um byte
inválido o esvazia).

**Ao resolver:** tabela de configuração no banco e trocar os três pontos de
código. Como a medição mostrou uso zero da URL, não é preciso continuar servindo
o arquivo.

#### PARCIAL em 20/10/2026 -- a fatia 23 da fila das telas que faltam, VERSAO-DO-APLICATIVO

**O que mudou:** o arquivo passou a ter um gravador só, `ISConfiguracao\Service\RegistroDoAplicativo` -- a versão e o Push
gravam por ele, sob a trava e por `rename` (o D354) --, e a tela nova (Configurações › Aplicativo › Versão, sobre a
`/adm/api/aplicativo/versao`) o recria quando ele falta: diz "Hoje o aplicativo recebe “sem informação de versão”", e publicar refaz
o registro com o `push` "inativo". O Dnovo-4 do desenho fica aqui: o "refaz pela tela" passou a ser verdade pela tela nova; as duas
antigas continuam 404 sem o arquivo, de propósito. Quem mudou e quando fica num histórico só de acréscimo,
`data/aplicativo/historico.jsonl`, fora de `public/` e ignorado pelo git (o D548).

**O que falta (o corte, como estava decidido):** a tabela de configuração no banco. Os pontos de código passaram de três para dois --
o gravador e o leitor congelado do `/api/versao/app` (`AplicativoController::selecionarVersaoAction`) --, e o histórico vai junto na
importação. Nenhum esquema mudou nesta fatia, e nada entrou no passo 0.

### D35 — Adiada a migração do visagismo para a abstração

Decisão do responsável em 19/08/2026: não migrar o `visagismo` agora.

São **7 pontos de escrita** em 4 arquivos (`ISApi` e `ISCadastro`, serviço e
controller), num fluxo que fala com API externa de geração de imagem — mais
superfície e mais risco que os outros, e nenhum teste automatizado cobrindo.

**O que já foi feito e não espera:** o furo de segurança do D34 está corrigido —
`public/visagismo` entrou na regra de nginx que impede execução de PHP em
diretório de upload. Isso era o urgente; a migração para a abstração é
arrumação.

**Consequência de adiar:** aquele diretório continua gravando no disco local
mesmo depois de os outros passarem para armazenamento de objetos. Numa promoção,
as imagens de visagismo somem junto — soma-se ao D33, não é um problema novo.

**Ao resolver:** mesmo padrão dos demais — `Armazenamento::padrao()->guardar()`
com caminho lógico `visagismo/...`. O cuidado extra é que o caminho aparece
**na resposta da API** (`VisagismoController`, `VisagismoService`), então o
aplicativo recebe a URL montada — trocar o destino sem trocar a URL devolvida
quebra a exibição no app.

### D33 — Arquivos ficam fora do failover — PARCIAL, lido em 05/10/2026 — PARCIAL em 08/11/2026: a foto do colaborador sai pelo `Armazenamento` nas telas e na API; seguem à mão os banners da API e as capas dos assistentes; a agenda da V4 manda só o nome; a virada pede a `url_publica`

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: contratar o destino, migrar o `app.json`, trocar as URLs de leitura (9 `.phtml` e a API), subir os 346 MB e virar o driver.

Decisão do responsável em 19/08/2026: o armazenamento de objetos fica no servidor
local por ora, com a distribuição em nuvem adiada.

**Consequência:** a camada de arquivos não é coberta pelo failover automático da
F1.7. Numa promoção, o nó novo sobe funcionando e **sem** contrato, certificado,
banner, material do VIP Class e foto de colaborador — 346 MB em 1.146 arquivos,
com o caminho gravado no banco apontando para o vazio.

É lacuna conhecida e aceita, não descoberta. A diferença entre as duas é
justamente este registro.

**O custo de resolver já está pago.** A abstração `Armazenamento` está pronta e
testada contra MinIO, e o driver fala o protocolo S3 — que MinIO, Cloudflare R2,
Backblaze B2 e o Google Cloud Storage em interoperabilidade entendem. Virar a
chave é editar `config/autoload/local.php`, sem tocar em código.

#### O que será necessário implementar, em ordem

Registrado a pedido, para o trabalho futuro não depender de memória.

**1. Contratar e configurar** — escolher o destino (`f1-5-armazenamento.md`
compara custo e durabilidade; para 346 MB são centavos por mês), criar o balde e
as credenciais, e preencher a seção `armazenamento` em
`config/autoload/local.php`. Nenhuma linha de código.

**2. Terminar de migrar os pontos de escrita.** Feito: `arquivos`, `banners`,
`assistentes`, `vip-class/capas`, `vip-class/certificados`. Falta:

| Onde | Pontos |
|---|---:|
| ~~`vip-class/materiais`~~ | feito |
| ~~`layout/img/avatar`~~ | feito |
| `app.json` | 3 |
| `visagismo` | 7 — adiado, ver **D35** |

Sobre o avatar: só o **final** passa pela abstração. O `cache/`, onde mora o
arquivo entre o envio e o recorte, fica local de propósito — é rascunho que morre
em segundos, e mandá-lo para a rede seria pagar latência à toa.

**3. Resolver as URLs de leitura — é o item que costuma ser esquecido.**
Gravar no lugar novo não adianta se a tela continua pedindo o antigo. Hoje o
caminho é montado à mão em pelo menos **9 arquivos `.phtml`** e no retorno da
API. Todos precisam passar a usar `Armazenamento::padrao()->url()`, que já
existe e já devolve o endereço certo em cada driver.

*Nota de 05/11/2026 (a fatia 79; o D712):* a VIP Class do aluno passou: a capa do cartão pelo `url()`, e o fundo do
certificado e o material lidos pelo `Armazenamento` (o material vai ao endereço direto do bucket quando há a `url_publica`).
Seguem escritos à mão a capa e o certificado da administração e a capa do relatório da VIP Class (o D712).

*Nota de 08/11/2026 (a fatia 130, PREPARO-DO-PHP-E-DO-ARMAZENAMENTO):* a nota de 05/11 ficou velha -- o D712 fechou a VIP
Class inteira. **A foto do colaborador passou**, nos sete pontos que a montavam à mão (o `grep` por `img/avatar`; a triagem
listava seis, e o sétimo é o endereço que a tela recebe depois do recorte): o topo do ERP (`layout.phtml`), a lista e a foto do
usuário (`usuarios/index.phtml` e `usuarios/editar-foto.phtml`) e o `UsuarioService::definirImagem` pelo `url()`; e as três
respostas da API do aplicativo -- a agenda do cliente e o consumo dele (`AplicativoController`) e a lista de colaboradores da
unidade (`UnidadeRepository::getUnidadeColaboradoresServicos`) -- pelo `urlCompleta($caminho, $prefixoLocal)`, novo no
`Armazenamento`: **no disco, a URL sai byte a byte a de sempre** (o prefixo que cada resposta colava, inclusive as duas barras
de `https://franquiabv.com.br//layout/img/avatar/...` da lista de colaboradores), e no bucket é o `url()` dele. Os snapshots do
contrato só conferem o tipo (`"string"`): quem prende os bytes é o `D33EnderecoDaFotoPeloArmazenamentoTest` (9 casos, nos dois
bancos: no disco, a API igual à fórmula antiga e as telas pedindo ao site; no bucket com a `url_publica`, as três telas, as três
respostas e a foto recortada apontando para ele). Nas telas, o `src` passou a sair pelo `escapeHtmlAttr`: o mesmo endereço para o
navegador, e a coluna `imagem` deixou de ir crua ao HTML. **Seguem escritos à mão:** a URL dos banners na API do aplicativo
(`AplicativoController::bannersSelecionarAction`, `$url . "banners/"`), que o `BannersPelaApiTest` prende ao `sistema.url` de
propósito desde a fatia 22 -- passá-la ao `urlCompleta` é a mesma troca, e pede reescrever aquele caso; as capas dos assistentes
(`assistentes-usuarios/index.phtml:29` e `assistentes-admin/capa.phtml:38`), que esperam o dono com as fatias 87 a 89; e o
`app.json` (o D36). Os passos 1, 4 e 5 (contratar, subir os arquivos e virar o driver) são do dono e do corte.

*Na correção da fatia 130 (08/11/2026), duas coisas que a nota de cima não dizia:* **(1) a virada passa a pedir a `url_publica`**
(ou um bucket de leitura pública). Antes, a foto era sempre pedida ao site; agora as telas e a API pedem o endereço ao
`Armazenamento`, e sem a `url_publica` o `ArmazenamentoS3::url()` devolve o endereço cru do bucket, que o navegador e o aplicativo
pedem sem assinatura -- com o bucket privado, o normal no R2 e na AWS, as fotos (no `franquiabv_dev`, 1.498 usuários com
`padrao*` e 934 com `avatar_*`) somem nas três telas e no aplicativo. Está no passo 0 de "Migrar os 346 MB" em
`docs/f1-5-armazenamento.md`, com os três `padrao*` versionados em `public/layout/img/avatar/`, que também precisam subir, e no
comentário da classe; que o endereço cru dê 403 depende da política do bucket, e não foi medido. **(2) A agenda da API nova
fica de fora:** `GET /api/v4/clientes/:id/agenda` (`V4\ClientesController::agendaAction`) devolve só o nome do arquivo
(`colaboradorImagem`, `avatar_12.jpg?abcd`), e quem a consome monta o endereço a partir do site -- o `grep` por `img/avatar`
não a acha, porque ela não tem o caminho. Trocar pede mudar o contrato (o snapshot `v4-clientes-agenda-autenticado.json`), e
nenhum aplicativo fala o `/api/v4` ainda (o D59): o caminho, quando o aplicativo novo for escrito, é um campo com o endereço
pronto pelo `urlCompleta`, como a convenção da API manda. Os dois ficam neste débito até lá.

O caminho que aparece **na resposta da API** é o mais sensível: o aplicativo
móvel recebe a URL pronta, e há versões antigas em campo (**D5**) que não podem
ser atualizadas junto. A URL antiga precisa continuar funcionando durante a
transição — na prática, o nginx segue servindo `public/` como está, e só depois
de o parque renovar é que aquele caminho sai.

**4. Subir os 346 MB existentes**, preservando o caminho lógico, e conferir
contagem e resumo de cada arquivo antes de virar a chave.

**5. Virar o `driver` no `local.php`.**

**6. Manter o disco local intacto por algumas semanas.** É o que torna a virada
reversível: se algo escapou, o arquivo ainda está lá.

**7. Backup do novo destino.** Armazenamento de objetos não se faz sozinho —
ativar versionamento ou replicação no provedor, e incluir no plano de
contingência. Hoje os arquivos dependem do `sistemacompletofbv.sh`, diário.

#### O que NÃO será necessário

Vale registrar para dimensionar: **nenhuma mudança nos serviços já migrados.**
A abstração existe justamente para isso, e o driver S3 já está escrito e testado
contra MinIO. Os itens acima são configuração, URLs de leitura e cópia de
arquivo — não reescrita.

**Enquanto não resolver:** o backup dos diretórios de `public/` passa a ser parte
do plano de contingência, não conveniência. Hoje o `dumpv3.sh` cobre o banco de
hora em hora; os arquivos dependem do `sistemacompletofbv.sh`, diário.

### D31 — Logins e CPFs duplicados em produção — PARCIAL em 08/10/2026: os logins e e-mails repetidos vão ao passo 3 do corte; os dois ativos e os CPFs são do dono

Levantado em 19/08/2026, na coleta de produção. É a resposta que faltava para o
D29, e ela é pior que o esperado.

| Coluna | Valores repetidos | Linhas envolvidas |
|---|---:|---:|
| `usuarios.login` | 159 | **389** |
| `usuarios.email` | 144 | 316 |
| `clientes.cpf` | 348 | **1.483** |
| `unidades.hash` | 0 | 0 |

O `hash` estar limpo é a boa notícia: era o mais perigoso, porque autentica as
rotas `/api/unidade/vendas*`.

> **ESTES NÚMEROS NÃO PODEM SER REMEDIDOS NA CÓPIA LOCAL, e quem tentar vai
> achar zero.** Medido em 31/08/2026: a anonimização reescreveu **todas** as três
> colunas para valores únicos.
>
> | Coluna | Linhas | Distintas |
> |---|---:|---:|
> | `usuarios.login` | 2.427 | **2.427** |
> | `usuarios.email` | 2.427 | **2.427** |
> | `clientes.cpf` | 242.586 | **242.586** |
>
> Zero duplicatas locais **não é evidência de conserto** — é evidência de que a
> anonimização funcionou. Os 159, 144 e 348 acima são números de produção, de
> 19/08/2026, e só a produção pode confirmá-los ou desmenti-los.
>
> Isto está escrito aqui porque eu mesmo caí nele em 31/08: fui remedir os
> logins duplicados para avaliar o **D72**, achei zero, e por um instante li
> aquilo como "o problema não existe".

#### Apurado em 19/08: o login NÃO é falha de autenticação

Eu havia levantado a hipótese de que os logins duplicados quebrassem a
autenticação, já que o Laminas usa `login` como propriedade de identidade. A
segunda coleta desmente:

| | |
|---|---:|
| Logins com **mais de um usuário ativo** | **0** |
| Linhas ativas entre as 389 | 1 |
| Linhas inativas | 388 |

Ou seja: toda duplicata de login envolve colaborador **desligado**. A
autenticação hoje é determinística, e o problema é de higiene de cadastro, não
de segurança. **Retiro a gravidade que atribuí.**

Fica um risco condicional: reativar um colaborador antigo cujo login coincida
com o de alguém ativo cria a duplicata entre ativos, e aí vira falha de verdade.
A restrição de unicidade é o que impede isso.

#### O CPF é o oposto: quase todos ativos

| | |
|---|---:|
| Clientes em CPF duplicado | 1.483 |
| **Ativos** | **1.451** |

Aqui não é higiene: são 1.451 clientes ativos que quase certamente são **cadastro
duplicado da mesma pessoa**, cada um com sua pontuação e seu histórico. Fundir
exige somar saldo e preservar as duas trilhas.

**Consequência imediata para o D29:** as restrições de unicidade **não podem ser
criadas** na F1.3 como estava planejado. O `CREATE UNIQUE INDEX` falharia. A
ordem passa a ser: apurar, limpar, e só então restringir.

**Ao resolver:** decidir caso a caso o que fazer com cada duplicata — e isso é
decisão de negócio, não de banco. Dois colaboradores com o mesmo login podem ser
a mesma pessoa cadastrada duas vezes, ou dois cadastros que precisam de logins
distintos. O mesmo vale para os 1.483 clientes com CPF repetido, que provavelmente
são cadastro duplicado da mesma pessoa — e aí a fusão precisa somar pontuação e
preservar histórico dos dois lados.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 8): a opção (b), "Renomear o login do
inativo de cada par, e baixar a caixa de todos" -- ninguém ativo muda de login, e o par com dois ativos
fica com o dono, um a um. Entrou no roteiro do corte: no passo 3, os quatro `UPDATE` do fim de
`scripts/medir-logins-com-maiuscula.sql` (o inativo de cada par ganha `.inativo<id>` no login, e no
e-mail no par de e-mails; depois a caixa de quem ficou sem par baixa), e a listagem 4c, no 5 de "O que
hoje bloqueia", dá antes da janela os ids de quem tem mais de um ativo no mesmo login ou e-mail.
Ensaiado em `scripts/ensaiar-sql-do-passo-3.php`, no 8.4.11 e no 8.0.46, com usuários inventados; o
par é só a caixa -- o par que só difere pelo acento não é tocado (a revisão de 06/10/2026, ver o D349).
**O que a decisão não alcança:** o repetido EXATO de login -- uma grafia só, que a conta de 19/08 não separou dos
pares -- não é par de caixa, e fica como está: não piora com a caixa baixa, mas impede o índice único do
D295; a pergunta de estender o renomear a ele está no lote. E os 1.483 clientes com CPF repetido
continuam do dono, caso a caso.

**Decidido pelo dono em 07/10/2026** (pergunta 32 da página do lote): a opção (a), "A mesma regra para
eles" -- "Sobra só o caso de dois ativos, que já é seu, e o índice fica possível depois da janela."
Entrou no roteiro do corte em 08/10/2026: os dois `UPDATE` do renomear de
`scripts/medir-logins-com-maiuscula.sql` agrupam pelo minúsculo byte a byte com "mais de uma linha"
(`COUNT(*) > 1`), e não mais "mais de uma grafia" -- o inativo do repetido exato, de login e de e-mail,
ganha `.inativo<id>` como o do par de caixa --, e o e-mail vazio fica fora do grupo (não é e-mail
repetido; sem o filtro, o inativo de e-mail vazio virava `.inativo<id>` sozinho). A listagem nova 3b
conta os repetidos exatos por estado, a 4 dá também os ids deles (com a coluna `grafias`), e a 4b conta o
renomear dos dois tipos. O passo 3 e o 5 de "O que hoje bloqueia" do procedimento dizem isso.

**Provado** em `scripts/ensaiar-sql-do-passo-3.php`, no 8.4.11 e no 8.0.46 (um contêiner descartável,
sem porta publicada, apagado no fim): dez usuários inventados a mais -- o repetido exato com um ativo e um
inativo, o de dois ativos com um inativo, o de dois inativos, o e-mail exato com um inativo, o de dois
ativos e um inativo de e-mail vazio --; os quatro `UPDATE` mudam 8, 2, 5 e 2 linhas (eram 4, 1, 5 e 2) e
nada na segunda vez, e cada usuário fica como o ensaio escreve; depois deles, o que ainda se repete pelo
minúsculo byte a byte são só os ativos da 4c (106/107 e 125/126 no login, 116/117 e 130/131 no e-mail); e
o índice único do login, na chave da entrada do PostgreSQL (`CAST(LOWER(login) AS BINARY)`, a do D349),
é recusado com eles e nasce sem eles. **Falsificado**, um mutante por vez em cópias dos roteiros (o
`--scripts` do ensaio; os arquivos de verdade não mudaram), com o mesmo vermelho nos dois MySQL: o
roteiro de 06/10/2026 inteiro (6 vermelhos), o renomear do login de volta a "mais de uma grafia" (4), o do
e-mail (3), o do e-mail sem tirar o vazio (2 -- 8, 3, 5 e 2, e o e-mail vazio virando `.inativo132`) e a
4b do login (1).

**A revisão de 08/10/2026: a 3b e a 4 passaram a ser conferidas.** O ensaio lia a 4b, a 4c e o efeito dos
`UPDATE`, e não a 3b, que é a única medida dos repetidos exatos que vale (a cópia os apaga) e dá o número
que vai a você antes da janela -- apagá-la, agrupá-la pelo minúsculo em vez da grafia exata ou deixar o
e-mail vazio entrar nela não deixava nada vermelho. Agora ele exige a 3b (login: 5 grupos, 12 linhas, 8
inativas, 1 grupo com dois ativos; e-mail: 3, 6, 3 e 1) e a 4 inteira (os ids, o estado de cada um e a
coluna `grafias` de cada grupo). E ganhou oito usuários inventados, os grupos que a 3b e o renomear contam
de jeitos diferentes: o de caixa e exato juntos ("Nina" ativo, "nina" e "nina" inativos -- três linhas
no renomear, duas na 3b), o exato de um ativo com dois inativos, e o e-mail exato só de inativos. Os quatro
`UPDATE` passam a mudar 12, 4, 6 e 2 linhas, e o resto do ensaio dá o mesmo. **Falsificado**, um mutante
por vez em cópias, com o mesmo vermelho no 8.4.11 e no 8.0.46 (o contêiner descartável, apagado no fim):
a 3b com o e-mail vazio, a 3b pelo minúsculo, sem a 3b e a 4 sem a coluna `grafias` (1 cada); e os de
antes, com os dados novos: o roteiro de 06/10/2026 inteiro (8), o renomear do login por "mais de uma
grafia" (4 -- ele ainda pega o grupo da Nina, que tem duas grafias, e deixa o edu, o joel, o zeca e o
otto), o do e-mail (3) e o do e-mail com o vazio (2).

**Uma sobra de texto no código, que este item não muda:** o docblock de
`UsuarioRepository::selecionarPorLogin` (`module/ISConfiguracao/src/Entity/UsuarioRepository.php`) diz que
o par de caixa "e o repetido exato, de antes do D294" existem, "e o que fazer com ele e pergunta do
dono". Desde a resposta 8 e a 32, o inativo dos dois é renomeado no passo 3, e só os dois ativos (a 4c)
continuam seus; o comportamento que o comentário descreve (devolver até dois, e a recusa com a frase do
par) segue certo e necessário até a janela, e depois dela para os ativos da 4c. Fica para quem mexer no
arquivo: "o que fazer com ele foi decidido (respostas 8 e 32): o inativo e renomeado no passo 3 do corte,
e o de dois ativos e do dono". **Feito em 11/10/2026** (o item RESPOSTAS-SEM-RODADA), com esse texto e
"até a janela, e depois dela para os dois ativos" na frase seguinte; só o comentário, e o título continua
PARCIAL pelos dois ativos e pelos CPFs, que são do dono. **A verificação do mesmo dia achou três irmãs**
com a mesma sobra ("o que fazer com cada par e pergunta do dono"), pelo `grep` de `D31` e `D295` em
`module/` e `tests/`: o docblock da frase do par (`RegraDoUsuario`), o de `UsuariosController::validarCampoUnico`
e o da classe `LoginSemCaixaTest`. As três dizem agora que os pares foram decididos pelas respostas 8 e 32 --
o inativo renomeado no passo 3, e o de dois ativos com o dono, um a um. Só comentário.

**Medido na cópia** (só `SELECT` de contagem, sem login nem e-mail, em 08/10/2026): 0 logins e 0 e-mails
repetidos exatos, nos 2.432 usuários (371 ativos), e 0 e-mails vazios. **Não vale como medida**, pelo que
está acima: a anonimização reescreveu as duas colunas para valores únicos. Na produção, a 3b dá o número
antes da janela (o 5 de "O que hoje bloqueia"). Continuam do dono: o login ou o e-mail com dois ativos (a
4c, um a um) e os 1.483 clientes com CPF repetido.

### D32 — Números de telefone de balcão contaminam a base — PARCIAL, lido em 05/10/2026 — PARCIAL em 12/10/2026: o cadastro recusa o número em 10 cadastros (a resposta 113); falta a lista dos números de balcão, na produção — PARCIAL em 13/10/2026 (a resposta 167)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: classificar manualmente os números de balcão como não identificáveis antes de o telefone virar chave; no cadastro, recusar número reusado acima de um limite.

Levantado em 19/08/2026, na coleta de produção.

Um único número de telefone aparece em **355 cadastros de cliente**. Outro em
320, outro em 193, outro em 176. A distribuição continua com 70, 61, 47, 46, 32,
31.

Isso não é telefone de família. É o número da própria loja, ou um preenchimento
padrão que a recepção usa quando o cliente não informa o dele.

#### Dimensão real, medida em 19/08

| | |
|---|---:|
| Clientes em número repetido | 13.977 |
| Números distintos repetidos | 5.944 |
| Clientes nos números com **10 ou mais** cadastros | **~1.590** |

A distinção importa: 5.944 números repetidos para 13.977 clientes significa que a
**maioria é repetição de dois ou três**, que é telefone de família legítimo. O
problema real são os ~1.590 clientes presos nos números de balcão — 0,65% da
base, não 5,8%.

#### Metade se resolve por regra; a outra metade, não

A função `normalizar_telefone` passou a descartar número cujo assinante é um
**dígito repetido**, porque isso é preenchimento e não telefone. Testado contra
os oito piores casos reais:

| Número | Cadastros | Resultado |
|---|---:|---|
| `+554733692043` | 355 | descartado (fixo, não é identidade) |
| `+55` | 320 | descartado |
| `+554799999999` | 193 | descartado (dígito repetido) |
| **`+5547991744313`** | **176** | **ACEITO** |
| `+554730194612` | 70 | descartado (fixo) |
| `+5500000000000` | 61 | descartado |
| `+5548999999999` | 31 | descartado |
| `+5548012345678` | 10 | descartado |

Sete dos oito saem sozinhos. **O oitavo é o que prova que a lista manual é
obrigatória:** `+5547991744313` é um celular perfeitamente válido — provavelmente
de um gerente ou da própria unidade — digitado em 176 cadastros. Nenhuma regra de
formato o distingue de um cliente real.

**Impacto direto sobre a identidade do cliente** (`f1-2-identidade-do-cliente.md`):
o telefone é a chave escolhida, e sem uma lista de exclusão desses números,
**355 pessoas viram uma só** no momento em que a identidade de plataforma for
criada. Elas passariam a compartilhar histórico, pontuação e consentimento.

**Ao resolver:** antes de o telefone virar chave, levantar todo número com mais
de N cadastros (`coletar-producao-2.sql`, seção 2), classificar manualmente
quais são de balcão, e marcá-los como **não identificáveis** — o cadastro existe,
o telefone serve para contato da unidade, mas não gera vínculo com uma pessoa da
plataforma.

Vale também impedir a entrada de novos: validação no cadastro que recuse número
já usado acima de um limite.

**Decidido pelo dono em 12/10/2026 (a resposta 113, opção a, a recomendada):** recusar o número que já tem 10 cadastros
-- "Este telefone já está em 10 cadastros. Informe o telefone do próprio cliente." --, no ERP e na tela nova; a família
de até 10 continua entrando como dependente. O aplicativo já recusa qualquer repetido, e não muda.

#### O conserto (12/10/2026, o item PDV-E-CADASTRO): o cadastro novo, nas três portas

- **A regra:** `RegraDoCadastroDeCliente::telefoneEmMuitosCadastros` (o teto, `CADASTROS_POR_TELEFONE`, 10, e a frase
  do dono, `FRASE_DO_TELEFONE_EM_MUITOS_CADASTROS`) e `ClienteRepository::contarClientesPorTelefone` -- os ativos com o
  número, pelo `telefone_sem_mascara`, fora os cadastros do sistema: a mesma pergunta do repetido, que é quem faz um
  cadastro novo virar dependente.
- **O modal do ERP** (`ClientesController::validarFormulario`, o da agenda e o do PDV): com 10, recusa com a frase, com
  ou sem o `responsavel_id`, e sem o `telefone_existente` -- o modal só oferece "cadastrar como dependente" com ele.
- **A página "Novo cliente"** (`validarTelefone`): ela já recusava todo repetido ("Telefone já cadastrado"); com 10, diz a
  frase do teto.
- **A API da tela nova** (`Adm\ClientesController::conferirRepetidos`): 409 com a frase e `detalhes.motivo =
  'telefone_em_muitos_cadastros'`, sem o dono, ANTES do `responsavelId` (que pulava a conferência do telefone). A tela
  (`formularioDoCliente.ts`, `errosDaRecusa`) põe a frase no campo do telefone, e não oferece o dependente
  (`donoDoTelefone` só lê o `telefone_repetido`).
- **A alteração não passa pelo teto:** quem já divide o número salva sem trocá-lo. A troca para o número de outro é
  recusada a quem não é dependente (o D417) -- e só a ele: o dependente gravado troca o telefone sem conferência (a
  resposta 27), e pode passar para o número em 10 (a verificação, abaixo).

**Provado** em `D32TelefoneEmMuitosCadastrosTest` (novo, 12 casos), verdes no MySQL e no PostgreSQL, em série: a regra e
a frase; a contagem (o inativo não conta, e o cadastro do sistema só conta sem o filtro); pelo modal, com 10, a recusa
sem a oferta e sem nada gravado, e o reenvio como dependente também recusado; com 9, a oferta e o décimo entrando como
dependente; 9 ativos com 3 inativos e o "SEM CADASTRO" ainda oferecem o dependente; um dos 10 edita sem trocar o número;
pela página, a frase do teto com 10 e a de sempre com 9; pela API, o 409 com o motivo e sem o dono, com e sem o
`responsavelId`, e com 9 o 409 de sempre e o décimo cadastrado com o dono. No front, `formularioDoCliente.teste.ts` (o
409 do motivo novo é do campo do telefone; o dono não vem dele) e `ClientesLigada.teste.tsx` (um caso novo: a frase no
telefone, com o foco, e sem o botão do dependente).

**Falsificado** (`medir-mutantes-pdv-cadastro.py`, no rascunho do item: um mutante por vez, o arquivo devolvido e
conferido pelo sha256 depois de cada um, contra os 181 casos do filtro do item; o mesmo vermelho no MySQL e no PostgreSQL):

| mudança | vermelho |
|---|---|
| a API sem o teto | 2: a API com 10, sem e com o `responsavelId` |
| a API com o `responsavelId` pulando o teto (a ordem de antes) | 1: a API com 10 e o `responsavelId` |
| o modal do ERP sem o teto | 2: o modal com 10, sem e com o `responsavel_id` |
| a página "Novo cliente" sem a frase do teto | 1: a página com 10 |
| o teto em "mais de 10" | 6: a regra e as cinco recusas com 10 |
| a contagem com os inativos | 2: a contagem e o de 9 ativos com 3 inativos |
| a contagem com os cadastros do sistema | 2: a contagem e o de 9 ativos com o "SEM CADASTRO" |
| a tela nova sem o motivo novo (`errosDaRecusa` só com o `telefone_repetido`; no `vitest`, os dois arquivos) | 2: o 409 do motivo novo no `formularioDoCliente.teste.ts` e a frase no telefone no `ClientesLigada.teste.tsx` |

#### A verificação (12/10/2026): duas portas da API sem caso, e a do dependente

A verificação do item achou o que os casos não prendiam, e o que o comentário do teto afirmava errado:

- **A API na alteração.** O `conferirRepetidos` da API também roda no `PUT`, e o teto só não vale ali pelo `$atual ===
  null`; o "um dos 10 edita sem trocar o número" ia só pelo modal. Sem o `$atual === null`, o `PUT` de qualquer um dos 10
  daria 409 do teto, e a tela nova deixaria de editar quem já está num número de balcão. Caso novo:
  `testAApiAlteraUmDosDezSemTrocarONumero`.
- **O cadastro do sistema fora da conta, na API.** A API passa a lista dela (`AVULSO`, `SEM_CADASTRO`) na própria
  chamada, e o ERP usa outra (`ClientesController::SENTINELAS`); só o modal provava que o "SEM CADASTRO" não conta. Caso
  novo: `testAApiNaoContaOCadastroDoSistema` (9 ativos e o "SEM CADASTRO": o 409 de sempre, com o dono).
- **O dependente troca o telefone para o número em 10** (no modal e na API), e a reativação do inativo que tem o número
  também o leva a 11: a alteração não confere o telefone do dependente (a resposta 27), e a reativação não confere nada.
  A 113 fala do cadastro novo, e fechar essas portas é escolha do dono: foi ao lote (PDV-E-CADASTRO, a verificação, o
  D32), com a recomendação de o teto valer quando o telefone muda na alteração, para o dependente também. Caracterizado,
  sem decidir: `testODependenteTrocaParaONumeroEmDezCadastros` (com o controle de quem não é dependente, recusado com a
  frase do repetido). O comentário de `RegraDoCadastroDeCliente::CADASTROS_POR_TELEFONE` dizia "a alteração não muda: o
  telefone de outro já é recusado nela" -- falso para o dependente --, e atribuía à cópia os números da coleta da
  produção (355, 320, 176, ~1.590): corrigido. Na cópia, contado só em agregados, o maior número tem 26 clientes ativos,
  e os 10.000 números da anonimização têm 10 ou mais cada um (sem o "+": nenhum cadastro novo os alcança).

**Falsificado** (`medir-mutantes-pdv-cadastro-verif.py`, no rascunho do item, com o mesmo método; o mesmo vermelho no
MySQL e no PostgreSQL):

| mudança | vermelho |
|---|---|
| o teto da API também na alteração (sem o `$atual === null`) | 2: a API altera um dos 10 sem trocar o número; e a caracterização do dependente (o `PUT` dele cai no teto) |
| a API contando os cadastros do sistema (sem a lista) | 1: a API não conta o cadastro do sistema |

**O que fica -- o PARCIAL do título:** a lista dos números de balcão, que só a produção dá, classificada à mão como não
identificável antes de o telefone virar a chave do cliente (a regra da migração já descarta o fixo e o de dígito
repetido, 7 dos 8 piores); "a lista dos números de balcão sai da produção, pela sua mão". Os números que já passam de 10
cadastros continuam como estão: o teto vale para o cadastro novo. A troca do telefone do dependente e a reativação, no
lote (acima). Na cópia não dá para medir quantos números passam de 10 hoje: a anonimização deriva o telefone do id
(`medir-telefones-compartilhados.sql`). E nada foi aberto num navegador.

#### 13/10/2026 — O TETO TAMBÉM NA TROCA DO TELEFONE — pela resposta 167 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 167** (a opção "b", a recomendada): *"O teto de 10 cadastros por telefone vale também quando o telefone MUDA na
alteração, para o dependente também, com a frase 'Este telefone já está em 10 cadastros. Informe o telefone do próprio
cliente.'; quem não muda o telefone continua salvando; a reativação fica como está. No ERP e na tela nova."*

##### O conserto

- **A regra:** `RegraDoCadastroDeCliente::mudaOTelefone` -- o `telefone_sem_mascara` gravado contra o pedido, a mesma
  comparação que o `confereOTelefoneNaAlteracao` do D417 já fazia (e que agora a chama).
- **A API da tela nova** (`Adm\ClientesController::conferirRepetidos`, o `PUT`): o teto vale no cadastro novo e na
  alteração que troca o telefone, antes da regra do D417 (que deixaria o dependente passar): 409 com a frase e o motivo
  `telefone_em_muitos_cadastros`, sem o dono.
- **O ERP** (`ClientesController::recusaDoTelefoneNaAlteracao`, as duas portas da alteração: o modal `salvar` e a página
  `editar`): a frase do teto, antes da regra do D417.
- **Quem não é dependente** e troca para o número em 10 recebe a frase do teto, e não mais a do repetido ("Este telefone já
  é do cliente X."): a mesma recusa, com a frase que diz a saída. Abaixo do teto, nada muda: o dependente troca (a
  resposta 27) e quem não é recebe a frase do repetido.
- **A tela nova não mudou:** o `salvar` da edição é o mesmo do cadastro, e o 409 do motivo novo já ia para o campo do
  telefone, com o foco e sem o dependente; agora um caso o prende na edição. A reativação ficou como está.

**Provado** nos dois bancos, em série: `D32TelefoneEmMuitosCadastrosTest` (de 15 a 19 casos, um deles a caracterização do D481, abaixo) -- o dependente não troca
para o número em 10 pelo modal, pela página `editar` nem pelo `PUT`, com a frase (e o 409 com o motivo), e os três ficam
no número de antes; quem não é dependente recebe a frase do teto nas três; abaixo do teto, com 8 no número, quem não é
dependente recebe a do repetido e os dependentes trocam -- o nono pelo modal, o décimo pela API --, e o seguinte, já com
10, é recusado pela página; quem já está no número em 10, o dependente inclusive, salva sem trocar nas três portas. O
caso que caracterizava a porta aberta (`testODependenteTrocaParaONumeroEmDezCadastros`) virou o que a fecha. No front,
`ClientesLigada.teste.tsx` (um caso novo: a edição que troca para o número em 10 põe a frase no telefone, com o foco, e o
formulário fica).

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-r30.py`, no rascunho do item) | vermelho, no MySQL e no PostgreSQL |
|---|---|
| a API com o teto só no cadastro novo (a regra de antes) | 2: o dependente não troca; quem não é dependente recebe a frase do teto |
| a API com o teto em toda alteração, troque ou não | 2: a API altera um dos 10 sem trocar; o dependente que já está no número salva |
| o ERP sem o teto na alteração | 3: o dependente não troca; quem não é dependente; o seguinte, já com 10, pela página |
| o ERP com o teto em toda alteração | 2: um dos 10 edita sem trocar; o dependente que já está no número salva |
| a tela nova sem o motivo do teto (`errosDaRecusa` só com o `telefone_repetido`; no `vitest`) | 2, em `ClientesLigada.teste.tsx`: a frase no telefone no cadastro novo e na edição |

**Não provado:** o teto compara o `telefone_sem_mascara` gravado com o pedido. Um cadastro antigo com a coluna fora da forma
de hoje (nula, ou gravada antes da regra) contaria como troca e, num número em 10, seria recusado mesmo sem mexer no
telefone -- o risco que o D417 já corre com quem não é dependente. Na cópia não se mede: a anonimização reescreve as duas
colunas. E nada foi aberto num navegador.

**Achado no caminho, ao derivar as portas pelo `grep` (`setTelefoneSemMascara`), e não pela memória:** a etapa 1 da adesão
troca o telefone do cliente gravado sem conferência nenhuma -- é o **D481**, no lote.

**O que fica -- o PARCIAL do título:** a lista dos números de balcão, que só a produção dá, classificada à mão como não
identificável antes de o telefone virar a chave do cliente; e a etapa 1 da adesão (o D481, no lote).

### D29 — Nenhuma restrição de unicidade em todo o banco — PARCIAL, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: conferir na réplica de produção as seis chaves que a cópia anonimizada não mede (logins, e-mails, CPFs, hashes) e declará-las com a limpeza correspondente.

Levantado em 17/08/2026, no inventário da F1.3.

As 83 tabelas somam 137 índices e **nenhum deles é único**. Não há
`UNIQUE KEY` em lugar nenhum do schema.

Ou seja: o banco aceita dois colaboradores com o mesmo login, duas unidades com
o mesmo `hash` de API, dois clientes com o mesmo CPF. O que impede isso hoje é
exclusivamente o código da aplicação — quando impede. Uma condição de corrida
entre duas requisições simultâneas passa por qualquer verificação feita em PHP.

O `hash` de unidade é o caso mais sensível: ele autentica as três rotas
`/api/unidade/vendas*` (ver D4). Duas unidades com o mesmo `hash` significa uma
enxergando os dados da outra.

**Ao resolver:** na F1.3, junto com a criação do schema — é quando sai de graça.
Duas exigências:

1. **Conferir duplicatas na réplica de produção primeiro.** Se existirem, o
   `CREATE UNIQUE INDEX` falha. A base anonimizada **não serve** para essa
   conferência: ela deriva login, e-mail, CPF e hash do `id`, então são únicos
   por construção. Ver `f1-3-conversao-schema.md`.
2. **Toda restrição é composta com `inquilino_id`**, senão dois inquilinos não
   podem ter um colaborador com o mesmo login — o que quebraria o SaaS logo no
   segundo cliente.

#### Em 05/09/2026 a pergunta virou roteiro

`scripts/conferir-unicidade.php` guarda as nove chaves conferíveis e as seis que
**não são conferíveis na cópia anonimizada**, com o motivo de cada uma escrito ao
lado. Ele é leitura pura — só `GROUP BY ... HAVING COUNT(*) > 1` — e a mesma
invocação serve para a réplica:

```bash
BANCO_HOST=<replica> php scripts/conferir-unicidade.php
```

O que ele respondeu na cópia local, que é onde o dado de negócio está intacto:

| chave | repetidos |
|---|---:|
| `royalties` (unidade, dia) | **87** |
| `nps_resultados_colaboradores` (colaborador, mês, ano) | 5 |
| `clientes_creditos` (cliente, unidade) | 3 |
| `nps_resultados_unidades` (unidade, mês, ano) | 3 |
| `push_app` (cronjob) | 2 — esperado, é lista |
| os outros quatro | 0 |

E o que ele **se recusa** a responder, porque a anonimização deriva do `id`:
`usuarios.login`, `usuarios.email`, `usuarios.cpf`, `clientes.cpf`,
`clientes.hash` e `unidades.hash` — este último é o que autentica as rotas
`/api/unidade/vendas*`, e portanto o mais sensível dos seis.

Duas guardas o impedem de mentir:

- **sem a cópia de produção ele para e diz isso**, em vez de reportar zero
  duplicatas num banco só de fixtures — zero ali não é "não há duplicata", é
  "não há dado";
- **as contagens conhecidas são declaradas**, e ele falha nas duas direções:
  duplicata nova reprova, e uma conhecida que suma também, para que a lista não
  envelheça em silêncio.

Conferido: com a conta de `royalties` zerada à força, ele acusa e sai com 1.

#### Em 15/09/2026 apareceu o vão do OUTRO lado: quem limpa antes da carga

O gerador de DDL escreve, dentro do schema que ele mesmo emite, uma frase que é
uma **promessa sobre outro arquivo**:

> A carga FALHA se os dados ainda tiverem duplicatas. É de propósito:
> `scripts/pre-migracao-limpeza.sql` roda antes, e falhar aqui é melhor que
> migrar o problema.

**Nada conferia essa promessa.** Enquanto as duas pontas coincidirem por hábito,
funciona; no dia em que alguém declarar uma chave sem o bloco de limpeza
correspondente, quem descobre é a carga — no meio do corte, com o banco pela
metade.

O preço está medido. `scripts/migrar-dados.sh` copia **83 tabelas**, e sua função
`conferir()` só aceita a resposta `COPY <n>` do PostgreSQL:

```sh
if echo "$2" | grep -qE '^COPY [0-9]+$'; then ... fi
echo "FALHOU"; exit 1
```

Não há `|| continue`. Uma violação de unicidade na 29ª tabela deixa as **54
seguintes nunca carregadas**. E a sequência documentada do corte roda a limpeza
**antes** e o `conferir-migracao.php` **depois** — o único portão que enxergaria
o estranho só fala quando o estrago já aconteceu.

#### A regra certa não é "toda entrada precisa de bloco"

Essa regra mais simples seria **mentira**, e vermelha sobre um estado correto:
`clientes_sessoes.token_hash` é única, tem zero duplicatas e não tem bloco
nenhum — corretamente, porque não há o que limpar. Portão vermelho sobre estado
certo ensina a ignorar portão.

A regra que vale é o **par**: chave declarada + dado sujo na origem ⇒ tem de
haver quem limpe. Medido na cópia local:

| chave declarada | duplicatas | quem limpa |
|---|---:|---|
| `clientes_sessoes` (token_hash) | 0 | ninguém, e está certo |
| `privilegios` (grupo, funcionalidade) | 0 | `DELETE p FROM`, linha 125 |
| `dashboard_colaboradores` (4 colunas) | 0 | `UPDATE` 198, `DELETE d FROM` 203 |
| `royalties` (unidade, data) | **87** | `DELETE r FROM`, linha 273 |

O `royalties` é a prova de que o par não é enfeite: a cópia local **ainda tem**
as 87 duplicatas, e a única coisa entre a chave declarada e uma carga abortada é
aquele bloco.

`scripts/conferir-limpeza-da-unicidade.php` passou a conferir isso, e entrou no
`conferir-tudo.sh`.

**Ele imprime o comando que casou, com o número da linha** — e isso não é enfeite
de saída. *Escrever na tabela* não é *deduplicar a tabela*: o bloco 4 faz `UPDATE
usuarios` para consertar ano de nascimento inválido, e no dia em que
`usuarios.login` entrar na lista — é um dos seis que este débito espera — o
portão acharia aquele UPDATE e diria "tem quem limpe". Não dá para distinguir os
dois por regra sem mentir (os três blocos de hoje nem se parecem entre si; o do
`dashboard_colaboradores` monta tabela temporária antes, e o `HAVING` nem está no
mesmo comando). Então em vez de adivinhar, ele mostra, e quem lê decide.

**Falsificação**, nas três direções:

| o que arranquei | o que ele fez |
|---|---|
| acrescentei `'funcionalidades' => ['nome']` ao gerador | **EXIT 1**, nomeando a tabela, as 63 e o bloco ausente |
| comentei o `DELETE` de `royalties` na limpeza | **EXIT 1**, nomeando `royalties` e as 87 |
| rodei sem `BANCO_HOST`, com o banco inalcançável | **EXIT 2** — "não consegui medir", e não 0 |

Os dois arquivos voltaram byte a byte (`sha1` e contagem conferidos,
`git status --porcelain` vazio), e o portão voltou ao verde.

#### O que fez este portão nascer: a entrada que eu ia acrescentar

Eu ia declarar `funcionalidades.nome` única. A evidência de que a duplicata é
**erro** é das mais duras que há neste repositório — e continua valendo:

`ISConfiguracao\Permissions\Acl::carregarFuncionalidades()` registra cada linha
como recurso da ACL **pelo nome**:

```php
$this->addResource(new GenericResource($funcionalidade->getNome()));
```

e o Laminas lança na segunda
(`vendor/laminas/laminas-permissions-acl/src/Acl.php:254`):

> `Resource id '<nome>' already exists in the ACL`

Não há `try`/`catch` em nenhum ponto do login. Alimentei a biblioteca com os
**126 nomes reais** da tabela: estourou no **64º** recurso, em `Agendas
visualizar`. Com os **63 da faixa de produção**, montou inteira. O mesmo vale
para `grupos.nome`, por `addRole` — `Role id "%s" already exists in the registry`.

**E mesmo assim a entrada estava errada de ser acrescentada sozinha.** A origem
padrão da carga é `franquiabv_dev`, que hoje tem 63 duplicatas de nome, e a
limpeza não tem uma linha sequer sobre `funcionalidades`. Ela seria a primeira
unicidade declarada sem par — e o preço seria a carga parando na 29ª de 83.

**A duplicata não é da produção: é do instrumento de medida.** São 126 linhas
para 63 nomes, e os 63 grupos são todos mistos, uma linha de produção e uma de
fixture. Dentro da faixa de produção: 63 linhas, 63 nomes distintos, zero
duplicatas — conferido por colação `ci`, por `BINARY` e por `LOWER(TRIM(nome))`.

**A causa tem endereço**, e o próprio `tests/Caracterizacao/fixtures.sql` enuncia
a regra antes de quebrá-la. Na linha 295, sobre `grupos`:

> Condicional, como o cliente 1 e a área 3: quando as fixtures entram num banco
> que já tem produção, a linha real fica.

São **nove** `WHERE NOT EXISTS` no arquivo. Mas o bloco de `funcionalidades`
(linha 208) insere os 63 nomes **incondicionalmente**, precedido de um `DELETE
FROM funcionalidades WHERE id >= 90001000` que jamais alcança as linhas de
produção (ids 1..68). É essa a razão de o ERP não conseguir montar a ACL no banco
de desenvolvimento — ver D167.

#### A premissa que fechou este parágrafo era FALSA, e caiu no mesmo dia

O parágrafo original dizia, sobre consertar a fixture:

> Torná-la condicional significa reusar os ids de produção, e aí esses cinco
> deixam de achar o que procuram.

**A segunda metade não decorre da primeira.** Dá para condicionar pela coluna
`nome` e **manter os ids `90002xxx`** — eles continuam existindo exatamente onde
não há produção. O erro foi ler a convenção do próprio `fixtures.sql`, cujos
nove `WHERE NOT EXISTS` condicionam todos pelo **id**, como se fosse a única
forma possível. `funcionalidades` é a única tabela daquele arquivo cuja colisão é
numa coluna de negócio, com o id livre.

E os cinco acoplamentos, conferidos um a um: **quatro nunca veem produção.**
`ensaiar-recusas-adm-api.php` **se recusa a rodar** onde há — `if ($unidades >
10) … exit(2)`, linha 267 —, e `medir-telas-no-postgres.sh` e o `ci.yml` fazem
`DROP`/`CREATE` do banco antes de carregar. Sobrou **um**:
`PrivilegioPropagacaoTest.php`, que também roda contra a cópia.

#### O conserto, feito em 15/09/2026

O bloco de `funcionalidades` do `fixtures.sql` passou a ceder pelo nome:

```sql
INSERT INTO funcionalidades (id, nome)
SELECT v.id, v.nome FROM (
            SELECT 90002000 AS id, 'Agendas visualizar' AS nome
  UNION ALL SELECT 90002001, 'Agendas gerenciar'
  ...
) v
 WHERE NOT EXISTS (SELECT 1 FROM funcionalidades f WHERE f.nome = v.nome);
```

A forma foi **escolhida por medição**, e não por gosto: a compacta (tabela
derivada) podia esbarrar no erro 1093 do MySQL, então foi ensaiada em banco
descartável nos **dois** bancos, nos dois cenários. Tabela vazia → as 63 entram
na faixa de fixture; tabela com os nomes de produção → nenhuma linha nova.
Idêntico no MySQL e no PostgreSQL.

O bloco das oito concessões do gerente de fixture passou a casar por nome pelo
mesmo motivo — com id fixo ele violaria a chave estrangeira na cópia e abortaria
a carga inteira. E `PrivilegioPropagacaoTest` passou a achar a funcionalidade
pelo nome, com uma asserção que **diz o que houve** em vez de morrer quatro
camadas adiante num `Column funcionalidade cannot be null`.

**Os 63 pares `(id → nome)` foram conferidos idênticos antes e depois**, acentos
inclusive — e a primeira conferência que escrevi era um falso verde: `grep -P`
falhou por locale, os dois lados saíram vazios, e o `diff` disse "idênticos"
comparando nada com nada. Refeita em PHP.

#### O que isso fez com a cópia, medido

| | antes | depois |
|---|---:|---:|
| `funcionalidades` (MySQL) | 126 linhas / 63 nomes | **63 / 63** |
| `funcionalidades` (PostgreSQL) | 126 / 63 | **63 / 63** |
| concessões do gerente de fixture | 8 no MySQL, **7** no PostgreSQL | 8 nos dois |
| privilégios de produção | 15.495 | 15.495 |

E a prova que importa, com a classe do próprio projeto, dentro do container,
contra o banco vivo, pelo caminho que o login usa:

> **A ACL DO PROJETO MONTOU.**
> `isAllowed('Gerente unidade', 'Agendas visualizar', 'ler') = true`

Antes: `Resource id 'Agendas visualizar' already exists in the ACL`, no 64º
recurso. **O ERP voltou a ter ACL.**

A recarga completa do `fixtures.sql` na cópia também passou, e é o que atualiza
`_fixtures_versao` — a marca do banco e a do arquivo voltaram a bater.

#### E a chave única, que era o objetivo desde o começo

Com a cópia limpa, `conferir-limpeza-da-unicidade.php` — que na véspera recusava
esta entrada com `63 SUJA E SEM NINGUÉM QUE LIMPE` — passou a dizer
`funcionalidades (nome) 0 limpa, e não precisa de bloco`. **O mesmo portão, a
mesma entrada, veredito oposto, porque o dado mudou.**

`'funcionalidades' => ['nome']` entrou no gerador, o `postgres/schema.sql` ganhou
`ALTER TABLE funcionalidades ADD CONSTRAINT funcionalidades_nome_unico UNIQUE
(nome);`, e a entrada entrou na declaração de `conferir-unicidade-do-schema.php`.
Falsificada: arrancada do gerador, o portão sai com **1** nomeando a tabela e o
custo. São **cinco** unicidades declaradas.

### D30 — Dinheiro em ponto flutuante — MESMO DEFEITO DO D49

**Este débito e o D49 são o mesmo**, registrados com dez dias de diferença: as
mesmas 85 colunas `double`, a mesma consequência, a mesma resolução adiada para
depois do corte. Descoberto em 30/08/2026, ao varrer o registro atrás de
assuntos repetidos.

**E os dois já tinham divergido.** O D30 mandava converter para `numeric(12,2)`;
o D49, para `numeric(14,2)`. Uma escala errada para valores acumulados é
exatamente o tipo de detalhe que se perde quando duas páginas descrevem a mesma
migração — e a de 14 dígitos é a certa, porque `royalties_faturas.valor` e as
somas de relatório passam de 10 dígitos antes da vírgula com folga.

**O conteúdo vivo está no D49**, que é mais rico: ele traz a divergência medida
na 16ª casa entre os dois bancos, e o episódio em que a própria prova de
migração passou por sorte — comparava somas de ponto flutuante com tolerância de
meio centavo, valor **abaixo do que o tipo distingue** numa soma de 155 milhões.

Este registro fica como remissão, e não é apagado: quem chegar aqui por um link
antigo precisa ser mandado ao lugar certo, não encontrar um vão.

### D28 — A venda passou a poder ser registrada na data em que aconteceu; falta o caixa — FECHADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está fechado, sem nada a fazer. O corpo: "A guarda nova não é redundante — ela fecha exatamente essas 44".

Levantado em 17/08/2026, ao detalhar as metas de RTO.

`Venda::setDataCriacao()` **não aceita parâmetro**: o corpo é
`new \DateTime('now')`, fixo. E `VendaPagamentoService::finalizar()` atribui o
caixa com `selecionarPorUsuario($usuario, true)` — o caixa aberto **naquele
instante**.

Consequência: uma venda lançada depois do fato fica com a data do lançamento e
no caixa que estiver aberto na hora, e não na data e no caixa em que realmente
ocorreu.

Isso importa porque descreve o que a operação faz numa parada. O sistema fora do
ar não impede a venda: o cliente é atendido presencialmente e o lançamento é
feito quando o sistema volta. Se a volta acontece dentro do mesmo caixa, o desvio
é de minutos e não tem efeito. Se atravessa o fechamento do caixa, a venda cai no
caixa seguinte — o dia que teve o movimento mostra menos, e o dia seguinte
mostra mais. Se atravessa a virada do mês, **o cálculo de royalties do mês fica
errado**, porque ele soma por período a partir de `vendas`.

Na parada de 36 h de 2025, portanto, todo o movimento do período foi registrado
com data e caixa errados. Vale considerar isso ao comparar aqueles meses com
outros.

#### O erro de data não se corrige sozinho, e chega na fatura

*Apurado em 17/08/2026, depois de o responsável definir que atravessar a virada
do dia é inaceitável.*

Royalties **não são somados de `vendas` ao vivo**. `FranquiaService::royaltiesAtualizar()`
grava um retrato diário por unidade na tabela `royalties`, e os relatórios leem
de lá (`RoyaltiesRepository`, filtrando por `roy.data`).

Duas propriedades desse retrato agravam o problema:

1. **A janela de recálculo é de dois dias.** O método começa em `-2 days` e
   avança duas vezes, ou seja, recalcula **ontem e hoje**. Um lançamento que
   entre com data errada e só seja notado no terceiro dia **nunca é recalculado**:
   o número fica congelado errado.
2. **A fatura fecha no dia 16, cobrindo do dia 16 ao 15.** Uma venda que
   escorregue de um dia para o outro atravessando essa fronteira **muda de fatura
   de royalties** — deixa de ser cobrada num período e passa a ser cobrada no
   seguinte. Isso não é relatório: é cobrança ao franqueado.

#### A metade da data foi feita em 30/08/2026

`Venda::setDataCriacao()` passou a aceitar um `?\DateTime` opcional, com o mesmo
padrão de antes:

```php
public function setDataCriacao(?\DateTime $quando = null) {
    $this->dataCriacao = $quando ?? new \DateTime('now');
    return $this;
}
```

**Toda chamada existente se comporta de forma idêntica** — e há uma só,
`VendaPagamentoService::finalizar()`. Não é mudança de comportamento: é
permissão para informar o que era impossível informar.

Cobertura em `tests/Caracterizacao/VendaDataDoFatoTest.php`, com quatro
asserções, e conferi que acusam: removendo o parâmetro, três das quatro falham.

> **Este arquivo não existe mais.** Ele foi removido em 31/08/2026 junto com o
> parâmetro que cobria, quando a regra de negócio enunciada naquele dia proibiu
> retroagir venda. O parágrafo fica porque descreve o que foi feito em 30/08, e
> a seção seguinte descreve por que foi desfeito.

Uma delas verifica por **reflexão** que o parâmetro continua opcional. Parece
excessivo, e não é: é exatamente a opcionalidade que torna a mudança segura, e
alguém que a torne obrigatória quebra a única chamada existente sem que nenhum
outro teste perceba.

**O QUE ISTO NÃO ENTREGA, e é importante ser exato:** não há como informar a data
pela tela. Nenhum campo existe, e criá-lo é decisão de produto — inclui decidir
quem pode retroagir um lançamento e até quando. O que a mudança faz é remover o
impedimento técnico; a operação continua registrando tudo como "agora".

#### A REGRA FOI ENUNCIADA em 31/08/2026, e ela substitui a resposta de 30/08

**A regra, dita por quem decide:**

> Não permitir venda retroagida, apenas na data atual ao lançamento. Se o caixa
> passar de 23:59:59, pedir para fechar o caixa e abrir um na data vigente.

**Isso inverte a resposta que este débito tinha recebido no dia anterior.** Em
30/08 a saída escolhida foi permitir informar a data do fato; em 31/08 a regra de
negócio diz que não se retroage. As duas não valem juntas.

**O que foi desfeito:** `Venda::setDataCriacao()` voltou a ser `new
\DateTime('now')` fixo, e `VendaDataDoFatoTest` saiu com ele. Manter o parâmetro
seria pior que nunca o ter criado — uma capacidade que a regra proíbe usar é
armadilha, porque quem a encontrar depois vai supor que é permitida.

**O que foi feito:** `VendaPagamentoService::caixaDeDataAnterior()` devolve o
caixa aberto quando ele é de data anterior a hoje, e `finalizar()` recusa a venda
**antes de qualquer escrita** — as primeiras linhas do método debitam crédito do
cliente e do programa de fidelidade, e sair no meio deixaria o cliente sem o
crédito e sem a venda. `VendasPagamentosController` usa a mesma função para dizer
ao operador o que fazer, em vez de a venda falhar em silêncio.

Cinco testes em `tests/Caracterizacao/CaixaNaoAtravessaODiaTest.php`, verificados
**desligando a guarda de propósito**: com ela desligada, três dos cinco caem.

**Um deles passava sem provar nada, e isso vale registrar.** O teste que verifica
que a recusa não deixa a venda pela metade buscava a venda *antes* de
`recuarCaixa()`, que faz `em->clear()` — a entidade chegava desanexada a
`finalizar()`, nada era gravado, e a asserção passava por acidente. Só apareceu
porque, com a guarda desligada, ele continuou verde enquanto os outros dois
caíam. **A prova de que um teste acusa é a única forma de saber que ele mede.**

#### E o tamanho disto é muito menor do que eu apresentei

Eu levei a você o número de **19,18%** — 4.156 dos 21.666 caixas fechados desde
2025 fecharam em data diferente da abertura — dizendo que a guarda encostaria na
operação todo dia. **Esse número mede outra coisa:** caixa deixado aberto de um
dia para o outro e fechado depois, sem venda no meio.

O número que mede o risco é este:

| | |
|---|---:|
| Vendas desde 2025 | 407.544 |
| **Em caixa de outra data** | **44 — 0,011%** |
| Valor | R$ 7.787 |

**É pouco porque a tela já barrava.** `VendasController::validarCaixaAberto()`
recusa a entrada em `novoAction()` quando o caixa é de outro dia, e mostra a
mensagem certa, desde antes de tudo isto. As 44 são o que escapa: abrir comanda,
finalizar venda já aberta, ou estar com a tela aberta quando o dia virou.

**A guarda nova não é redundante — ela fecha exatamente essas 44**, e é a lição
do D20 pela terceira vez: trava na tela protege a tela; trava no serviço protege
qualquer chamador, inclusive um endpoint REST da Fase 2.

#### As duas asserções que pareciam ter sumido

Ao trocar `VendaDataDoFatoTest` por `CaixaNaoAtravessaODiaTest`, o total da suíte
caiu de **203 para 201** asserções — e os dois arquivos declaram 7 cada. Duas
tinham deixado de rodar, que é a forma exata de um teste que silenciosamente
para de verificar.

**Não era isso, e a diferença foi medida e não deduzida.** Rodei a suíte antiga
com `--log-junit`, guardando as mudanças no stash e recarregando as fixtures
antigas, e comparei classe a classe:

| | Antes | Agora |
|---|---|---|
| `VendaDataDoFatoTest` | 4 testes, **9 asserções** | removido |
| `CaixaNaoAtravessaODiaTest` | — | 5 testes, **7 asserções** |
| **as outras 19 classes** | — | **idênticas, uma a uma** |

203 − 9 + 7 = 201.

**O arquivo removido executava 9 declarando 7**, e a causa foi provada com um
teste descartável:

| Asserção | O PHPUnit conta |
|---|---|
| `assertSame` | **1** |
| `assertGreaterThanOrEqual` | **2** |
| `assertLessThanOrEqual` | **2** |

As duas últimas são constraints compostas — `LogicalOr` de "maior que" **ou**
"igual a" —, e cada lado incrementa o contador. `testSemArgumentoContinuaSendoAgora`
usava as duas: duas instruções, quatro asserções contadas.

**A lição é a mesma do D67, no mesmo dia:** contei com `grep` e tratei instrução
como asserção. `grep` conta linhas, não o que o código faz. E "o total bateria se
eu contasse do meu jeito" não é conferência — a conferência foi rodar as duas
versões e comparar.

#### O "avisar antes" pedido na decisão já existia

A decisão de 31/08 pedia duas coisas: recusar a venda **e** avisar antes, no
primeiro acesso do dia, para o operador não descobrir o problema com cliente na
frente.

**A segunda metade já estava implementada**, e ninguém tinha notado ao redigir
este débito. `VendasController::novoAction()` chama `validarCaixaAberto()` e, se
o caixa é de outro dia, mostra:

> *"O caixa foi aberto no dia X, para realizar uma nova venda você precisa fechar
> o caixa e abrir novamente."*

...e redireciona para a tela de gerenciar caixas. É mais forte que um aviso: o
operador não entra na tela de venda sem resolver.

**Nada foi construído para isso, então.** O que faltava era só a metade do
serviço — e é ela que fecha as 44.

#### Os caixas que já estavam abertos

`scripts/medir-caixas-abertos.sql` mede e lista, e **não fecha nada** — o
`UPDATE` fica comentado no fim, para ser conferido antes.

| | |
|---|---:|
| Caixas abertos | 137 |
| **De usuário ativo** | **36** — são os únicos que travariam alguém |
| De usuário inativo | 101 |
| O mais antigo de usuário ativo | id 245, aberto em 20/10/2015 — **3.968 dias** |

Fechar é mais simples do que parece: `CaixaService::fechar()` faz
`setStatus(false)` e nada mais, porque o `valor_final` já é mantido durante a
operação por `atualizar()`. Fechar **não recalcula dinheiro** — encerra o caixa
com o saldo que ele já tem.

#### A metade do caixa continua aberta, e é de propósito

Apontar um lançamento para um caixa já fechado mexe na reconciliação de saldo
(`CaixaService::atualizar()`) e exigiria decidir o que fazer com um caixa fechado
que muda de valor.

A distinção importa porque **a data é o que afeta dinheiro** — royalties, fatura,
fechamento diário. O caixa é reconciliação operacional, resolvida por quem opera.

Enquanto a segunda metade não vem, uma venda retroagida ficaria com a data certa
e o caixa de hoje. Para royalties isso basta, porque a apuração soma por data.
Para o fechamento do caixa, não.

### D27 — A réplica em MySQL 8.4 não serve para retorno

Levantado em 17/08/2026, ao inventariar a infraestrutura.

A produção e duas das três réplicas rodam MySQL 8.0.4x. O servidor 1
(Hostinger `linkwise`) roda **8.4.10**. A replicação do MySQL é suportada de uma
versão menor para uma maior, **não no sentido inverso**.

A consequência aparece justamente no cenário que motiva a redundância:

1. Se o servidor 1 for promovido a primário numa falha, **as outras duas réplicas
   (8.0) não conseguem replicar a partir dele**. A cadeia de redundância desaba
   para um nó só no momento em que ela mais importa.
2. Voltar para a produção 8.0 depois de ter operado em 8.4 não é suportado.
3. O MySQL 8.4 **desabilita `mysql_native_password` por padrão**. Se algum
   usuário de aplicação ou de replicação ainda usa esse plugin, a conexão falha —
   e falha na hora do desastre, não antes.
4. Dumps tirados dele carregam sintaxe 8.4. Já causou confusão em 16/08/2026: um
   dump da réplica foi tomado por dump de produção, e a divergência de versão
   ficou dois dias sem explicação.

Enquanto as versões divergirem, o servidor 1 é **réplica de leitura, não nó de
contingência** — e o plano de contingência precisa dizer isso com todas as
letras, senão alguém vai contar com ele numa madrugada.

**Ao resolver:** alinhar o servidor 1 em 8.0.4x (o caminho simples), ou subir
todos para 8.4 de uma vez. Não deixar o parque misto.

---

### D63 — 78 entidades declaravam `@ORM\Entity` duas vezes — CORRIGIDO em 30/08/2026

Encontrado em 30/08/2026 de raspão, ao ler `CupomCliente` por outro motivo.

```php
/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks
 * @ORM\Table(name="cupons_clientes")
 * @ORM\Entity(repositoryClass="ISConfiguracao\Entity\CupomClienteRepository")
 */
```

**Não era um deslize num arquivo: eram 78 das 82 entidades do projeto.** Era a
convenção, escrita do mesmo jeito em todo lugar, com a linha nua sempre antes da
que carrega o `repositoryClass`.

**Não quebrava nada, e a ordem era a única razão.** O `AnnotationDriver`
reindexa as anotações pelo nome da classe, então a segunda sobrescreve a
primeira — e a segunda é justamente a que traz o repositório. Invertida a ordem
em qualquer um dos 78 arquivos, aquela entidade perderia o repositório
customizado sem nenhum aviso.

**O problema era a Fase 1.** Anotação em docblock aceita repetição; **atributo
de PHP 8 não**. Verificado por execução em 30/08/2026, e não de memória:

```
Attribute "Marca" must not be repeated
```

`ORM\Entity` não é `IS_REPEATABLE`. Uma conversão mecânica de anotações para
atributos — que é o caminho normal ao subir de Doctrine, e o projeto vai subir —
produziria **78 erros fatais de uma vez**, todos no carregamento da classe, e
todos com a mesma mensagem opaca.

#### Como foi resolvido

`scripts/conferir-entidade-duplicada.php` relata por padrão e corrige com
`--aplicar`. Remove a linha nua apenas, preservando integralmente a que traz
`repositoryClass`: **78 remoções idênticas, nenhuma inserção**.

**O roteiro passou por uma prova de que consegue acusar.** Uma entidade forjada,
com a duplicata e **sem** `repositoryClass`, apareceu na lista dos casos que ele
se recusa a corrigir sozinho — ali, remover a linha nua apagaria a única
declaração de entidade existente. O contador de corrigíveis seguiu em 77 e não
78, mostrando que a exclusão é real e não apenas relatada.

**E o roteiro errou uma vez, do jeito que roteiro erra: por não ler.** Na
primeira passada ele disse "0 restantes" com uma duplicata ainda de pé.
`ClienteCredito` traz um `use` **entre o docblock e a palavra `class`** — legal
em PHP, e o docblock continua valendo para a classe —, e o padrão simplesmente
não casava esse arquivo. O único sinal foi as contagens não baterem: **82
entidades mapeadas contra 81 lidas**. Sem essa diferença de um, o 78º caso teria
ficado para trás com o roteiro declarando sucesso.

**Verificação:** `orm:validate-schema` reprova 19 entidades antes e 19 depois —
idêntico, e por motivo alheio (**D64**). Os 80 repositórios customizados
continuam existindo e resolvendo, com 0 quebrados. E 109 de 109 na caracterização
no MySQL e no PostgreSQL, 35 de 35 no contrato.

**Nota de leitura:** quatro entidades escrevem o `repositoryClass` sem namespace
(`UnidadeRepository`, `UnidadeMensageriaRepository`, `SaidaParcelaRepository`,
`SaidaParcelaPagamentoRepository`). Funciona porque o `ClassMetadata` resolve
nome curto contra o namespace da própria entidade — mas convém não mexer nesses
quatro sem olhar.

---

### D64 — O `orm:validate-schema` voltou a valer como portão — FECHADO em 31/08/2026

Medido em 30/08/2026, ao conferir se a limpeza do **D63** tinha estragado algo.
Não tinha — **19 reprovações antes, 19 depois** —, e foi essa conferência que
mostrou o tamanho de um problema que já estava registrado pela metade.

**31 queixas em 19 entidades**, de dois tipos bem diferentes.

#### 29 são `inversedBy` faltando — barulho, mas barulho que esconde

O lado inverso declara `mappedBy`, o lado proprietário não declara o
`inversedBy` correspondente. O Doctrine funciona assim mesmo: carrega, grava,
consulta. As mais concentradas são `Usuario` com 5, e `Unidade` e `VipClass` com
3 cada.

O custo não é de execução, é de leitura. **`orm:validate-schema` é um portão que
hoje reprova sempre**, então ninguém consegue usá-lo para descobrir se uma
mudança estragou o mapeamento. Foi preciso rodar a validação duas vezes — com e
sem a mudança guardada — e comparar contagens, porque o "reprovado" sozinho não
diz nada.

#### 2 são de outra natureza: apontam para campos inexistentes

```
Convenio#clientes      OneToMany(Cliente, mappedBy="cliente")
                       Cliente não tem $cliente. Tem $convenio.

ProdutoEstoque#produto ManyToOne(Produto, inversedBy="entradas")
                       Produto não tem $entradas. Não tem nada parecido.
```

A primeira é um erro de uma palavra: `mappedBy="cliente"` onde cabia
`mappedBy="convenio"`. A segunda aponta para um lado inverso **que nunca foi
escrito** — `entradas` não existe em `Produto`, e a única ocorrência da palavra
no projeto inteiro é a própria anotação.

**Nenhuma das duas é percorrida hoje**, e isso foi conferido:
`Convenio::getClientes()` existe e não é chamado de lugar nenhum fora da própria
entidade. Enquanto ninguém tocar nessas associações, elas não falham — e foi por
isso que duraram.

É o padrão que você já descreveu: **placeholder de função que nunca foi usada**.

#### As duas quebradas foram corrigidas em 30/08/2026

Por decisão sua, e só elas — os 29 `inversedBy` ficaram de fora de propósito.

**`Convenio#clientes`** passou a apontar para `convenio`, que é o campo que
existe, e `Cliente#convenio` ganhou o `inversedBy="clientes"` que fechava o par.
Duas linhas, uma de cada lado.

**`ProdutoEstoque#produto`** perdeu o `inversedBy="entradas"`. Inventar a coleção
`$entradas` em `Produto` seria escrever código novo para satisfazer uma anotação;
remover descreve o que a relação sempre foi na prática — unidirecional.

**Medido depois:** `orm:validate-schema` caiu de **19 para 18** entidades
reprovadas e de **31 para 29** queixas, e as queixas do tipo *"refers to a field
which does not exist"* passaram a ser **zero**. `Convenio` continua na lista,
mas agora só pelo `#unidades`, que é do grupo dos 29.

**Por que só estas duas.** Acrescentar `inversedBy` a uma relação muda como a
UnitOfWork trata a coleção do lado inverso; fazer isso em 29 relações de uma vez
é mudança de comportamento disfarçada de arrumação. Estas duas são diferentes
porque **ninguém as percorre** — o único efeito possível era o validador parar de
reclamar, e foi o que aconteceu.

#### Os 29 `inversedBy` foram aplicados em 31/08/2026, por decisão sua

```
[OK] The mapping files are correct.
```

De **18 entidades reprovadas e 29 queixas** para **zero**. O `[ERROR]` que
continua aparecendo logo abaixo é de banco fora de sincronia — outro assunto
(**D29**), que some quando o PostgreSQL recebe as restrições que o MySQL nunca
teve.

**A lista não foi minha, e isso foi deliberado.** Cada edição saiu de uma queixa
do próprio `orm:validate-schema`, que já diz a entidade alvo, o campo alvo e o
valor exato do atributo. Varrer o código por conta própria abriria espaço para
eu decidir errado; aqui o validador ditou e o roteiro executou.

**E as duas listas foram comparadas antes de aplicar.** Derivei uma segunda lista
do código — todo lado inverso com `mappedBy`, conferindo se o proprietário tem
`inversedBy` — e ela deu **30**. A diferença era falso positivo meu:
`SaidaParcelaPagamento#parcela` declara `targetEntity="SaidaParcela"` **sem
namespace**, o meu roteiro não achou o arquivo e reportou como faltante. Já
tinha o atributo. Fora essa, **as 29 são idênticas nas duas listas**.

| | |
|---|---:|
| Arquivos tocados | 27 |
| Anotações alteradas | 29, todas `ManyToOne` |
| Linhas do diff | 58 — 29 fora, 29 dentro |
| Linhas acrescentadas sem `inversedBy` | **0** |

Nenhuma é `OneToOne`, o que mantém o caso simples: o lado proprietário continua
carregando por referência tardia, e o que muda é o Doctrine passar a saber que a
coleção do outro lado existe.

**O ganho é o portão.** Antes, para saber se uma mudança estragou o mapeamento,
era preciso rodar a validação duas vezes — com e sem a mudança guardada — e
comparar contagens, porque "reprovado" sozinho não dizia nada. Agora `[OK]`
significa `[OK]`.

**Não confundir com o `[ERROR]` de banco fora de sincronia** que aparece no fim
da mesma saída: aquele é outro assunto (**D29**), e some sozinho quando o
PostgreSQL recebe as restrições que o MySQL nunca teve.

---

### D65 — 98 colunas onde a anotação e o banco discordam sobre aceitar nulo — e um SEGUNDO eixo, o tipo, MEDIDO em 12/09/2026

Medido em 30/08/2026, ao investigar a observação de passagem do **D51** de que
`vendas.comanda_temp` é declarada `nullable=true` e o MySQL diz `NOT NULL`. Não
era uma coluna: são **98 de 732** — 13% do mapeamento.

E elas discordam nos **dois sentidos**, que quebram de jeitos opostos:

| | | O que isso faz |
|---|---:|---|
| Anotação diz NOT NULL, banco aceita nulo | **52** | o Doctrine hidrata nulo num campo que o código trata como sempre preenchido |
| Anotação diz NULO, banco diz NOT NULL | **46** | o código pode gravar nulo, e o `flush()` falha com erro de SQL opaco |

#### Das 52, quantas têm nulo de verdade

**33.** Descontando 8 que são artefato da anonimização — ela zera `salt`,
`whatsapp_api_key`, `asaas_id` e afins —, **restam 25 reais**.

(Uma das oito era `iframe_bi`, que saiu do sistema em 09/09/2026 com o Power BI.
A medida acima é de 08/09 e fica como foi feita.)

| Coluna | Nulos | de |
|---|---:|---:|
| `agendas.add_comanda` | **708.877** | 4.000.081 |
| `clientes_creditos.pontuacao_validade` | 153.135 | 256.985 |
| `produtos.pacote_validade_dias` | 9.268 | 9.506 |
| `produtos.categoria` | 8.040 | 9.506 |
| `visagismo.feedback_nota` | 2.557 | 2.740 |
| `push_app.link` | 701 | 701 |
| `produtos.quantidade` | 280 | 9.506 |
| `convenios.data_ultima_venda` | 188 | 638 |
| `notificacoes.primeiro_envio` / `ultimo_envio` | 149 | 2.787 |
| `unidades.faturamento_*` (9 colunas) | 2 a 19 | 60 |
| `unidades.potencial_franquia` | 5 | 60 |
| `grupos.administrador` | 2 | 13 |
| `arquivos.subcategoria` | 7 | 109 |

#### As fixtures contaminam a contagem, e quase me fizeram registrar defeito nosso

A primeira medição acusou **`unidades.nome` com 1 nulo em 62**, e eu escrevi que
uma unidade sem nome merecia olhada. Merecia — e a olhada mostrou que era a
unidade **90001000**, criada pelas nossas próprias fixtures de teste. O mesmo
para `unidades.estado`.

Excluindo os ids da faixa `9000xxxx`, **`unidades.nome` tem zero nulos reais**.

**A regra que fica:** a base de desenvolvimento tem, além da cópia anonimizada da
produção, as linhas que os testes criam. Em tabela grande isso é irrelevante —
`agendas.add_comanda` cai de 708.879 para 708.877. Em tabela pequena é decisivo:
`unidades` tem **60 linhas reais e 2 nossas**, e uma contagem de "1 em 62" pode
ser inteiramente artefato.

Toda medição em tabela pequena precisa de `WHERE id < 90000000`.

#### O código já sabe o que a anotação nega

`AgendaService:212`, ao arquivar uma exclusão:

```php
'add_comanda' => $addComanda === null ? null : ($addComanda ? 1 : 0),
```

**O código trata o nulo explicitamente**, e a anotação três arquivos adiante diz
`nullable=false`. Quem escreveu o serviço sabia; quem lê a entidade, não.

A propriedade é `private $addComanda`, sem tipo — então a hidratação de nulo é
silenciosa. O construtor grava `false`, então linha nova nasce preenchida: os
708.879 nulos são históricos.

#### NÃO é risco de corte, e vale dizer por quê

`scripts/gerar-ddl-postgres.php` lê o **`mysql.sql`**, que é o dump de estrutura
do banco — nunca a anotação. Conferido: `postgres/schema.sql` traz
`add_comanda boolean DEFAULT false`, **sem `NOT NULL`**, exatamente como o MySQL.

**E isso deixou de ser argumento de mecanismo para virar medição.** Na madrugada
de 31/08/2026 comparei a nulabilidade **coluna a coluna nos dois bancos vivos**:

| | |
|---|---:|
| Colunas no MySQL | 891 |
| Colunas no PostgreSQL | 970 |
| Presentes nos dois | 890 |
| **Divergentes** | **0** |

As 80 a mais no PostgreSQL se explicam inteiras: **75 são `inquilino_id`**, e as
outras **5 são a tabela `inquilinos`**, que só existe lá. Nenhuma sobra.

Os dois bancos concordam perfeitamente. **Quem discorda dos dois é o
mapeamento** — e só ele.

**Onde isso morde, então:**

1. **`orm:schema-tool` propõe `NOT NULL` em coluna cheia de nulo.** É parte dos 71
   comandos `CHANGE` que ele lista, e é mais uma razão para o `--force` nunca ser
   executado (**D17**).
2. **Quem regenerar o schema a partir do MAPEAMENTO** — e não do dump — produz um
   banco que a carga recusa. Não é o caminho de hoje, mas é o caminho natural de
   quem chega e usa `schema-tool:create`.
3. **A anotação é o que o leitor humano acredita.** 98 divergências são 98
   oportunidades de alguém raciocinar sobre o campo errado.

**Ao resolver:** alinhar a anotação ao banco, coluna por coluna, **depois do
corte**. É mudança de mapeamento, e mapeamento é o que a suíte de caracterização
exercita — então o portão "o comportamento não mudou" precisa estar livre para
falar só de uma coisa por vez.

**Nota sobre o D51:** a coluna que ele cita, `vendas.comanda_temp`, é do segundo
grupo — anotação `nullable=true`, banco `NOT NULL`. A observação dele estava
certa; o que faltava era saber que não estava sozinha.

#### HÁ UM SEGUNDO EIXO, achado em 11/09/2026: o TIPO, e não só o nulo

Este débito conta desacordos de **nulabilidade**. Procurando onde caracterizar o
valor de um relatório, tropecei num desacordo de **tipo**, que é outro eixo e não
está contado em nenhuma das 98:

| | anotação | MySQL | PostgreSQL |
|---|---|---|---|
| `dashboard_movimentos.data_inicio` | `type="datetime"` | `date` | `date` |
| `dashboard_movimentos.data_fim` | `type="datetime"` | `date` | `date` |

Os dois bancos concordam entre si — **de novo** — e quem discorda dos dois é a
anotação, exatamente o padrão que este débito descreve. O efeito hoje é benigno
por sorte: o Doctrine manda `Y-m-d H:i:s` para uma coluna `date`, e os dois
motores truncam em silêncio. Onde ele morde é no mesmo lugar das 98 — quem
regenerar o esquema a partir do **mapeamento** produz `datetime` onde o dado é
`date`, e o `orm:schema-tool` propõe a mudança.

**O CENSO FOI FEITO em 12/09/2026**, e não pelo `orm:schema-tool:update --dump-sql`:
aquele dump mistura eixos — 274 linhas, com 130 `RENAME` de índice, 39 `DROP`, 32
`ADD` e 72 `CHANGE`, várias por linha, a maioria sendo nulabilidade de chave
estrangeira, que já é a contagem de cima. Um número tirado dali seria chute com
cara de medida, e este registro já pagou por isso (o **D82** nasceu com "~65
linhas" e precisou de medidor próprio).

O censo veio do cruzamento do metadata com o `information_schema` dos **dois**
bancos, coluna a coluna. São **746 campos mapeados, nenhum sem coluna**, e sete
tipos distintos de anotação. O que NÃO casa:

| anotação → coluna | quantos | o que é |
|---|---:|---|
| `datetime` → `time` | **34** | hora do dia anotada como instante |
| `datetime` → `date` | **16** | dia anotado como instante (os dois do `dashboard_movimentos` são daqui) |
| `string` → `text` | 15 | anotação mais estreita que a coluna |
| `integer` → `tinyint(1)`/`smallint` | 9 | inteiro pequeno |
| `text` → `varchar` | 1 | o inverso do de cima |
| `string` → `double` | 1 | `convenios.porcentagem` |
| `integer` → `varchar` | 1 | `dashboard_colaboradores.total_produtos_vip_vendidos` |
| `float` → `varchar` | 1 | `dashboard_colaboradores.total_produtos_vip_vendidos_reais` |
| `float` → `int` | 1 | `dashboard_faturamento.total_clientes_antigos` |

Os dois bancos dão **os mesmos números** em todas as linhas acima — mais uma vez,
quem discorda dos dois é a anotação.

#### E uma correção no parágrafo de cima: a anotação NÃO é ignorada pelo gerador

Este débito afirma que `scripts/gerar-ddl-postgres.php` *"lê o `mysql.sql` […]
**nunca** a anotação"*. Isso está **errado como absoluto**, e o próprio gerador diz
por quê (`gerar-ddl-postgres.php:229-231`):

> *"Só o par inteiro/booleano é resolvido pela anotação. É onde MySQL e PostgreSQL
> discordam de fato, e onde converter pelo tipo da coluna produziria erro de
> execução — nos dois sentidos."*

E a razão é medida, não estética: *"Convertendo pelo tipo do MySQL, **70 colunas**
ficariam com o tipo errado — nos dois sentidos. Quem decide, portanto, é a anotação
da entidade."* O MySQL não sabe dizer a diferença entre `int`, `tinyint` e
`tinyint(1)`; o PostgreSQL não perdoa. O argumento de "não é risco de corte"
continua valendo para a **nulabilidade**, que é o assunto deste débito — mas a
frase absoluta não.

#### As 29 colunas em que os dois bancos DISCORDAM de tipo — e as 29 são deliberadas

Na nulabilidade, este débito mediu **zero** divergências entre os dois bancos. No
tipo, são **29 de 906 colunas comuns** — e eu cheguei a achar que tinha encontrado
um defeito, porque a forma assusta: `tinyint(1)`, que é o booleano canônico do
MySQL, virou `smallint` no PostgreSQL, enquanto `int` virou `boolean`. Parece
invertido. Não é:

| família | quantas | por que |
|---|---:|---|
| `int` → `bigint` | **12** | `agendas`, `vendas` e `vendas_produtos` são as `GRANDES` do gerador: chave primária e toda estrangeira que aponta para elas viram `bigint`, por projeção de centenas de milhões de linhas em 4.000 inquilinos |
| `int`/`tinyint` → `boolean` | **7** | a anotação diz `boolean`, então o gerador escreve `boolean` — é a regra de cima |
| `tinyint(1)` → `smallint` | **8** | a anotação diz `integer`, então o gerador escreve `smallint`. A coluna *parece* booleana no MySQL e não é, segundo quem mapeou |
| `char` → `varchar` | 1 | `string` nos dois; diferença de dialeto |
| `tinyint` → `boolean` (restante) | 1 | idem à segunda família |

**Nenhuma das 29 é defeito**, e registrar isso tem valor próprio: a próxima pessoa
que cruzar os dois `information_schema` vai ver a mesma forma estranha e precisar
de duas horas para chegar onde este parágrafo chega. `vendas.comanda_temp` — a
coluna do **D51** — é uma das sete da segunda família.

#### Os três casos singulares que merecem olhada, e o que a olhada achou

`convenios.porcentagem` é `double` anotado como `string` — porcentagem de desconto
lida como texto. E dois estão em `dashboard_colaboradores`, que é a tabela do
**D40** (linhas duplicadas e divergentes, causa declarada como não confirmada):
uma **contagem** e um **total em dinheiro** guardados em `varchar(255)`.

Fui atrás, e o resultado é **latente, não vivo** — medido em 12/09/2026:

| | |
|---|---|
| linhas em `dashboard_colaboradores` | 18.257 |
| nulas nas duas colunas | 15.283 (84%) |
| valores com **vírgula** decimal | **0** |
| valores com ponto decimal | 297 |
| consultas que ORDENAM ou comparam essas colunas | **nenhuma** |

`DashboardColaboradorRepository::colaboradorDestaque()` monta
`ORDER BY das.$item DESC` com o nome da coluna interpolado — o que acende dois
alarmes e apaga os dois: os **13** chamadores passam **literal**
(`DashboardColaboradoresController:43-55`), então não há injeção alcançável pela
URL — é por isso que a varredura do **D73** não a listou —, e **nenhum dos 13 é uma
das duas colunas `varchar`**, então ninguém ordena texto como se fosse número.

Ordenar por elas ordenaria `"9"` acima de `"85"`. Ninguém ordena hoje. Fica escrito
aqui para que o décimo quarto chamador não seja o primeiro a descobrir.

#### E agora há portão, porque número que não se repete apodrece

`scripts/conferir-tipos-do-mapeamento.php`, nos **32** portões e **no CI**. Ele
cruza o tipo da anotação com o `information_schema` do banco apontado e compara com
uma linha de base **por banco**:

| | desacordos |
|---|---:|
| MySQL | **70** |
| PostgreSQL | **54** |

Ele reprova quando a contagem **muda** — para cima ou para baixo. Para cima porque
desacordo novo é trabalho novo; para baixo porque o alinhamento do D65 andou e a
linha de base precisa descer, ou o próximo desacordo entra de graça. E imprime a
lista inteira quando muda, pela lição do `conferir-guarda-do-erp.php`: trocar um
desacordo por outro mantém o total, e mesmo assim é trabalho.

**Falsificado:** anotando `dashboard_movimentos.data_criacao` como `string`, ele
acusa *"a contagem mudou: eram 70 e agora são 71"*, nomeia a coluna e sai com 1.

**E a diferença de 16 entre os dois bancos é a notícia boa deste débito.** São
exatamente os 7 `boolean` sobre coluna inteira mais os 9 `integer` sobre
`tinyint(1)`: nesses 16, o esquema do **PostgreSQL concorda com a anotação e o do
MySQL não**, porque `gerar-ddl-postgres.php:229` resolve o par inteiro/booleano pela
anotação. Ou seja — o banco para o qual estamos migrando é **mais fiel ao
mapeamento** do que o de onde saímos.

**Ele roda no CI**, e isso foi conferido antes de ligar: contra
`franquiabv_pgcontrato`, montado do mesmo `postgres/schema.sql` que o job usa, dá
os mesmos 54. Ao contrário dos irmãos que dependem de banco
(`conferir-booleano-em-coluna-inteira.php`, `conferir-relacoes-doctrine.php`), este
**não precisa da cópia de produção** — basta o esquema. *Portão que não roda é pior
que portão que não existe*, e isso foi aprendido caro em 11/09.

**O conserto é o mesmo e a hora é a mesma:** alinhar a anotação ao banco, depois do
corte.

---

### D66 — A suíte de contrato falha sozinha, e todo "35 de 35" desta semana vale menos do que parecia

Descoberto em 31/08/2026, ao aplicar os 29 `inversedBy` do **D64**.

**O susto primeiro.** A execução seguinte à mudança deu **6 falhas**, e a
atribuição óbvia era a mudança de mapeamento. Não era.

**A prova, e é o que torna este débito um débito:** com a mudança guardada no
stash — **o mesmo código, byte a byte** — duas execuções seguidas deram

| Execução | Resultado |
|---|---|
| 1ª | **OK (35 tests, 36 assertions)** |
| 2ª | **Failures: 3** |

A suíte falha sozinha.

#### O que as falhas são, e o que não são

Todas trazem `"status": 0`. Isso não é payload diferente: é **o `curl` desistindo
antes de qualquer resposta**, com `CURLOPT_TIMEOUT => 30` em
`ContratoTestCase:157`.

Elevando o limite para 120 segundos, passa **35 de 35 com e sem a mudança de
mapeamento**. É tempo, e só tempo.

#### E o número não fecha

Medido direto, com a aplicação apontada para `franquiabv_contrato`, o endpoint
mais lento dos que falham responde em **1 a 1,6 segundo**, estável em quatro
medições seguidas:

```
cupons/selecionar/1   http=200  tempo=1,37s
cupons/selecionar/1   http=200  tempo=0,98s
cupons/selecionar/1   http=200  tempo=1,62s
cupons/selecionar/1   http=200  tempo=1,23s
```

**Por que dentro da suíte ele às vezes passa de 30 segundos, eu não sei.** O que
já foi descartado:

| Hipótese | Como caiu |
|---|---|
| Carga da máquina | `docker stats` com tudo entre 0% e 8% de CPU |
| Cache de configuração do Laminas | `config_cache_enabled => false`, e `data/cache/` vazio |
| A aplicação no banco errado | a guarda de ambiente teria abortado a suíte inteira |
| Payload divergente | com 120 s passa tudo, sem tocar em snapshot nenhum |

As duas que restavam foram medidas em 31/08/2026, e **as duas caíram.**

#### A medição que derrubou as duas hipóteses

Um só conjunto de dados as separa. Com a aplicação apontada para
`franquiabv_contrato`, proxies e cache apagados e o `local.php` recém-reescrito,
25 requisições seguidas ao endpoint mais pesado, de dentro do container,
anotando a cada uma o tempo e a contagem de arquivos de proxy:

| # | s desde o início | tempo | arquivos de proxy |
|---|---|---|---|
| 1 | 7,31 | **7,29 s** | 2 |
| 2 | 9,93 | 2,58 s | 2 |
| 3 | 11,33 | 1,36 s | 2 |
| … | … | 1,1 a 2,5 s | 2 |
| 25 | 51,31 | 2,13 s | 2 |

**Hipótese 1, proxy do Doctrine — caiu.** A contagem é **2 já na primeira
requisição e nunca muda**. Os proxies são gerados uma vez e reusados; não há
custo por requisição.

Junto com ela cai uma afirmação errada deste próprio registro: eu havia escrito
que "não há `generate_proxies` em configuração nenhuma". Há —
`vendor/doctrine/doctrine-orm-module/config/module.config.php:64` traz
`generate_proxies => true`, e a configuração efetiva resolve para isso. O
diretório estava vazio porque `scripts/rodar-contrato.sh` o apaga no início *e*
no `trap` de saída. **Diretório vazio depois de uma execução não é prova de que
nada foi gerado durante ela** — foi só isso que eu havia lido.

**Hipótese 2, janela do opcache — caiu.** A lentidão não decai em dois segundos:
ela **persiste por 51 segundos**, do começo ao fim da série. Se fosse a janela de
revalidação, as requisições após o segundo 2 teriam voltado ao normal. As
requisições 3 a 25 estão todas entre 1,1 e 2,5 segundos.

O que a série mostra de verdade é um custo frio **real mas único** — 7,29 s na
primeira contra ~1,8 s de regime — e um endpoint que custa 1,1 a 2,5 s sempre.
Um endpoint leve na mesma série responde em 0,20 s, então o arranque do
framework não é o custo: a consulta é.

#### E aí o problema muda de forma

Cinco execuções completas da suíte, seguidas, deram **35 de 35 as cinco vezes**
(30 a 38 segundos cada). O flake não reproduziu. Mas o `--log-junit` dessas cinco
execuções dá o número que reformula tudo:

| | |
|---|---|
| teste mais lento das 175 medições | `testAgendaSelecionar`, **4,11 s** |
| limite do `curl` | 30 s |
| folga | **25,89 s** |

**Uma falha por tempo limite aqui não é "ficou lento": é 7 vezes o pior tempo já
medido.** A suíte não se arrasta até os 30 segundos — alguma coisa *trava*.

Isso muda o que procurar. Não é afinar desempenho, é achar o que bloqueia: uma
espera por trava no banco, uma conexão que não volta, um worker preso. É por aí
que a próxima investigação deve começar, e não pela lentidão.

#### UM MECANISMO MEDIDO em 22/09/2026 — o pool do PHP-FPM tem CINCO filhos, e já bateu no teto nove vezes

A ficha acima terminava com um pedido: *"não é afinar desempenho, é achar o que
bloqueia: uma espera por trava no banco, uma conexão que não volta, **um worker
preso**"*. Esta é a medição do terceiro.

##### O que foi medido

```
pm.max_children     = 5
pm.start_servers    = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
```

E o próprio FPM reclama, no registro do contêiner:

```
WARNING: [pool www] server reached pm.max_children setting (5), consider raising it
```

**Nove vezes**, em 09/09, 10/09 (×3), 11/09 (×2), 16/09 (×2) e **21/09 às
23:57** — esta última durante a minha própria rodada de portões, o que é a
demonstração mais direta possível: basta outra coisa estar usando a aplicação.

##### Por que isto casa com a forma do defeito, e não com a lentidão

A reformulação desta ficha era que **uma falha por tempo limite aqui é 7 vezes o
pior tempo já medido** — a suíte não se arrasta até os 30 segundos, alguma coisa
trava. Teto de pool produz exatamente isso: enquanto os cinco filhos estiverem
ocupados, o nginx **enfileira**, e a requisição não anda. Não é lentidão
proporcional; é espera até alguém liberar.

E casa com a intermitência: o mesmo código falha ou passa conforme **o que mais
estiver batendo na aplicação naquele minuto** — outro portão, o navegador aberto,
uma rotina de cron. *"A suíte falha sozinha"* sempre quis dizer "sem mudança de
código", e não "com mais nada rodando".

##### Duas ressalvas, e elas importam

**1. Isto NÃO explica o incidente original.** O registro do contêiner começa em
**07/09**, e o D66 nasceu em **31/08**. Não há linha para olhar. Afirmar que o
teto causou aquelas seis falhas seria inventar.

**2. NÃO é configuração deste projeto.** `pm.max_children = 5` vem da imagem
base do PHP; não há `max_children` em `Dockerfile`, `docker-compose` ou `.conf`
do repositório. **E o CI também não a sobrescreve** — o mesmo teto vale lá.

##### O ensaio que decide, e por que não o fiz agora

Rodar a suíte de contrato **enquanto se observa o registro do FPM**: se uma falha
por tempo limite coincidir com a linha de `max_children`, o mecanismo está
confirmado; se falhar sem ela, é outro.

Não rodei porque `rodar-contrato.sh` **reponta o `local.php`** para o banco de
contrato, e deixar isso a meio caminho numa madrugada sem ninguém olhando é o
risco que o registro do D190 e o do `definir-senha-contrato.php` já descrevem.
Com alguém acordado, é um comando.

##### Se confirmar, o conserto não é subir o teto

Subir `max_children` esconde o sintoma e **destrói a reprodução**. O conserto é
decidir o que a suíte deve fazer quando o pool está cheio — e o `CURLOPT_TIMEOUT
=> 30` passa a ser a pergunta certa: hoje ele não distingue *"o endpoint quebrou"*
de *"não havia worker livre"*, que são defeitos diferentes com o mesmo rosto.

#### O ENSAIO FOI FEITO em 22/09/2026 — o flake reproduziu, as DUAS hipóteses caíram, e o instrumento não conseguia diagnosticar trava

Quatro execuções seguidas, com o registro do FPM observado em cada uma:

| # | condição | duração | resultado |
|---:|---|---:|---|
| 1 | logo após recriar o banco | **237 s** | **3 falhas** |
| 2 | quente | 106 s | OK |
| 3 | logo após recriar de novo | 108 s | OK |
| 4 | com o limite novo, 60 s | 94 s | OK |

##### O flake reproduziu, e a testemunha ficou

As três falhas da execução 1 são `testPedirCodigoRecusaGet`,
`testPedirCodigoSemTelefone` e `testPedirCodigoComTelefoneDesconhecido` — os
**três primeiros casos do arquivo, em sequência**, cada um exatamente 30 s:

```
resolucao de nome  0,002115 s
conexao            0,002480 s
pronto para enviar 0,002534 s
primeiro byte      0,000000 s
total             30,002228 s
```

**Conectou e a aplicação não respondeu byte nenhum.** Depois delas, os outros 64
casos passaram. É janela de tempo, e não endereço — os três nem sequer mandam
SMS: são todos casos de **recusa**.

##### Hipótese 1, o pool do PHP-FPM — CAIU

Era a minha, escrita ontem. **`max_children` não foi atingido em nenhuma das
quatro execuções** — zero linhas no registro do FPM nos quatro intervalos. O teto
de 5 filhos é real e já foi atingido nove vezes, mas **não é o que derruba esta
suíte**.

##### Hipótese 2, custo frio depois de recriar o banco — CAIU

A execução 1 veio logo depois de um `--montar`, e pareceu explicação. A execução
**3 teve a condição idêntica** — banco recriado imediatamente antes — e passou em
108 s. Recriar não reproduz.

##### O que APARECEU, e é mais afiado que as duas

```
innodb_lock_wait_timeout   50 s   (medido)
CURLOPT_TIMEOUT            30 s   (era)
```

**O instrumento desistia antes do banco.** Numa espera por trava, o MySQL aguarda
até 50 s antes de devolver erro; o curl largava aos 30. O resultado é que uma
trava **nunca podia se apresentar como trava** — ela chegava sempre como
*"conectou e não respondeu byte nenhum"*, indistinguível de worker preso, de
chamada de rede sem tempo limite (**D67**) ou de laço infinito.

Isto reinterpreta uma frase desta própria ficha. Ela dizia, de 31/08:
*"Elevando o limite para 120 segundos, passa 35 de 35 (…) É tempo, e só tempo."*
Não era só tempo: **era o limite passando por baixo do limite do banco**, e
apagando a única pista que teria nome.

##### O que entrou

`CURLOPT_TIMEOUT` de **30 para 60 segundos**, acima dos 50 do InnoDB. Isto
**não conserta o D66** — conserta o instrumento: da próxima vez, uma trava chega
com o erro do MySQL, e não como silêncio. E não esconde travamento de verdade:
60 s continua sendo **15 vezes** o pior tempo já medido nesta suíte (4,11 s).

Execução 4, com o limite novo: **OK, 67 testes, 94 s**.

##### O candidato que sobra, com a história causal

Antes da execução 1 eu havia **interrompido** uma rodada de portões no meio do
`medir-cron-no-postgres` — e rotinas de cron gravam. É exatamente o padrão que a
memória desta casa registra: *matar a suíte deixa trava*. Não há como provar em
retrospecto (as transações já haviam saído quando fui olhar), mas é a única
explicação que sobrevive às duas refutações acima, e agora **a próxima vez vai
deixar rastro**.

**Antes de rodar a suíte, olhe `information_schema.INNODB_TRX`.** Se houver
transação `RUNNING` antiga, o vermelho que vier não é do código.

#### A TRAVA SEGURADA DE PROPÓSITO, em 22/09/2026 — o ensaio que fecha a cadeia

Decisão do dono: *"segure a trava de propósito e rode"*. Feito, em dois braços,
com uma transação que **não grava nada** (`SELECT … FOR UPDATE` + `SLEEP` +
`ROLLBACK`) e se solta sozinha, sobre o banco **descartável** de contrato.

##### Braço 1 — a trava durante o PREPARO

O `definir-senha-contrato.php` faz `DELETE FROM clientes_sessoes WHERE telefone
= ?`. Com a trava de pé ele esperou os 50 s e apanhou:

```
Doctrine\DBAL\Exception\LockWaitTimeoutException:
  'DELETE FROM clientes_sessoes WHERE telefone = ?' com ["+5547320000000"]
  SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded
```

A suíte abortou com **255**, antes de rodar um caso. Isso é comportamento
**bom**: uma trava perdida não produz vermelho esquisito no meio dos testes —
ela estoura no preparo, com nome.

##### Braço 2 — a trava durante os TESTES, e é a que responde a pergunta

Com a trava tomada **depois** do preparo, a falha chegou assim:

```
Failed asserting that 500 is identical to 202
```

**A aplicação RESPONDEU.** Com os 30 s de antes, o curl teria desistido aos 30 —
**antes** dos 50 do InnoDB — e isso teria aparecido como *"conectou e não
respondeu byte nenhum"*, que é a assinatura exata do flake. O conserto do
instrumento está **provado por experimento**, e não por aritmética.

##### E o relatório ainda jogava fora a testemunha

O 500 chegava como número: *"500 is identical to 202"*, sem uma palavra sobre o
porquê. Conferido que **nenhum dos 67 casos espera 5xx**, o auxiliar de
requisição passou a falhar com o corpo e com o caminho:

```
POST /api/v4/sessoes respondeu 500 -- a aplicacao quebrou.

  corpo:
  {"sucesso":false,"mensagem":"Não foi possível atender a este pedido."}

  O CORPO E GENERICO DE PROPOSITO: a API nao vaza interno, e esta certa.
  O MOTIVO fica no log do conteiner, com a excecao e o SQL por extenso:
      docker logs franquiabv-app-1 --since 5m | grep -i 'PHP message'
  Se disser `1205 Lock wait timeout`, o vermelho e de TRAVA e nao de codigo.
```

E o log tinha mesmo o nome:

```
LockWaitTimeoutException ... 'UPDATE clientes_sessoes SET tentativas = ? WHERE id = ?'
SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded
```

##### O mecanismo do órfão foi reproduzido ao vivo, sem querer

Ao encerrar o primeiro braço com `TaskStop`, **a transação continuou de pé**: o
cliente morreu e a sessão do MySQL dentro do contêiner sobreviveu segurando as
cinco linhas. Foi preciso `KILL 21111`. É exatamente o mecanismo que esta ficha
suspeitava da execução 1 — demonstrado, ainda que em outro papel.

##### O que este ensaio NÃO prova

**Não prova que a execução 1 foi trava.** Prova que, se for, da próxima vez a
trava chega com nome em vez de silêncio. O flake original continua sem causa
atribuída — o que mudou é que ele perdeu o esconderijo.

Suíte limpa depois de tudo: **67 testes, 107 s, verde**, e o `local.php`
restaurado byte a byte nas sete execuções desta sessão.

#### A PONTE DE ARQUIVOS, medida em 05/09/2026 — e ela já estava escrita aqui

Encontrei o D66 duas vezes num dia: duas execuções seguidas da suíte falharam em
testes **diferentes**, por tempo esgotado, com o mesmo código. Fui medir.

**Onde o custo está, e onde não está:**

| alvo | mediana | máximo |
|---|---:|---:|
| arquivo estático (só nginx) | **5 ms** | 525 ms |
| endpoint PHP leve | **320 ms** | 2,4 s |

A rede e o nginx não são o custo. O PHP é — e um pedido trivial varia oito vezes.

**Por quê, com número:** `/var/www/html` é um bind mount **v9fs**, a ponte que o
Docker Desktop usa para expor o `D:\` do Windows. Medido de dentro do contêiner,
percorrendo arquivos de verdade:

| origem | por arquivo |
|---|---:|
| `module/` (bind mount v9fs) | **3,63 ms** |
| `vendor/` (volume nomeado) | **0,03 ms** |

**A ponte custa 121 vezes mais.** Com os caches de configuração desligados, um
pedido do Laminas toca centenas de arquivos da aplicação — e 100 arquivos a
3,63 ms dão 0,36 s, que é a mediana observada. O número fecha.

*Isto não é descoberta minha, e vale dizer.* `docker/php/php.ini` já registra,
com medida: *"com 0 o PHP faz stat() em cada um dos ~4.900 arquivos do projeto a
cada requisição, e sobre um bind mount do Docker no Windows isso custava cerca de
9 segundos por página"*. O que faltava era **este débito ligar-se àquele
comentário** — a tabela de hipóteses descartadas acima não menciona o sistema de
arquivos uma vez sequer.

#### Uma hipótese testada que NÃO se sustentou

Se a revalidação do opcache (`revalidate_freq = 2`) fosse a causa da cauda, uma
janela longa a derrubaria. Medido, três execuções completas de cada lado:

| `revalidate_freq` | execuções |
|---|---|
| 2 (como está) | 105 s, 94 s, **79 s** |
| 300 | 87 s, 82 s, 88 s |

**As faixas se sobrepõem** — a execução mais rápida de todas foi com a janela
curta. A primeira leitura sugeria 17% de ganho; com três de cada lado, ela era
ruído. **A hipótese cai.**

#### E uma correção de raciocínio na tabela acima

A linha *"Cache de configuração do Laminas — `config_cache_enabled => false`"*
está listada como hipótese **descartada**, e o argumento estava invertido: o
cache estar desligado é o **mecanismo**, não a refutação. Com ele ligado, num
endpoint trivial:

| | sem cache | com cache |
|---|---:|---:|
| mediana | 320 ms | **244 ms** |
| máximo | 2,43 s | **1,15 s** |

Num endpoint que toca o Doctrine, porém, a diferença some no ruído (444 ms
contra 414 ms). Ele é **um** contribuinte, e não a causa.

#### O que continua sem resposta

Nada disto explica os **30 segundos**. O pior tempo que consegui provocar num
endpoint foi 12,9 s, uma vez, e em série controlada o máximo foi 2,4 s. A
observação anterior continua valendo e agora com mais razão: **alguma coisa
trava**, e travar não é o mesmo que estar lento.

O que este registro acrescenta é onde *não* procurar — não é a rede, não é o
nginx, não é a revalidação do opcache — e um custo de base que explica a
lentidão comum sem explicar o travamento.

#### Por que isto importa mais que a falha em si

**"35 de 35" foi reportado como evidência dezenas de vezes nesta semana**, aqui e
no roadmap. Parte desses verdes pode ter sido sorte.

E o lado pior é o simétrico: **a partir de agora existe a tentação de descartar
uma falha real como "flakeou de novo"**. Suíte que falha sozinha não protege — e
ainda dá cobertura para ignorar quando ela acerta.

#### Ao resolver — e o que NÃO fazer

**Não é elevar o limite para 120 e seguir.** Isso esconde o sintoma sem explicar
a causa, e o número certo depende de decidir quanto tempo um endpoint do
aplicativo pode legitimamente levar — que é pergunta de negócio, não de teste.

#### A DECISÃO, tomada em 31/08/2026

**Por decisão sua, este débito passa a esperar evidência, não trabalho.** Nada de
laço de reprodução, nada de mexer no limite de 30 s. A instrumentação está posta,
e a próxima falha — quando vier — vai carregar o diagnóstico consigo.

**O que isso aceita conscientemente:** pode demorar. Até lá, todo "35 de 35"
deste documento carrega um asterisco, e toda falha futura terá a desculpa pronta
de "flakeou de novo". A defesa contra a segunda parte é esta seção: quando a
suíte falhar, **leia o diagnóstico antes de rerodar**. Ele diz de que lado
travou, e ele desaparece na execução seguinte.

**O que NÃO fazer, e continua valendo:** elevar o limite. Com 120 s passa tudo, e
o sintoma some sem que ninguém saiba por quê — foi medido.

**Uma opção que foi considerada e recusada:** baixar o limite de 30 s para 10 s.
Ela transformaria um travamento raro num sinal mais frequente, já que o pior
tempo medido é 4,11 s. Foi recusada pelo risco simétrico: numa máquina carregada
a suíte passaria a falhar por lentidão legítima, e o remédio viraria a doença.

#### O caminho, quando a evidência chegar

Procurar o que **bloqueia** por mais de 30 segundos, com 25 s de folga sobre o
pior tempo já medido.

**A instrumentação para pegá-lo já está posta** (31/08/2026). Até aqui toda
falha chegava como `"status": 0` — um número que diz só "o curl desistiu", sem
dizer de quê, e que fazia as três falhas serem indistinguíveis entre si e de
qualquer outra causa. Agora `ContratoTestCase` guarda o erro do `curl` e a
repartição de tempos, e **calcula o veredito**:

| `connect_time` | `starttransfer_time` | Veredito |
|---|---|---|
| exatamente 0 | — | a conexão nunca se completou; procure fora do PHP |
| > 0 | exatamente 0 | conectou e a aplicação não respondeu byte nenhum — **este é o caso que este débito persegue** |
| > 0 | > 0 | a resposta começou e foi cortada no meio |

Os dois primeiros ramos foram postos à prova, cada um contra a sua causa
fabricada — um ouvinte que aceita e nunca responde, e um endereço que engole o
pacote — e cada um acusou o seu.

**E a primeira versão dessa mensagem estava errada**, o que vale registrar
porque é o mesmo erro de sempre nesta semana. Ela imprimia os tempos com três
casas decimais e pedia ao leitor que reparasse num zero. Contra o ouvinte que
aceita e não responde, ela imprimiu `conexao 0,000 s` — porque conectar em
localhost leva menos de um milissegundo — e teria mandado a próxima investigação
procurar rede, o único lugar onde o problema não está. Seis casas decimais, e o
veredito calculado em código em vez de deixado para o olho.

**Da próxima vez que a suíte flakear, a falha dirá de que lado ela travou.** É a
única testemunha que existe, e ela desaparece na execução seguinte: anote os
números aqui antes de rodar de novo.

**Correção de registro:** a mensagem do commit `30d4696` diz que esta suspeita
ficou "registrada no D61". Não ficou — o D61 é sobre snapshots que só têm o caso
vazio, e este débito não existia quando aquela mensagem foi escrita. Fica dito
aqui porque mensagem de commit não se corrige, e uma remissão para o lugar errado
custa a próxima pessoa procurar onde não está.


#### Mais uma ocorrência, 23/09/2026 às 08:1x — anotada antes de rodar de novo

A suíte de contrato rodou logo depois do corredor e de
`montar-banco-contrato.sh`, com o banco recém-montado, e **quatro** casos
bateram nos 60 s, todos com o mesmo veredito: **conectou e a aplicação não
respondeu byte nenhum**.

| caso | rota | conexão | primeiro byte | total |
|---|---|---:|---:|---:|
| `ApiV4Test::testCredencialQueNaoAutentica` | `GET /api/v4/clientes` | 0,001592 s | 0 | 60,000993 s |
| `AplicativoApiTest::testVersaoDoAplicativo` | `GET /api/versao/app` | 0,001738 s | 0 | 59,999968 s |
| `AplicativoApiTest::testUnidadesSelecionar` | `GET /api/agendas/getUnidades` | 0,001832 s | 0 | 60,000817 s |
| `AplicativoApiTest::testUnidadeSelecionar` | `GET /api/agendas/getUnidade/90001000` | 0,001547 s | 0 | 60,001149 s |

O que se olhou em seguida, sem rodar nada:

- **O registro do FPM não tem linha de `max_children` nesta rodada** — a última
  é de 22/09 às 20:47. O teto do pool não explica esta ocorrência.
- **`INNODB_TRX` vazio** logo depois — nenhuma transação aberta. Se houve trava,
  ela já tinha soltado quando se olhou.
- As quatro rotas são da API do aplicativo, e **nenhuma foi tocada** pela mudança
  que se estava verificando (o formatador da sessão de `/adm/api`).

A execução seguinte, com o banco montado de novo e nada mais mudado: **67 de 67**,
e também sem linha de `max_children` no registro. Mesmo código, outro resultado
— é o retrato que este débito tem desde 31/08.

#### E no ensaio de recusas, 23/09/2026, antes das 10:27

A primeira rodada de `ensaiar-recusas-adm-api.php` do B10, contra o banco de
contrato, passou pelos casos 1 a 44 e **parou no 45** até o `timeout 300` a
matar. O caso 45 é um `POST /adm/api/agendas/fechamentos` que o servidor recusa
com 422 antes de tocar no banco. Não houve linha de saída do caso, nem erro.

- A rodada seguinte, com o mesmo código, passou os 46 casos em **198 s**, e o 45
  levou **3 s**.
- Depois da morte, nenhum processo do ensaio ficou vivo no contêiner, o banco de
  contrato não tinha resíduo do ensaio, e o banco dev não recebeu nada (os
  pedidos HTTP do ensaio usam o `local.php`, que já tinha voltado para o dev).

É a mesma cara das ocorrências acima: o pedido entra e a aplicação não responde.

#### No PostgreSQL, 27/09/2026 — anotada antes de rodar de novo

A suíte de contrato no PostgreSQL, na verificação da fatia 3 de clientes, levou
**1.046 s** (o normal é ~250) e três casos bateram nos 60 s, os três com o
veredito de sempre:

| caso | rota | conexão | primeiro byte | total |
|---|---|---:|---:|---:|
| `ApiV4Test::testUmIdDeOutroTelefoneERecusadoComToken` | `GET /api/v4/clientes/90001152/consumo` | 0,002881 s | 0 | 60,082047 s |
| `AplicativoApiTest::testIndicacoesListarAutenticado` | `GET /api/cliente/indicacoes/90001000` | 0,002457 s | 0 | 59,999804 s |
| `AplicativoApiTest::testClienteSelecionarAutenticado` | `GET /api/agendas/getCliente?telefone=…` | 0,003137 s | 0 | 60,001673 s |

- **A máquina estava carregada, e isto é o que a distingue das anteriores**:
  31 agentes de uma auditoria de código lendo o repositório e consultando o
  banco dev, e a suíte do front rodada cinco vezes na mesma janela.
- **Sem linha de `max_children`** no registro do FPM nos 90 minutos anteriores,
  e **nenhuma transação aberta** no PostgreSQL logo depois (`pg_stat_activity`:
  duas conexões ociosas no banco de contrato).
- As três rotas são da API do aplicativo, e nenhuma foi tocada pela mudança que
  se verificava; a mesma suíte, no MySQL, na mesma rodada, deu 67 de 67.
- Na mesma janela, o ensaio de permissão no PostgreSQL saiu 2 com "SEM
  RESPOSTA" num `GET /adm/api/clientes` que só devia devolver 403, e os outros
  ensaios levaram de duas a três vezes o normal (o de recusas, 739 s).

**De novo, com a máquina parada** e nada mais mudado: a suíte de contrato no
PostgreSQL, **67 de 67 em 139 s**, e o ensaio de permissão, **42 de 42**.

**Esta ocorrência não explica as de antes.** A tabela de hipóteses lá em cima
descartou a carga da máquina (`docker stats` entre 0% e 8% de CPU) para as
ocorrências de agosto e setembro. Esta é de outra espécie: a carga estava lá,
medida do lado de fora, e sem ela o erro sumiu. Fica anotada para que ninguém
a conte como mais um caso do flake sem causa -- nem como a causa dele.

### D67 — Nove chamadas de rede sem tempo limite — 7 CORRIGIDAS em 31/08/2026, 2 congeladas — PARCIAL em 10/10/2026: as duas do OneSignal têm tempo limite e SSL; resta o teto do php-fpm, para o corte — PARCIAL em 11/10/2026: o teto escrito no corte

Descoberto em 31/08/2026, procurando o que poderia travar por 30 segundos no
**D66**. **Não é a causa do D66** — a ligação foi conferida e não existe, e isso
está dito no fim deste registro. É achado independente, e mais grave.

#### O que foi medido

Nove chamadas `curl` no código de produção **sem `CURLOPT_TIMEOUT`**:

| Arquivo | `curl_init` | com tempo limite |
|---|---|---|
| `module/ISBase/src/Util/OneSignal.php` | 2 | **0** |
| `module/ISBase/src/Util/Asaas.php` | 5 | **0** |
| `module/ISApi/src/Service/CronJobService.php` (Yungas) | 2 | **0** |

**Este registro nasceu dizendo dez, e eram nove.** O `grep -c curl_init` que
eu usei primeiro contou uma linha comentada no `CronJobService` — a chamada
do `yungasNpsAtualizar` (**D43**), que está desligada atrás de `//`. Quem
corrigiu foi `scripts/conferir-tempo-limite-rede.php`, que lê o PHP pelo
`token_get_all` em vez de por texto, e por isso não vê comentário. É a
terceira vez nesta semana que a ferramenta acha o erro da contagem feita à
mão, e a lição continua a mesma: **`grep` conta linhas, não código.**

O padrão do `curl` sem `CURLOPT_TIMEOUT` é **não desistir nunca**. As outras
integrações do sistema — `EvolutionApi`, `WahaApi`, `VisagismoService`,
`ArmazenamentoS3` — todas definem o seu. Estas três não.

#### E não há teto em lugar nenhum acima delas

Medido no container local (a produção está congelada e não foi medida):

| | |
|---|---|
| `max_execution_time` | **0** — o PHP nunca mata o script |
| `request_terminate_timeout` | **não definido** — o php-fpm nunca mata o worker |
| `pm.max_children` | **5** |
| `fastcgi_read_timeout` do nginx | 300 s |

O `fastcgi_read_timeout` limita **quanto o chamador espera**, não quanto o worker
vive. Aos 300 s o nginx devolve 504 e desliga; o processo PHP continua rodando, e
como um laço de cron não emite saída, ele nunca percebe que o cliente sumiu.

**As rotinas de cron são chamadas por URL** — o próprio
`docker/nginx/default.conf:76` diz isso ao justificar o timeout de 300 s. Ou
seja: elas passam pelo mesmo pool de 5 workers que atende o aplicativo dos
clientes.

#### O tamanho da exposição

O envio é **serial, uma chamada HTTP por destinatário**, dentro de um laço
aninhado (`CronJobService:1402`). Na cópia da produção:

| | |
|---|---|
| maior lista de destinatários | **6.573** |
| média por notificação | 940 |
| notificações do tipo push já enviadas | 2.186 |

Uma única campanha grande são 6.573 POSTs em sequência, num worker de cinco,
sem nada que possa interrompê-los.

#### O que NÃO está quebrado — e vale dizer, porque parece

Duas coisas neste código levantam suspeita e foram conferidas:

**A remoção do destinatário da lista está correta.** `setClientesIds` usa
`str_replace`, o que normalmente estraga listas assim (remover o id `12` mordendo
o `123`). Mas a chamada envolve o id nos delimitadores, e todas as **1.398**
listas não vazias começam e terminam com `;`. As concluídas ficaram reduzidas a
`;;`, isto é, todos os ids saíram.

**Um travamento não reenvia a campanha inteira.** O `flush` é por destinatário,
com o id já removido, então a execução seguinte retoma de onde parou em vez de
notificar de novo quem já recebeu.

A trava (`TravaDeExecucao`, **D44**) também não vaza: o `flock` é liberado pelo
sistema quando o processo morre. O problema é que o processo **não morre** — e
enquanto ele estiver pendurado, toda execução seguinte da rotina responde
`Processo já em execução`, que é uma mensagem tranquila para uma rotina que
parou de acontecer.

#### Três achados menores, no mesmo caminho

1. **`OneSignal::ativo()` nunca é chamado.** O método lê `public/app.json` para
   decidir se o push está ligado, e nenhum ponto do sistema o consulta. O
   desligamento de emergência não existe na prática. Liga-se ao **D36**.
2. **Código morto engolindo o log da rotina.** Em `CronJobService:1437` há um
   `return`, e o `logScripts` de `marketingNotificacoes` está na linha 1439 —
   depois dele. A rotina nunca registra que rodou. São 17 `logScripts` para 30
   ações de cron. (Um detector ingênuo apontou 4 trechos; três eram `return` de
   várias linhas e foram descartados na leitura.)
3. **`CURLOPT_SSL_VERIFYPEER => false`** nas duas chamadas do OneSignal — o
   certificado não é conferido, e o cabeçalho leva a chave da API.

#### O que foi corrigido, por decisão sua (31/08/2026)

**Sete das nove receberam tempo limite:** as cinco do Asaas e as duas do
Yungas. `CURLOPT_CONNECTTIMEOUT` de 5 s e `CURLOPT_TIMEOUT` de 30 s.

Os dois números foram provados mordendo, contra causas fabricadas, sem tocar
em nenhuma das APIs reais — chamar o Asaas de verdade é mexer num meio de
pagamento, e não se faz para testar:

| Cenário | Sem os dois | Com os dois |
|---|---|---|
| endereço que engole o pacote | desistiu em **21,04 s** (era o sistema operacional, não o código) | **5,01 s** |
| servidor que aceita e cala | **nunca** desistiria | **30,00 s** |

A segunda linha é a que importa: sem `CURLOPT_TIMEOUT`, um servidor que
aceita a conexão e não responde segura o worker para sempre. É o cenário do
fornecedor com problema, e é o mais provável dos dois.

**Quem confirmou que as sete pegaram não fui eu:** foi o próprio portão, que
passou a acusar exatamente aquelas sete como "estão na lista e não existem
mais". Ler o diff teria sido a mesma pessoa conferindo o próprio trabalho.

Uma dívida pequena nasceu aqui, e fica dita: os números vivem em constantes
na classe `Asaas` e **escritos à mão** no `CronJobService`. Mudar lá não muda
aqui. Dar casa comum a duas constantes exigiria uma classe nova só para elas,
e não pareceu troca boa — mas a divergência é possível.

#### O que continua em aberto

**As duas do OneSignal**, congeladas por instrução sua, e é o par que mais
assusta: são as que rodam em laço, uma chamada por destinatário, com a maior
lista em 6.573.

**E o teto do php-fpm, que não foi posto.** Ele é o que protege contra a
próxima integração escrita sem tempo limite — os sete `curl_setopt` de hoje
protegem só os sete lugares de hoje. `request_terminate_timeout` continua
indefinido, `max_execution_time` continua 0, e o pool continua com 5 workers.

#### Por que o resto não foi corrigido

Instrução em vigor: *"por hora, ignorar tudo do onesignal, visagismo e sms. Anote
como débito tecnico e será resolvido no futuro."* O OneSignal fica registrado, e
só.

O Asaas e o Yungas **não** estão nessa lista — mas a correção é a mesma linha nos
três, e o número certo do tempo limite é uma decisão só. Separá-los faria três
decisões onde há uma.

#### O que impede a décima

`scripts/conferir-tempo-limite-rede.php` guarda as nove numa lista com o motivo
de cada uma, e falha nos dois sentidos: quando aparece uma décima, e quando uma
das nove some sem que a lista seja atualizada — porque lista desatualizada
esconde a próxima. Ele entrou no `scripts/conferir-tudo.sh`, que passou a ser
onze portões.

Foi posto à prova nos dois sentidos, com um arquivo temporário que trazia duas
chamadas irmãs: acusou a que não tinha tempo limite e ignorou a que tinha.

**A granularidade é a função**, e isso é um limite real: uma função que abra
duas conexões e defina o tempo limite só numa delas passa. Nenhuma das nove tem
essa forma hoje.

#### Ao resolver

#### O teto do php-fpm — DECIDIDO em 31/08/2026: fica para o desenho do corte

**Por decisão sua, nada muda no container agora.** A justificativa é boa: a
produção está congelada e a configuração de php-fpm dela é desconhecida, então
qualquer número escolhido aqui seria palpite sobre outro ambiente. E as 7
chamadas já corrigidas reduziram a exposição de nove buracos para dois.

**O requisito fica escrito aqui para não ser redecidido lá.** Quem desenhar o
corte precisa de três coisas, nesta ordem de importância:

| | |
|---|---|
| 1 | `request_terminate_timeout` **acima de 21 minutos** — 1800 s serve |
| 2 | pool separado para as rotinas de cron, com o do aplicativo em teto curto |
| 3 | `pm.max_children` acima de 5 — mas **nunca sozinho**, ver abaixo |

**E um fato que elimina o caminho fácil, para ninguém o tentar de novo:**
`max_execution_time` **não serve**. Ele é a opção óbvia porque o `php.ini` é o
único arquivo versionado dos três — mas ele **não conta tempo gasto fora do
script**: consultas ao banco, operações de socket e chamadas de rede ficam de
fora do relógio. Ou seja, ele não mata um `curl` pendurado, que é exatamente o
caso deste débito. Quem mata é o `request_terminate_timeout`, porque quem o
aplica é o processo mestre do php-fpm e não o script.

**Subir `pm.max_children` sozinho não é teto.** Uma chamada infinita continua
infinita; o esgotamento só demora mais. Vale como acompanhamento de um teto de
verdade, nunca no lugar dele.

**Por que o pool separado é o desenho certo, e não só o teto:** com um teto
único de 30 minutos, cinco rotinas penduradas ainda ocupam os 5 workers por meia
hora — e o aplicativo dos clientes divide esse mesmo pool. `marketingPush` roda
**a cada minuto das 8h às 22h**, em pleno horário de uso. Com pools separados,
uma rotina pendurada nunca tira worker do aplicativo.

#### O número que decide o teto

**O teto do php-fpm tem um número que ninguém tinha medido, e ele decide a
escolha.** `request_terminate_timeout` é global do pool: ele mata *qualquer*
requisição que passe do limite, e as rotinas de cron passam pelo mesmo pool. A
mais demorada é `relatoriosDashboardColaboradoresAtualizar`, medida em **21
minutos na base local** — qualquer teto abaixo disso a mataria toda noite.

E aqui aparece outra coisa: **o crontab dá 600 s a essa rotina.** O `curl` do
agendador desiste aos 10 minutos de um trabalho que leva 21 — todas as noites,
desde sempre. Ele desiste e o worker continua, então o trabalho *termina*; o que
não existe é quem saiba disso. É o mesmo desenho do nginx: o limite de cima
protege o chamador, nunca o processo.

Portanto o teto útil está **acima de 21 minutos**, não abaixo. Algo como 1800 s
não protegeria contra uma chamada lenta — protegeria contra a chamada
*infinita*, que é o caso do D67. Infinito e 30 minutos são coisas muito
diferentes num pool de 5.

Definir um `CURLOPT_TIMEOUT` e um `CURLOPT_CONNECTTIMEOUT` nas nove chamadas, e um
`request_terminate_timeout` no php-fpm como rede de segurança para o que escapar.
O teto do php-fpm é o que protege contra a próxima integração escrita sem
timeout, e é ele que impede que cinco chamadas penduradas derrubem o aplicativo
inteiro.

#### Isto não explica o D66

Conferido: o OneSignal só é alcançado a partir do `CronJobService`, e o Asaas a
partir do `FranquiaService` (relatórios). **Nenhum dos dois está no caminho de
nenhum endpoint exercitado pela suíte de contrato.** A tentação de fechar o D66
com este achado é grande e seria errada.

#### As duas do OneSignal, em 10/10/2026 (PUSH-FCM-ENVIO)

O dono decidiu a troca do OneSignal pelo Firebase, e a ordem de ignorar o OneSignal cedeu para esta migração. A
classe `module/ISBase/src/Util/OneSignal.php` saiu; o envio mora em `module/ISBase/src/Push/`, e o único `curl_init`
dele (`ClienteHttpCurl::postar`) tem os números do Asaas -- 5 s para conectar e 30 s ao todo -- com a conferência do
certificado ligada (`CURLOPT_SSL_VERIFYPEER`, e `CURLOPT_SSL_VERIFYHOST` em 2). O pedido ao OneSignal é o mesmo byte
a byte; o do FCM passa pelo mesmo cliente. A lista do `scripts/conferir-tempo-limite-rede.php` ficou vazia: as nove
têm tempo limite, e qualquer chamada nova sem ele reprova.

**Provado** no `EnvioDePushTest`, contra servidores de mentira no 127.0.0.1: o que aceita e cala por 6 s é largado
em 1 s (o caso usa o limite de 1 s), e o TLS de certificado próprio é recusado. Os dois mutantes -- sem o
`CURLOPT_TIMEOUT`, e com o SSL desligado -- deixam os casos vermelhos, e o primeiro também o portão.

**Na verificação do mesmo dia**, os números da produção ganharam guarda, porque o caso de 1 s só provava o
mecanismo: o `testOsTemposDaProducao` prende os 30 s e os 5 s do cURL que a fábrica monta
(`FabricaDoPush::clienteHttp`), e o construtor recusa o zero -- que no cURL é "sem limite", este débito de volta,
calado. E o `testOCertificadoEConferido` passou a provar o nome além da cadeia: um certificado dado como confiável e
com outro nome (`outro.exemplo`) é recusado, e o mesmo confiável com o nome certo passa. Os mutantes novos --
`TEMPO_LIMITE` em 0, a fábrica com uma hora de limite, o construtor aceitando o zero, `CURLOPT_SSL_VERIFYHOST` em 0
-- deixam os casos vermelhos.

**O que fica:** o teto do php-fpm (`request_terminate_timeout`), decidido em 31/08/2026 para o desenho do corte,
continua sem existir. E um provedor que aceita e cala ainda segura o worker 30 s por destinatário -- numa campanha
de 6.573, horas: o disjuntor para isso (parar a rodada depois de algumas falhas de transporte seguidas) não foi
feito, e está no D461. (O FCM ganhou, na verificação, um disjuntor para o 429 e o 5xx, que são resposta; o tempo
limite, que é silêncio, segue sem ele nos dois provedores.) O liga-desliga do push (o achado 1, `OneSignal::ativo()`) saiu com a classe sem ser ligado a nada: foi ao
lote de perguntas (ligar ao despachante, retirar a tela, ou esperar o aplicativo novo).

#### Em 11/10/2026 (PUSH-TERCEIRA-RODADA): o teto escrito no corte, e o liga-desliga decidido

**Os três requisitos do php-fpm** decididos em 31/08/2026 (o `request_terminate_timeout` acima de 21 minutos, o pool
separado para o cron e o `pm.max_children` nunca sozinho) estão agora em `docs/procedimento-de-corte.md`, "Antes da
janela" > "Preparação, sem tocar em produção", item 4, com o porquê de cada um e o `max_execution_time` que não serve
-- para o corte não os esquecer. **Aplicar é do dono**, no servidor da produção: a configuração do php-fpm de lá não
está no repositório. Nada mudou no contêiner. Este débito segue parcial pelo que o título diz: o teto não existe
em servidor nenhum.

**O liga-desliga, decidido pelo dono** (a resposta 76, a opção (c)): **como está até o aplicativo novo**, e decidido
lá, junto com o que o aplicativo novo lê do `public/app.json`. Nada mudou no código: a tela segue gravando o campo
`push`, e nenhum envio o lê. O guia do aplicativo pede a quem o mantém que diga se o aplicativo lê o arquivo, e para
quê.

#### Em 15/10/2026 (RESPOSTAS-DE-15-10): os três na janela, e o teto curto do site no lote

**A resposta 205 (a) do dono** põe os três requisitos **na janela** da etapa 1 (`docs/procedimento-de-corte-etapa-1.md`,
os passos 8 e 9), e não "antes da janela", e diz que a volta não os desfaz. A verificação da fatia achou que o teto curto
do requisito 2, no pool do site, mataria as rotinas que a volta devolve a ele: foi ao lote como o **D497**, e até a
resposta o pool do site fica com o teto das rotinas. O teto segue sem existir em servidor nenhum.


### D68 — Onze rotinas de cron não deixam rastro, e são as que mais podem travar — DECIDIDO em 31/08/2026, e o portão consertado em 14/09/2026 — DECIDIDO, e a falha do push vai ao registro desde 10/10/2026

Descoberto em 31/08/2026, ao conferir se o registro do `marketingEnvios` — que
estava depois de um `return` (**D67**) — chegava mesmo a acontecer.

#### A prova de que não acontecia

O log das rotinas, `data/logs/log_scripts.txt`, tem 5.451 linhas e vai de
25/04/2024 a 28/07/2026, que é a data da cópia da produção. Nele:

| | |
|---|---|
| última linha de `marketingNotificacoes` | **05/12/2025** |
| campanhas que a produção enviou entre 05/12/2025 e 28/07/2026 | **217**, das quais 59 push |

**A rotina rodou, trabalhou e não escreveu uma linha em oito meses.** Não é o
caso de "não havia campanha": a tabela `notificacoes` conta 217. O `return` de
sucesso estava três linhas acima do registro.

Corrigido em 31/08/2026: o registro passou para antes do laço, que é onde a
própria mensagem dele diz que ele pertence — ela começa com "iniciou". Assim ele
também sobrevive a uma execução que morra no meio, que é justamente quando saber
que ela começou importa. O nome na mensagem também estava velho:
`marketingNotificacoes` virou `marketingEnvios` em algum rename, e as 58 linhas
antigas do log usam o nome anterior.

**Não executei a rotina para provar que agora ela registra.** Executá-la manda
mensagens de verdade para pessoas de verdade. A prova é estrutural: o registro
está antes do `return`, e o detector de código inalcançável, que apontava este
trecho, deixou de apontá-lo.

#### E aí a pergunta maior

Das 30 ações do `CronJobController`, três são endpoints de consulta e não
rotinas. Das 27 que sobram:

| | |
|---|---|
| registram alguma coisa | 15 |
| **não registram nada** | **11** |

**Eram 12 até 31/08**, quando o `yungasNpsAtualizar` foi removido pela decisão
do **D43**. Das 30 ações do controlador restam 29, das quais 26 são rotinas.

As onze mudas:

| Rotina | Observação |
|---|---|
| `notificarAniversario` | **manda push** |
| `clientesNotificarPrimeiraVisita` | **manda push** |
| `clientesNotificarSentimosSuaFalta` | **manda push** |
| `marketingPush` | **manda push** |
| `marketingPushOutros` | **manda push** — delega para `marketingPush` |
| `vencimentoPontuacaoProgramaFidelidade` | |
| `relatoriosDashboardFaturamentoAtualizar` | |
| `npsNotificacoes` | |
| `relatoriosRoyaltiesAtualizar` | delega para `FranquiaService` |
| `relatoriosRoyaltiesFaturasGerar` | delega para `FranquiaService` |
| `relatoriosRoyaltiesFaturasAtualizar` | delega para `FranquiaService` |

**As cinco rotinas que enviam push estão entre as onze mudas.** São exatamente
as que chamam o OneSignal sem tempo limite (**D67**): as que mais podem ficar
penduradas são as que não deixariam rastro de terem ficado.

**Este registro nasceu dizendo oito, e são onze.** A primeira contagem foi
minha, à mão, e ela parou nas ações cujo método de serviço tem o mesmo nome. As
quatro que faltavam delegam para um método de **outro** nome — três para
`FranquiaService`, uma para `marketingPush` — e eu as deixei de fora sem olhar
dentro. É a terceira contagem à mão que erra nesta semana, e as três foram
corrigidas por um roteiro. Este é o roteiro.

#### Por que isto é da mesma família do D43 e do D50

Uma rotina que para não dá erro — ela simplesmente deixa de acontecer, e o
sintoma aparece longe. O D43 é uma rotina que ninguém dispara; o D50 é uma que
dispara e cujo efeito ninguém vê. Este é o terceiro modo: **a que roda e não
conta.** `scripts/conferir-agendamento.php` pega o primeiro caso; nenhum roteiro
pega este.

#### Ao resolver

#### A DECISÃO, tomada em 31/08/2026

**Por decisão sua, nenhuma das onze é instrumentada no legado.** O requisito
passa para o sistema novo, e o portão fica.

O raciocínio que sustenta a escolha: **uma linha de log só vale quando o código
roda**, e este código não vai rodar num lugar onde alguém leria o log. A
produção está congelada, então nada disto a alcança; o legado será substituído.
Instrumentar onze rotinas que estão de saída é trabalho cujo retorno morre com
elas.

#### O PORTÃO PASSAVA NO DEFEITO QUE DEU ORIGEM A ESTE DÉBITO — 14/09/2026

Com a instrumentação decidida como "não se faz", **todo o valor restante deste
débito é o portão**. Ele foi falsificado, e passou no teste que não devia passar.

`conferir-rastro-das-rotinas.php` decidia *"tem rastro"* assim que via o token
`logScripts` em qualquer lugar do corpo do método — **sem conferir se ele é
alcançável**.

**Reproduzi o caso histórico**: movi o registro de `CronJobService::
marketingEnvios()` para depois do `return $this->jsonResponse(true, ...)`, que é
exatamente a forma que passou **oito meses mandando campanha em silêncio**. O
portão saiu **verde, código 0**: *"Nenhuma rotina muda nova."*

O portão que existe para impedir este defeito passava nele.

**Como ficou:** o laço mantém uma pilha, um lugar por bloco aberto, marcando se
aquele bloco já terminou em `return`, `throw` ou `exit`. Um `logScripts` só conta
quando **nenhum** bloco aberto — o dele ou qualquer um que o contenha — já
terminou.

**O `nenhum` é carregante, e olhar só o bloco corrente não basta:**

| forma | conta? | por quê |
|---|---|---|
| `if (a) { return X; }` e depois `logScripts()` | **sim** | o bloco que retornou fechou; o de fora segue vivo |
| `return X;` e depois `logScripts()` | não | mesmo bloco, já terminado |
| `return X;` e depois `if (a) { logScripts(); }` | **não** | bloco novo, mas o de fora já terminou |
| `return` dentro de uma *closure*, e depois `logScripts()` | **sim** | a closure abre o próprio bloco, e o `return` morre com ele |

As quatro foram medidas, uma a uma, e não deduzidas.

**Falsificado:** com o caso histórico de volta, o portão agora sai **1** e nomeia
`marketingEnviosAction`. Na árvore limpa continua verde, com os mesmos **15 com
rastro e 11 mudas** — a regra nova não inventou falso positivo nenhum.

#### E O NÚMERO ESTAVA ERRADO EM TRÊS LUGARES

O título dizia **oito**, o corpo dizia **doze** em três parágrafos, o comentário
do portão dizia **12** — e o portão **mede onze**, que é o que `$conhecidas`
lista. Corrigidos os quatro. É a quarta contagem à mão deste registro a ser
desfeita por um roteiro, e a lição já estava escrita aqui: *quem conta à mão
erra, e o roteiro é o que não erra.*

**O que fica valendo, e é o que importa:**

1. **Requisito do sistema novo:** toda rotina agendada deixa rastro do que fez —
   não só que rodou, mas o quê e quantos. Está escrito na seção da Fase 2 do
   roadmap.
2. **O portão continua de pé.** `conferir-rastro-das-rotinas.php` recusa a
   décima terceira rotina muda no legado. A decisão foi não instrumentar as
   doze, não deixar nascer a próxima.

**O que isso aceita conscientemente:** o legado segue cego pelo tempo que ainda
rodar, e o episódio dos oito meses do `marketingEnvios` pode se repetir sem que
ninguém saiba. O `marketingEnvios` em si já está corrigido — a decisão vale para
as doze restantes.

#### Ao resolver, no sistema novo

Um registro em cada rotina, com o que ela fez e quantos — não só que rodou.

**O portão foi construído**, e eu havia escrito aqui que não valia a pena. Mudei
de ideia por evidência, não por gosto: a conta à mão errou, e errou justamente
neste débito. `scripts/conferir-rastro-das-rotinas.php` lê pelo `token_get_all`,
segue um nível de delegação e recusa a décima terceira rotina muda. Ele entrou
no `conferir-tudo.sh`, que passou a ser doze portões.

Posto à prova nos dois sentidos: com uma ação muda temporária no controller ele
sai com 1 e a nomeia; sem ela, sai com 0. O controller voltou byte a byte, e o
`git diff` confirma.

**A lista conhecida tem doze itens, e isso é o débito, não o portão.** Ele não
cobra as doze — cobra a próxima.

#### A falha do push, em 10/10/2026 (PUSH-FCM-ENVIO)

Com a troca do push (o D461), o envio passou a ler a resposta do provedor, e a falha vai ao
`data/logs/log_scripts.txt` pelo despachante (`ISBase\Push\DespachanteDePush`): o tempo limite, a chave recusada, o
5xx que não passou, a troca do token do Google que falhou, e o FCM pedido e desligado por falta da conta de serviço.
Cada motivo sai uma vez por rodada, com o primeiro cliente que o teve, até 50 linhas e um aviso: uma chave errada
numa campanha de 6 mil destinatários não escreve 6 mil linhas. A recusa normal ("nenhum aparelho inscrito") não vai.

**As onze continuam mudas, e a decisão de 31/08 vale:** a linha de falha não diz que a rotina rodou, e o
`conferir-rastro-das-rotinas.php` continua contando as cinco do push entre as mudas -- o registro mora no
despachante, fora do nível de delegação que ele segue, e é o certo, porque não é o rastro de que a rotina rodou. É o
envio novo (o que sobrevive à migração) que ganhou o registro, e não o legado. Provado no `EnvioDePushTest`
(`testAFalhaVaiAoRegistroUmaVezPorMotivo`; os mutantes sem a deduplicação e sem o teto o deixam vermelho).

#### O aviso do retrato zerado não é rastro — 07/11/2026 (a fatia 105, o D364)

`relatoriosRoyaltiesAtualizar` passou a escrever no log das rotinas, mas **só** quando o retrato zera (o aviso do
**D364**). Não é a linha de "rodou" — a decisão de 31/08 continua valendo, e a rotina segue entre as onze mudas. O aviso sai
por `FranquiaService::escreverNoLogDasRotinas`, dois níveis abaixo da ação, e o `conferir-rastro-das-rotinas.php` segue um:
a rotina continua muda **sem lista de exceção**. A primeira versão da fatia pôs no portão uma lista `$soAvisam` que pulava a
ação antes de olhar o rastro — e o cegava para ela: uma linha de "rodou" posta depois em `royaltiesAtualizar` não a tiraria
das mudas, e o portão nunca a mostraria em "ganharam registro, tire-as da lista". A verificação a apontou, e a correção a
tirou. **Falsificado**: com a linha `logScripts('relatoriosRoyaltiesAtualizar - rodou')` posta direto na rotina, o portão
sai 1 e manda tirá-la da lista; devolvida pelo sha1.


### D69 — A caracterização passa 108 de 109 contra um banco de 36 linhas — CORRIGIDO em 31/08/2026

Descoberto em 31/08/2026, por um erro meu: rodei a caracterização em segundo
plano e `scripts/rodar-contrato.sh` por cima dela.

#### O que aconteceu

`rodar-contrato.sh` reescreve o `config/autoload/local.php` — que é
compartilhado por tudo — para apontar ao `franquiabv_contrato`, e restaura no
fim. A suíte de caracterização, iniciada dentro dessa janela, leu o banco de
contrato do começo ao fim.

**E quase não deu para perceber.** A suíte devolveu:

```
Tests: 109, Assertions: 201, Failures: 1.
```

Uma falha só, e a mensagem dela não diz nada sobre banco: *"o cenário precisa de
uma linha do mês corrente para observar"*.

#### O número, medido de propósito

Apontei a suíte ao `franquiabv_contrato` deliberadamente e rodei inteira. O
resultado reproduziu byte a byte — mesma falha única, mesmas 201 asserções:

| Banco | Tabelas | Linhas | Resultado | Tempo |
|---|---|---|---|---|
| `franquiabv_dev` | 84 | ~15,2 milhões | 109 de 109, 203 asserções | ~14 min |
| `franquiabv_contrato` | 83 | **36** | **108 de 109**, 201 asserções | **1min25** |

As contagens de linha são a estimativa do `information_schema`, não um `COUNT(*)`
tabela a tabela — a ordem de grandeza é o que importa aqui, e ela é de seis casas
de diferença.

**108 de 109 testes passam contra um banco com 36 linhas.**

Não é sorte, e nem é defeito dos testes: **14 dos 19 arquivos citam a faixa
90001000+**, que as fixtures criam nos dois bancos, e cada teste roda dentro de
uma transação revertida no fim. **Não depender do que já estava lá é boa
qualidade de teste, e aqui ela cobra o preço** — a suíte quase não consegue
perceber que está lendo o banco errado.

O único teste que percebe é `CronTravaSimultaneaTest::testODashboardDesiste...`,
porque ele precisa de uma linha de `dashboard_colaboradores` do mês corrente, e
só a cópia da produção tem. **Um teste e duas asserções separavam "109 de 109"
de "108 de 109 sobre 36 linhas."**

#### O que já existia, e do lado errado

`ContratoTestCase::exigirBancoDeFixtures` recusa rodar contra um banco com dado
real desde o **D55**. A metade de cá nunca foi escrita. A assimetria fazia
sentido quando nasceu — o estrago do D55 foi caro e visível —, mas o buraco
oposto é pior: o D55 falhava em cascata e gritava; este passa.

#### A correção

`CaracterizacaoTestCase::exigirCopiaDaProducao`, espelho do de lá: exige uma
unidade com id **abaixo** de 90000000, que só a cópia anonimizada tem. Uma
consulta, uma vez por processo, ao lado da guarda de fixtures que já existia.

Posta à prova: contra o `franquiabv_contrato` os 7 testes do arquivo passaram de
1 falha confusa para **7 erros com a explicação inteira** — o que é o banco, por
que ele quase não é notado, os dois outros sinais que denunciam (o tempo e a
contagem de asserções), e como voltar.

`scripts/rodar-contrato.sh` também ganhou o aviso de que nada deve rodar junto
com ele. Mas o aviso é lembrete; **quem protege é a guarda**, porque ela pega a
causa independente de quem a criou — inclusive uma execução do roteiro
interrompida antes do `trap`.

#### O que isto não invalida

A linha do roadmap que diz **109 de 109 contra PostgreSQL, com as mesmas 203
asserções do MySQL**. São 203, e o banco errado dá 201: aquela execução leu a
cópia certa. **A contagem de asserções foi o que permitiu afirmar isso** — sem
ela, a dúvida ficaria de pé.


### D70 — Metade do "contrato do aplicativo" não era — CORRIGIDO em 31/08/2026

> **A MESMA RAIZ VOLTOU DUAS VEZES EM 03/09/2026**, e as duas ficam registradas
> no fim deste débito: o gerador do inventário lê **texto**, e comentário é
> texto. Quem for mexer nele leia aquela seção antes.

Descoberto em 31/08/2026, ao conferir se dava para remover a rota do **D43**. O
inventário a classificava como **CONTRATO-APP**, com a nota *"versões antigas em
campo; os 28 testes seguram"* — que é exatamente o motivo pelo qual alguém
**não** mexeria nela.

Não era evidência. `scripts/inventariar-acoes.php` atribui esse veredito em bloco
a **toda** ação do módulo `ISApi`:

```php
if ($a['modulo'] === 'ISApi') {
    return ['CONTRATO-APP', 'versões antigas em campo; os 28 testes seguram'];
}
```

#### A composição das 60

| Controlador | Ações | O que é |
|---|---:|---|
| `CronJobController` | **29** | rotinas de cron — **o aplicativo nunca as chama** |
| `AplicativoController` | 20 | a API do aplicativo de verdade |
| `ApiDocsController` | 8 | documentação da API |
| `VisagismoController` | 3 | visagismo (congelado por instrução) |

E a suíte de contrato, medida: **27 caminhos distintos, e zero rotas de cron.**

**Ou seja: 29 ações estão rotuladas como "seguradas pelos 28 testes" e não têm
teste nenhum.** O rótulo não protege — ele só desencoraja mexer.

#### Por que isso importa, e em duas direções

**Para a Fase 2, o número está inflado.** O roadmap dimensiona a fase com
"contrato do aplicativo: 60 — não se mexe". A superfície que realmente não pode
mudar é a do aplicativo instalado: cerca de 20 ações, mais as 3 do visagismo. As
29 de cron precisam de tratamento *diferente*, não de tratamento *nenhum* — e 11
delas são as rotinas mudas do **D68**.

**E na direção oposta, é um convite a errar.** Um rótulo que diz "protegido por
testes" sobre algo que não tem teste é pior que nenhum rótulo: ele desliga a
desconfiança na hora errada. Se eu tivesse acreditado nele, o D43 não teria sido
resolvido — e se alguém acreditar no sentido contrário, vai mexer numa dessas 29
achando que a suíte avisaria.

**Detalhe menor, mesmo defeito:** a nota diz "28 testes". São **35** desde
28/08/2026 (**D55**), quando cinco ações versionadas que o gerador não enxergava
entraram na conta.

#### Um segundo achado, do mesmo momento

Ao regerar o inventário para tirar a linha do D43, ele mudou em coisa que **não
era minha**: `Convenios::atualizarVencimento` saiu do módulo `ISConfiguracao` e
`conveniosAtualizarVencimento` apareceu no `CronJobController`. A rotina mudou de
lugar durante o trabalho do **D50**, e o inventário nunca foi regerado.

**Ninguém teria notado.** `docs/inventario-acoes.md` e o `.csv` são gerados, mas
nada confere se o gerado bate com a fonte — diferente do que já existe para
schema, rotas órfãs e relações do Doctrine.

#### Ao resolver

Duas coisas, e a primeira é mais barata que parece:

1. ~~**Separar o veredito das ações de cron**~~ — **FEITO em 31/08/2026, por
   decisão sua.**
2. ~~**Um portão que regere o inventário e recuse a diferença**~~ — **FEITO em
   31/08/2026**, ver abaixo.

#### A separação, aplicada em 31/08/2026

`veredito()` ganhou um teste antes do bloco do `ISApi`: se o controller for o
`CronJobController`, o veredito é `ROTINA` com o motivo *"rotina de cron
disparada por URL; o aplicativo não a chama"*. O resto do `ISApi` continua
`CONTRATO-APP`, e com razão — `AplicativoController` e `VisagismoController` são
exercitados pela suíte, este último por `VisagismoApiTest`.

**O que o número virou:**

| Veredito | Antes | Agora |
|---|---:|---:|
| CONTRATO-APP | 60 | **31** |
| ROTINA | 0 | **29** |
| MORTA | 3 | 3 |
| NORMALIZAR | 124 | **123** |
| CRIAR | 158 | 158 |
| ARQUIVO | 16 | 16 |

As seis somavam 361 nos dois lados no momento da separação: nada apareceu nem
sumiu, só mudou de coluna.

**O 123 do NORMALIZAR veio de outra correção, no mesmo dia** — a ação fantasma
que a seção seguinte descreve. O total passou de 361 para **360**.

**Antes de mexer, reconferi a premissa** — a suíte de contrato exercita **25**
caminhos distintos, e **nenhum** é rota de cron. *Correção de registro:* a
mensagem do commit `8d6c214` diz "27 caminhos distintos"; são 25. A conclusão
não muda, o número estava errado.

#### E o portão pegou uma ação que nunca existiu

O portão do inventário ganhou, em 31/08/2026, um **segundo contador**: comparar
gerado-contra-commitado tem um ponto cego, porque os dois vêm do mesmo gerador —
se ele parar de enxergar uma ação, os dois concordam e o portão fica verde.

Esse segundo contador usa `token_get_all`, enquanto o gerador usa expressão
regular sobre o texto. **Na primeira vez que rodou, ele discordou: 360 contra
361.**

A diferença era `ISCadastro\VisagismoController::processarImagem`, e ela **não
existe**:

```php
//    public function processarImagemAction() {
//        $request = $this->getRequest();
```

**O método inteiro está comentado.** A regex procura `function ...Action(` no
texto, e comentário é texto — então a ação entrou no inventário como se
existisse, e foi até classificada: *NORMALIZAR, "já devolve JSON"*. Um veredito
completo sobre código que ninguém executa.

É a mesma família do **D43**, onde uma rotina de 27 linhas comentadas tinha rota
e respondia sucesso. E é a razão de o segundo contador existir: **um número
sozinho não se contradiz.**

**E a correção enfraqueceu o próprio contador, o que precisa ser dito.** Quando
ele nasceu, o gerador usava só a expressão regular e a independência era total.
Agora o gerador cruza a regex **com** o tokenizador — o mesmo mecanismo do
contador. Ele continua pegando uma regex que quebre ou passe a ver de menos, e
continua pegando `Action` dentro de comentário; **não pega** um erro de lógica
que os dois compartilhem. O que sobra de independente é pouco: o contador exige
visibilidade pública e o gerador não.

Está escrito no cabeçalho do próprio roteiro, com a alternativa honesta para o
dia em que a completude do inventário voltar a importar de verdade: contar as
rotas declaradas no `module.config.php` e cruzar com as ações — evidência de
outra fonte, não do mesmo leitor.

**A correção no gerador é de risco mínimo.** O tokenizador diz quais nomes são
reais; a regex continua fatiando os corpos, porque é ela que tem os
deslocamentos. Como `$fim` vem do array bruto de deslocamentos, pular a fantasma
**não altera o corpo de nenhuma outra ação** — só some a linha dela.

O inventário passou de 361 para **360**, e `NORMALIZAR` de 124 para **123**.

**Duas coisas que pareciam defeito e não eram**, conferidas no caminho:

- O CSV commitado tinha **0** ações `ROTINA`, não 1 — então o "Rotina ou morta:
  4" que o roadmap trazia já estava velho antes desta mudança.
- `conveniosAtualizarVencimento` parecia ter sumido do inventário. Não sumiu: eu
  havia grepado `atualizarVencimento` em minúscula, e ela mudou de nome ao mudar
  de módulo no **D50**. Está lá, agora como `ROTINA`.

#### O portão existe desde 31/08/2026

`scripts/conferir-inventario-atual.sh` guarda os dois arquivos, roda o gerador,
compara e **devolve os originais** — aconteça o que acontecer, inclusive se o
gerador morrer no meio. Ele nunca altera o repositório; quando acha diferença,
imprime o `diff` e o comando para você regerar.

Entrou no `conferir-tudo.sh`, que passou a ser **treze portões**.

**Posto à prova nos dois sentidos:** com uma linha inventada acrescentada ao CSV
commitado, ele sai com 1 e nomeia a linha; sem ela, sai com 0. E o
`git status docs/` ficou vazio depois das duas execuções, que é a prova de que
ele não altera nada.

**Duas guardas contra o verde falso**, das que esta semana ensinou a colocar:

| | |
|---|---|
| Gerador que falha | a saída dele é impressa e o roteiro sai com **2**, não com 0 |
| Arquivo curto demais | menos de 300 linhas no `.md` ou 200 no `.csv` sai com **2** |

A segunda existe porque comparar dois arquivos igualmente vazios passa — e
passaria em silêncio, que é o modo de falha que mais custou nesta semana.


#### A MESMA RAIZ, DUAS VEZES EM 03/09/2026 — e uma delas era antiga

O D70 nasceu porque o gerador contava um **método inteiramente comentado** como
ação: ele procurava `function ...Action(` com expressão regular sobre o texto, e
comentário é texto. Aquilo foi corrigido cruzando a regex com o tokenizador.

**A mesma raiz sobrevivia noutra coluna.** As colunas `servicos` e
`repositorios` — que dizem "de onde vem o dado", e por onde se estima o trabalho
da Fase 2 — eram extraídas por regex sobre a fatia de texto entre uma ação e a
seguinte. Duas consequências, medidas ao mexer no `AplicativoController`:

1. **Comentário virava dado.** Um docblock que cita `FranquiaService` fez o
   inventário atribuir aquele serviço a uma ação que nunca o usou.
2. **E era atribuído à ação ERRADA.** A fatia vai de uma ação até a próxima por
   deslocamento, então ela engole o docblock da ação *seguinte* — o serviço
   citado ia para a ação *anterior*.

Corrigido lendo o corpo **sem comentários**, pelo tokenizador. Três linhas
mudaram, e **duas eram anteriores a esta sessão**:

| Ação | Atribuição falsa | De onde vinha |
|---|---|---|
| `CronJobController::relatoriosDashboardFaturamentoAtualizar` | `FranquiaService` | docblock da ação seguinte |
| `AgendasController::checkin` | `VendaPagamentoService` | uma chamada **comentada**, na linha 491 |
| `AplicativoController::paisesSelecionar` | `FidelidadeService` | docblock que eu mesmo escrevi hoje |

A segunda é a mais parecida com o D70 original: `// $this->serviceVendaPagamento->gerarCupons(...)`
está comentada, e o inventário dizia que aquela ação dependia do serviço.

**Não é a única vez que o inventário mentiu nesta sessão.** As outras duas — a
chave de "rota declarada" sem o controller, e a colisão de namespace ao achatar
para o basename — estão em `docs/f2-fatia-pdv.md`. Somando: **45 atribuições
falsas** corrigidas em um dia, num arquivo que dimensiona uma fase inteira.

---

### D71 — 87 linhas de royalties duplicadas, e o `SUM` só acerta por acidente — PREPARADO em 13/09/2026

Descoberto em 31/08/2026, ao decidir o **D44**. O débito dizia que a unicidade de
`royalties` "não existe em banco nenhum" e que ali a trava é a única proteção.
Faltava perguntar se a corrida já tinha acontecido. Tinha.

#### Medido na cópia da produção

| | |
|---|---:|
| Pares `(unidade, data)` repetidos | **87** |
| Linhas envolvidas | 174 |
| Unidades afetadas | **29** de 62 |
| Janela | **25/11/2025 a 14/12/2025** |
| Fora dessa janela | **nenhum** |

**É um episódio, não um regime** — vinte dias, como os 41 caixas negativos de
2018 no **D20**. Mas metade das unidades passou por ele.

**O mesmo defeito de origem está no D40**, em `dashboard_colaboradores`, e ali a
consequência é oposta: aquela consulta agrupa e o banco devolve uma linha
arbitrária, então **o número exibido pode estar errado hoje**. Aqui a consulta
soma, e o número está certo. Mesma corrida, resultados opostos — e por isso os
dois débitos ficam separados.

**Em cada par, uma das linhas está zerada e a outra tem o valor.** A soma de
`MIN(ret_total)` sobre os 87 pares dá exatamente **0**, o que só acontece se toda
duplicata tiver um lado vazio. É a assinatura do ler-e-criar-se-não-existe: um
processo criou a linha, o outro criou de novo, e só um preencheu.

| | |
|---|---:|
| Pares com royalty divergente | 44 |
| Maior divergência de royalty | R$ 360,10 |
| Royalty total em jogo | **R$ 5.030,67** |
| Maior divergência de faturamento | R$ 11.226,20 |

#### Ninguém foi cobrado errado — e é aí que mora o problema

`RoyaltiesRepository::relatorioFranquias` agrega com **`SUM`**. Como um lado de
cada par é zero, a soma devolve o valor certo. **O dinheiro está correto hoje.**

Mas isso é acidente de dois fatos que se cancelam, e ambos podem mudar:

```php
// RoyaltiesRepository::selecionarPorDataUindade
->setMaxResults(1)->getOneOrNullResult();     // sem ORDER BY
```

A rotina lê **uma** das duas linhas, e qual delas não é determinado — não há
`ORDER BY`. Hoje ela vem escrevendo na que já tem valor. **Se um recálculo
dessas datas pegar a linha zerada, as duas ficam preenchidas e o `SUM` passa a
dobrar o faturamento daquele dia.** O erro seria silencioso e para mais, num
número que a franquia cobra.

O `LIMIT 1` sem `ORDER BY` é a mesma família do **D46** e do **D47**, e os
portões daqueles não pegaram este: eles varrem listagens paginadas e ordenação
por coluna anulável, não `setMaxResults(1)` em consulta de leitura pontual.

#### O que existe de proteção, e o que não existe

| | |
|---|---|
| Restrição de unicidade no MySQL | **não existe** (**D29**) |
| Restrição de unicidade no PostgreSQL | **não existe** — a do dashboard entrou, esta não |
| Trava de execução | existe desde 30/08 (**D44**), e é de arquivo |

A trava chegou **depois** do episódio, o que explica por que ele não se repetiu
desde então. Mas ela é `flock`: protege contra dois processos na mesma máquina e
não entre nós, e a F1.7 tem mais de um.

#### DECIDIDO em 31/08/2026: registrar e decidir depois

**Por decisão sua, nada é corrigido agora.** O raciocínio: ninguém está sendo
cobrado errado, a janela foi de vinte dias há nove meses, e a decisão volta junto
com o desenho do corte.

**O que fica escrito para essa hora**, e é barato quando chegar:

1. `UNIQUE (unidade, data)` no `postgres/schema.sql`.
2. A limpeza dos 87 pares em `scripts/pre-migracao-limpeza.sql`, que já existe e
   já roda antes do dump. **Apagar a linha zerada preserva a soma exatamente** —
   é a limpeza mais segura que se pode fazer, porque o valor não muda.
3. Um `ORDER BY` em `selecionarPorDataUindade`, para que "qual das duas" deixe de
   ser sorteio enquanto as duas existirem.

**O item 2 tem prazo:** se a unicidade entrar sem a limpeza, a carga falha nos 87
pares. Os dois andam juntos ou nenhum anda.

#### OS TRÊS ITENS ESTÃO FEITOS — 13/09/2026

E o terceiro já estava, sem que este registro soubesse.

| item | onde | quando |
|---|---|---|
| **1.** a unicidade | `scripts/gerar-ddl-postgres.php`, em `$unicidades`, e o `postgres/schema.sql` regerado | hoje |
| **2.** a limpeza | `scripts/pre-migracao-limpeza.sql`, bloco 8 | hoje |
| **3.** o `ORDER BY` | `RoyaltiesRepository::selecionarPorDataUindade` — `ORDER BY roy.id ASC` | **já estava**, no commit `6ba8609` |

**Nada disto toca produção nem o schema do servidor.** Os dois artefatos são da
**migração**: a limpeza roda antes do corte, e a restrição nasce com o schema
PostgreSQL. O `ensaiar-corte.sh` já exercita os dois localmente, na ordem
limpeza → schema → carga.

#### A REGRA SE ESCOLHE SOZINHA, e é isso que tira a decisão da frente

O registro dizia que fica a linha de menor id *"porque é a limpeza mais segura
que se pode fazer, porque o valor não muda"*. Remedido hoje, com a pergunta feita
de outro jeito — não *quanto some*, mas *onde está o valor*:

| | |
|---|---:|
| pares com as **duas** linhas zeradas | **41** |
| pares com exatamente **uma** com valor | **46** |
| destes, quantos têm o valor no **menor id** | **46 de 46** |
| pares com as **duas** tendo valor | **0** |

Não é que apagar o maior id seja *seguro*: é que **as 87 linhas que saem são todas
as 87 todo-zero**. A soma se preserva por construção, e a conferência fecha ao
centavo:

```
fat_total            57.632.640,83  ->  57.632.640,83
ret_total             2.422.062,28  ->   2.422.062,28
```

Ao contrário do bloco 6 da mesma limpeza — o do **D40** —, aqui **nenhum número de
relatório se mexe**. Lá é preciso avisar quem lê que o valor exibido muda; aqui
não há o que avisar.

#### A CÓPIA RESPONDE POR PRODUÇÃO, e isso foi conferido

`royalties` **não** está entre as nove tabelas que o anonimizador toca, e a tabela
está **completa**: `MAX(id)` 32.580 contra `AUTO_INCREMENT=32.581` no `mysql.sql`
— o portão de completude do D181 a dá por inteira. O número de 87 é o de
produção, não um recorte.

#### O QUE CONTINUA ABERTO

**A limpeza e a restrição ainda não rodaram contra dado de verdade.** Elas são
passos do corte, e por decisão de 11/09/2026 o corte é a única janela. Até lá o
`SUM` do relatório continua acertando **por acidente** — porque a duplicata é
zerada —, e é por isso que o título diz *preparado*, e não *corrigido*.

---


### D72 — 37 buscas escolhem uma linha sem dizer qual — 6 restam, sem consequência medida, e o número publicado estava velho em três lugares — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 08/11/2026: as três que restavam têm escritor único e travado, e o portão está em zero

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: restam 3 buscas sem ordem (o D349 baixou de 6 para 3 em 04/10); o conserto é a unicidade do D29, schema em produção.

Descoberto em 31/08/2026, ao registrar o **D71**. Aquele débito acusou uma
consulta com `setMaxResults(1)` e sem `ORDER BY`, e a pergunta seguinte era
óbvia: quantas mais existem?

#### Onde está, em 05/09/2026

Este registro é longo porque a triagem é o trabalho. O estado atual, para quem
chega agora:

| | |
|---:|---|
| **10 famílias, 12 consultas** | consertadas, cada uma com a regra escrita no método e presa por teste |
| **13** | dispensadas, com o motivo escrito dentro do método |
| **6** | restam — e **nenhuma tem consequência medida** |
| — | um contador no CI impede que virem 15 |

As dez, na ordem em que caíram: o **pacote** debitado na venda, a validação de
**telefone**, o **certificado** da VipClass, a validação de **e-mail**, o
**saldo** do cliente, o **débito** da venda, os **textos** que a unidade envia,
o **cupom** aplicado e queimado, as três consultas do **NPS** (uma família) e o
**bilhete** do sorteio.

**A regra não é a mesma nas dez, e essa é a parte que não dá para automatizar:**
no saldo e no débito vale a linha *original*, porque é onde o dinheiro está; nos
textos da unidade vale a *editada por último*, porque é o que alguém revisou; no
pacote e no cupom vale *o que vence antes*, porque é o que evita perda. Trocar
`setMaxResults(1)` por `setMaxResults(1) ORDER BY id` em massa teria produzido
código estável e errado em quatro delas.

As 6 que restam esperam decisões que não são de código: unicidade em produção
(**D29**), a limpeza pré-migração (**D40**), o desenho do corte (**D71**) e uma
decisão sua sobre o OneSignal.

**Dois defeitos novos apareceram durante a triagem**, e não são de ordem: o
**D93** (duas consultas do mesmo par perguntam o mês de jeitos diferentes) e o
**D94** (a busca do bilhete não filtra pelo sorteio).

#### Medido

| | |
|---|---:|
| Métodos examinados | 3.908 |
| Com `setMaxResults` e **sem** ordem | 74 |
| Destes, **agregação** (`SELECT SUM/COUNT/...`) — ordem não muda nada | 37 |
| Destes, **selecionam linhas** — a ordem decide qual vem | **37** |

**Só a segunda metade é assunto.** Numa agregação, `setMaxResults(1)` devolve a
única linha que existe; em `SELECT c FROM Cliente c WHERE ...`, ele devolve *uma*
das que casarem, e **qual não está definido** — nem entre bancos, nem entre duas
execuções no mesmo banco.

**Cheguei a três números errados antes deste.** O primeiro foi 72, sem separar
agregação. O segundo classificou por `getSingleScalarResult`, e deu "0
agregações" — implausível, porque este código usa `getOneOrNullResult` para os
dois casos. O terceiro olha a cláusula `SELECT` da própria DQL, que é onde a
resposta estava desde o começo.

#### Por que isso é débito e não estilo

Cada uma dessas 37 está correta **se e somente se** no máximo uma linha casar. E
**nada no banco garante isso**: o **D29** mediu zero restrições de unicidade em
todas as tabelas, MySQL e PostgreSQL.

Ou seja, 37 consultas cuja correção depende de uma promessa que ninguém faz.

**Uma já foi cobrada:** `RoyaltiesRepository::selecionarPorDataUindade`, com 87
pares duplicados na base (**D71**). Ali o estrago está contido por acidente —
uma das linhas de cada par está zerada e o relatório soma.

#### O que NÃO é o caso, e vale dizer

`UsuarioRepository::autenticacao` está na lista, e a leitura assustadora seria
"a autenticação escolhe um usuário arbitrário entre logins duplicados". **Não
é.** O **D31** já apurou isso em 19/08/2026: das 389 linhas em login duplicado,
**uma** está ativa e 388 são de colaboradores desligados, e a consulta filtra
`status = 1`. A autenticação é determinística hoje.

Fica o risco condicional que o próprio D31 nomeia: reativar um colaborador antigo
cujo login coincida com o de alguém ativo cria a duplicata entre ativos — e aí a
falta de `ORDER BY` deixa de ser teórica.

#### A triagem, medida em 31/08/2026

Das 37, **29 puderam ser medidas automaticamente** contra a cópia da produção:
para cada uma, quantas combinações dos valores que ela filtra têm mais de uma
linha hoje. As outras 8 usam função de data no filtro ou dependem de `JOIN`, e
foram medidas à mão logo abaixo — **as 37 estão todas classificadas.**

**Onde a busca PODE devolver mais de uma linha hoje:**

| Combinações repetidas | Busca | Exato? |
|---:|---|---|
| **10.000** (até **26** clientes no mesmo telefone) | `clientes` por telefone | exato |
| 167 | `vipclass_checkpoints_usuarios` por vipclass+usuário | exato |
| 15 | `clientes_debitos` por venda | exato |
| 7 | `cupons_clientes` por cliente | teto |
| 4 | `sorteios_vendas` por venda | exato |
| 3 | `clientes_creditos` por cliente+unidade | exato |
| 3 | `nps_resultados_unidades` por unidade+mês+ano | exato |
| 3 | `sorteios_vendas` por sorteio+ganhador | teto |
| 2 | `push_app` por cronjob | exato |
| 1 | `unidades_mensagerias` por unidade | exato |

**"Teto"** quer dizer que a consulta tem filtros que o medidor não soube aplicar
— um `JOIN`, uma comparação de data —, então o número real é menor ou igual.

#### As 8 que faltavam, medidas à mão em 31/08/2026

As 29 acima saíram do medidor automático. As outras 8 usam função de data no
filtro ou dependem de `JOIN`, e foram traduzidas para SQL uma a uma:

| Combinações repetidas | Busca | O que significa |
|---:|---|---|
| **2.083** (até 37) | `logradouros` por CEP | um CEP geral cobre muitas ruas; o preenchimento de endereço sugere uma delas |
| **99** (até 7) | `clientes_pacotes` por cliente+unidade+produto | **ver abaixo — a segunda com consequência real** |
| 87 | `royalties` por unidade+data | é o **D71** |
| 5 | `nps_resultados_colaboradores` por colaborador+mês+ano | a corrida que o **D44** já descreve |
| 3 | `nps_resultados_unidades` por unidade+mês+ano | idem |
| 0 | `royalties_faturas` por unidade+vencimento | |
| 289.042 | `vendas_produtos` por cliente+produto | **irrelevante**: os dois chamadores são `if (empty(...))`, regras de cupom "consumiu"/"não consumiu" |
| — | `pesquisaNpsLocalizarClientePorId` | **segura por construção**: agrupa por `cli.id` e filtra `cli.id = :id`, então no máximo uma linha |

Com isso, **as 37 estão todas classificadas.**

#### A segunda com consequência real: qual pacote é debitado

`ClientePacoteRepository::selecionarPacote` escolhe **um** pacote ativo do
cliente para aquele produto, e o chamador **usa o objeto**, não só a existência:

```php
$vendaProduto->setValorUnitario($pacote->getValorSessao());
$vendaProduto->setDescontoMotivo($pacote->getItem()->getPacote()->getNome());
$vendaProduto->setPacote($pacote);
```

Medido: **99 clientes** têm mais de um pacote ativo do mesmo produto. E eles não
são intercambiáveis:

| | |
|---|---:|
| Com `valor_sessao` **diferente** entre os pacotes | **36** |
| Maior diferença por sessão | **R$ 45,00** |
| Com **validade** diferente | **84** |

Duas consequências, e nenhuma é teórica:

1. **O valor gravado na venda depende de qual linha o banco devolveu** — em 36
   casos há mais de um valor possível, com até R$ 45 de diferença por sessão.
2. **Em 84 casos o pacote consumido pode não ser o que vence antes**, e aí o
   cliente perde sessões de um pacote que expira enquanto o outro é gasto
   primeiro.

Aqui o `ORDER BY` não é detalhe de estilo: **é a regra de negócio que ninguém
escreveu** — consumir primeiro o que vence antes, ou o mais barato, ou o mais
antigo. Como não está escrita, o banco decide.

#### A primeira com consequência visível: o telefone

`ClienteRepository::selecionarClientesPorTelefone` tem quatro chamadores. Dois
apenas perguntam se existe, e para isso tanto faz qual linha volta. **Os outros
dois usam o id da linha sorteada:**

```php
// ClientesController:759
if (!empty($clienteTelefone) && $clienteTelefone['id'] != $request['id']) {
    $validacao['mensagem'] .= "- Telefone já cadastrado<br/>";
    $validacao['responsavel_id'] = $clienteTelefone['id'];
}
```

Com 26 clientes ativos no mesmo telefone, **se a linha sorteada for o próprio
cliente que está sendo editado, a validação passa; se for outro dos 26, ela
bloqueia** — e oferece um "responsável" arbitrário entre os homônimos de
telefone. `ClientesController::validarTelefone` tem a mesma forma.

Não é hipótese: é o comportamento de hoje, e ele muda entre duas execuções da
mesma tela. A causa de fundo dos 10.000 é o **D32** — telefones de balcão
compartilhados por muitos clientes.

#### Quatro falsos alarmes, derrubados por medição

Números que pareciam graves e não eram. Ficam registrados porque a próxima
pessoa vai reencontrá-los:

| Parecia | É | Por quê |
|---:|---:|---|
| `caixas` 504 | **0** | as duplicatas são de caixas **fechados**; a consulta busca `status = true` |
| `vendas` 532 | **0** | o filtro `status IS NULL` (venda temporária) elimina todas |
| `cupons_clientes` 8.712 | **7** | eu agrupava por `status+cliente` sem aplicar `status = 1` |
| `pontuacoes_historicos` 149.109 | **irrelevante** | é verificação de existência: `if (empty($historico))`. E na família que a guarda protege — "Completou a etapa N" — são **2** pares repetidos, valendo **20 pontos**. |

#### Seis medições erradas antes da certa

Vale registrar o caminho, porque cada erro tinha uma forma diferente:

1. **72**, sem separar agregação de seleção de linhas.
2. Classificação por `getSingleScalarResult` → "0 agregações", implausível: este
   código usa `getOneOrNullResult` para os dois casos.
3. **37/37** pela cláusula `SELECT`, que é onde a resposta estava.
4. Agrupamento sem excluir nulos — os "2 tokens repetidos" eram 2.426 linhas com
   token **nulo** agrupadas entre si.
5. Excluir vazio com `<> ''` em coluna **numérica**: no MySQL isso vira `<> 0` e
   apagou silenciosamente todos os caixas fechados.
6. Agrupar por colunas que a consulta fixa num literal, em vez de filtrar por
   ele — foi o que inflou `caixas` e `cupons_clientes`.

#### Seis dos dezesseis zeros não são confiáveis

Das 29 medidas, 16 não têm duplicata na cópia local. **Seis desses zeros são
artefato da cópia, não do sistema:**

| Busca | Por que o zero é falso |
|---|---|
| `dashboard_colaboradores` (3 métodos) | `pre-migracao-limpeza.sql` **remove** essas duplicatas (linha 137, citando o **D40**, que mediu 107 em produção) |
| `usuarios` por login, e-mail (3 métodos) | a anonimização reescreveu as colunas para valores únicos (**D31**) |

É a segunda armadilha do mesmo tipo neste débito, e a terceira no dia. **Zero na
cópia local não é evidência de conserto.**

#### Ao resolver

Não é acrescentar `ORDER BY` nas 37. Em boa parte delas a ordem certa é
"nenhuma, porque não deveria haver duas" — e nesses casos o conserto é a
**restrição de unicidade** (D29), que transforma um empate silencioso em erro do
banco.

O `ORDER BY` serve onde o empate é legítimo e alguém precisa da mais recente ou
da mais antiga; ali a ausência dele é bug de verdade, e a triagem das 37 nessas
duas famílias é o primeiro passo.

~~**Não construí portão para isto.** Os portões que existem cobrem listagens
paginadas (**D47**) e ordenação por coluna anulável (**D46**); este caso escapa
dos dois. Um portão aqui nasceria com 37 itens na lista conhecida, e uma lista
desse tamanho documenta o débito em vez de proteger contra ele — a decisão de
triar vem antes.~~

**Riscado em 05/09/2026, e o raciocínio errado vale mais que a conclusão.** Eu
comparei com portão-em-zero, que de fato não cabe aqui, e concluí "nenhum
portão". A terceira forma já existia neste projeto e eu não a considerei:
**contador com linha de base**, como o `conferir-injecao-dql.php` faz com as 31
concatenações que ninguém vai remover. Ele não pede lista vazia — pede que o
número não cresça. Está descrito logo abaixo.

#### As duas com consequência real estão consertadas — 05/09/2026

Não as 37. **As duas que o próprio débito nomeou** como tendo efeito visível: o
pacote debitado na venda e a validação de telefone. As outras 35 continuam
abertas, e a triagem delas continua sendo o primeiro passo.

**Os consertos são de formas diferentes, e a diferença é a lição:**

| | O pacote | O telefone |
|---|---|---|
| o que faltava | a **regra** de qual escolher | a **pergunta** certa |
| o conserto | `ORDER BY` que escreve a regra | mover a exclusão para dentro da consulta |

##### O pacote: "vence antes, sai antes"

`ClientePacoteRepository::selecionarPacote` passou a ordenar por validade
crescente, com desempate por `id`. Das ordens possíveis — mais barato, mais
antigo, o que vence antes — **as três dão resposta estável, e só uma evita
perda**: gastar primeiro o perecível é a única em que o cliente não vê um pacote
expirar com saldo enquanto o outro era consumido no lugar dele.

A regra não existia em lugar nenhum do sistema. Agora está escrita no método que
a executa, e presa por seis testes.

**Validade nula quer dizer "não vence", e por isso vai por ÚLTIMO** — foi
preciso um auxiliar novo, `Ordenacao::nulosPorUltimo()`. Ele não é o
`comoMysql()` do **D46** com outro nome: aquele *preserva* onde o MySQL punha o
nulo, porque a tela já era assim; este **decide**, porque aqui a regra do MySQL
é que está errada. `ORDER BY validade ASC` cru poria o pacote eterno na frente
do que vence amanhã.

##### O telefone: o conserto não foi ordenar

`ClienteRepository::selecionarClientesPorTelefone` ganhou um parâmetro
`$exceto`, e os dois chamadores que comparavam o id **depois** passaram a
excluí-lo **dentro** da consulta.

A validação sempre quis saber *"alguém ALÉM deste cliente usa o número?"*, e
perguntava *"me dê um cliente deste número"* para conferir o id em seguida. Com
26 clientes na mesma linha, a resposta dependia de qual linha o banco sorteasse
— e a pergunta reescrita **não depende de ordem nenhuma**.

A ordem entrou mesmo assim, por causa do segundo uso: a tela oferece o id
devolvido como *responsável* pelo cadastro, e uma sugestão que muda a cada F5 é
pior que nenhuma. `cl.id ASC` é o cadastro mais antigo da linha.

##### Uma correção ao que este débito mediu

O número **99** — clientes com mais de um pacote ativo do mesmo produto — e os
que dependem dele (**36** com valor diferente, até **R$ 45,00**; **84** com
validade diferente) foram medidos **sem o filtro de validade**. Os dois
chamadores de produção passam `date('Y-m-d')`, e o filtro elimina a maior parte:

| | |
|---|---:|
| pacotes ativos com saldo | 533 |
| **destes, já vencidos** | **452** |
| combinações ambíguas para o chamador real | **1** |
| e nela, valores diferentes | 0 |

**A ambiguidade viva hoje é uma, e inofensiva** — dois pacotes de mesma validade
e mesmo valor. O débito continuava real (nada no banco promete unicidade, e a
cópia é uma foto de um dia), mas a frase "até R$ 45,00 de diferença" descrevia
pacotes que a consulta nunca chegava a ver. Fica corrigida aqui em vez de
apagada acima: o erro foi *medir a tabela em vez de medir a chamada*, e ele é
fácil de repetir.

##### Um defeito de brinde, no mesmo método

`selecionarPacote($cliente, $unidade, $produto, $validade)` **descartava o
argumento**: usava-o só como "filtrar ou não" e comparava com `date('Y-m-d')`.
Quem passasse outra data receberia hoje, sem aviso. Os dois chamadores passam
hoje, então nada mudou de comportamento — mas a armadilha estava armada, e o
mesmo valia para `selecionarPacotes()`.

##### O que ficou provado, e onde

`tests/Caracterizacao/PacoteDebitadoTest.php` (6) e
`TelefoneCompartilhadoTest.php` (4). **Nos dois arquivos o resultado certo é o
de id MAIOR ou exige a exclusão** — sem isso passariam por acaso, já que a
resposta sem ordem tende a vir na ordem física.

Postos à prova: comentando o `ORDER BY`, **4 dos 6** ficam vermelhos; anulando o
`$exceto`, **2 dos 4**. E rodam nos dois bancos — 202 testes verdes contra o
PostgreSQL.

#### O contador, e por que não é portão em zero — 05/09/2026

`scripts/conferir-linha-sorteada.php`, no `conferir-tudo.sh` e no CI.

| | |
|---:|---|
| **6** | selecionam linha, sem ordem — a linha de base |
| 42 | com `ORDER BY` |
| 37 | agregações, onde a ordem não muda nada |
| 13 | dispensadas com motivo escrito |
| **98** | o total, e ele fecha: é o número de chamadas de `setMaxResults` em `module/` |

*Remedido em 14/09/2026, e os quatro números acima saem do portão, não desta
prosa.* A linha do total está aqui de propósito: ela é a única que permite ver,
sem rodar nada, se alguém mexeu numa das quatro sem mexer nas outras.

**Ele falha nas duas direções.** Subir é o caso óbvio. Cair sem baixar a linha
de base deixaria folga para uma busca nova entrar escondida atrás de uma
corrigida — e a mensagem diz isso ao autor do conserto, em vez de só o parabenizar.

**A distinção que faz o número valer** é a mesma que custou três medições
erradas ao D72: agregação não conta. Quem responde é a cláusula `SELECT`, e não
o método de leitura — este código usa `getOneOrNullResult()` para os dois casos.

**A dispensa exige motivo com 40 caracteres**, como no auditor de `GROUP BY`
(**D24**), e serve para "a segunda linha não pode existir" — não para "hoje não
tem duplicata na cópia". O próprio D72 mediu **seis zeros que eram artefato da
cópia anonimizada**, e essa armadilha não pode voltar pela porta da dispensa.

A única dispensada até agora é `ClienteSessaoRepository::porTokenHash`, e ela é
minha, do **D59**: o filtro é o `sha256` inteiro de um token de 256 bits: duas
linhas iguais significariam o gerador quebrado, e não empate de dados. **Ali um
`ORDER BY` seria pior que a falta dele** — daria regra de desempate a um
impossível, e esconderia o impossível no dia em que ele acontecesse.

##### O erro que ele quase cometeu, e como apareceu

O roteiro casa a fatia limpa (onde procura a consulta) com a bruta (onde mora a
justificativa, num comentário). Casava **por índice** — e tirar comentários
deixa linhas em branco que o corte também aceita: o mesmo arquivo saiu com **4
fatias no bruto e 9 no limpo**. O marcador do método certo era procurado no
pedaço errado.

Só apareceu porque a primeira dispensa que escrevi **não surtiu efeito**, e
fui atrás do motivo em vez de mexer no texto do comentário. Casar por nome
resolveu. É a terceira vez neste projeto que uma leitura por posição mente
calada.

#### A primeira triagem: 34 viraram 27 sem mudar uma linha de comportamento

Sete das buscas **não podiam mesmo** devolver duas linhas. Elas não estavam
corretas por sorte: estavam corretas por construção, e faltava dizer isso onde
alguém fosse ler.

| Busca | Por que não há segunda linha |
|---|---|
| `ClienteRepository::selecionarPorIdHash` | o filtro fixa a **chave primária**; o hash só decide entre uma linha e nenhuma |
| `PesquisaRepository::selecionarPorIdHash` | idem |
| `VipClassMaterialRepository::selecionarMaterialPorIdHash` | idem |
| `ProdutoRepository::pesquisarQuantidade` | idem — o `status` pode zerar o resultado, nunca multiplicá-lo |
| `VendaProdutoRepository::pesquisaNpsLocalizarClientePorId` | agrupa por `cli.id, uni.id` e o `WHERE` já fixa `cli.id` |
| `PontuacaoHistoricoRepository::selecionarPorDescricao` | o único chamador faz `if (empty(...))` — **só existência** |
| `VendaProdutoRepository::selecionarProdutoVenda` | os dois chamadores idem, e a linha nunca é lida |

As duas últimas são de outra natureza: ali **pode** haver duas linhas — 149.109
combinações repetidas na primeira —, e mesmo assim a ordem não muda decisão
nenhuma, porque ninguém olha o conteúdo. A dispensa registra o motivo certo, e
não o conveniente.

**A linha de base desceu junto.** Guardar a folga seria pior que não ter
contador: uma busca nova entraria escondida atrás de uma explicada.

**As 27 que sobram não são resto uniforme.** Há pelo menos quatro famílias com
duplicata medida hoje na cópia — crédito do cliente por unidade (3), débito por
venda (15), NPS por unidade e por colaborador (3 e 5), credencial de WhatsApp da
unidade (1) —, e nelas o chamador **usa** o objeto. Essas precisam de regra
escrita, não de dispensa. O login e o e-mail duplicados (**D31**) continuam
sendo caso de restrição de unicidade (**D29**), e não de ordenação: ordenar
escolheria um vencedor de empate que não deveria existir.

#### O certificado da VipClass saía com a data de uma tentativa sorteada

A terceira com consequência real, e ela imprime papel.

`VipClassCheckpointUsuarioRepository::selecionarUsuario` escolhia **uma** das
tentativas do aluno naquele curso. A linha não é só consultada: vira a **nota**
na listagem de alunos e vira o **certificado em PDF**, com a data impressa
nele.

*Correção de registro (04/10/2026, o D343):* o certificado imprime só o nome, o curso e a
carga horária, e não a data (`docs/f3-telas-restantes/tela-vipclass.md`). A tentativa
sorteada pesa na nota e na data da listagem de alunos. *(Revisão, 04/10/2026:)* o comentário de
`VipClassCheckpointUsuarioRepository::selecionarUsuario` repetia a frase errada ("com a data
impressa nele"), e foi corrigido junto.

| | |
|---|---:|
| pares (curso, aluno) repetidos | **167** |
| maior número de tentativas da mesma pessoa | 6 |
| destes, com **data** diferente | **167** |
| destes, com **nota** diferente | 3 |

**A regra escrita: vale a tentativa mais recente** — `ORDER BY vip.data DESC,
vip.id DESC`. A coluna `data` é `NOT NULL` no banco e na anotação, então não há
nulo para posicionar (**D46**); o `id` desempata duas tentativas gravadas no
mesmo segundo, que é o caso do **D42/D87** aplicado a uma coluna sem fração de
segundo.

Se o negócio decidir que vale a **maior nota**, muda uma linha — e a decisão
passa a estar num lugar onde se pode mudá-la.

##### Dois testes verdes que não provavam nada

Escrevi os dois primeiros com a tentativa recente no id **menor**, anotando no
cabeçalho que assim fugia da ordem física. **Era o contrário:** sem `ORDER BY` o
banco devolve justamente a primeira linha gravada, então eles passavam **com a
consulta quebrada de propósito**. Só o teste de desempate acusava.

Refeitos, e agora cada um guarda uma coisa:

| Teste | Fica vermelho quando |
|---|---|
| `testValeATentativaMaisRecente` | não há `ORDER BY` nenhum |
| `testAOrdemEPelaDataENaoPeloId` | a ordem é `vip.id DESC` sozinho — a simplificação tentadora |

Provado nas três configurações: sem ordem, **3 de 5** vermelhos; só por `id`,
**1 de 5**; com a ordem certa, verde.

**A lição é sobre como se escolhe o cenário de um teste de ordenação.** "Inverti
os ids" não basta — é preciso saber o que a consulta devolve quando não ordena,
e montar o cenário contra isso.

#### O e-mail tinha o mesmo defeito do telefone, em três chamadores

`ClienteRepository::selecionarClientesPorEmail` recebeu a mesma exclusão
(`$exceto`), e os três chamadores que comparavam o id **depois** passaram a
excluí-lo **dentro** da consulta:

| Onde | O que fazia |
|---|---|
| `ClientesController::validarCliente` | comparava `$clienteEmail['id'] != $request['id']` |
| `ClientesController` (validação de e-mail) | idem, com `$id` |
| `FidelidadeController` (adesão) | idem, com `$request['id']` |

O quarto, no cadastro pelo aplicativo, só pergunta se existe alguém — para ele
a ordem é indiferente, e nada mudou.

##### O sétimo zero que não é evidência

A cópia local tem **zero** e-mails repetidos, e esse zero não vale nada: a
anonimização reescreve `clientes.email` como `cliente<id>@exemplo.invalid`,
**único por construção**. Medir duplicata de e-mail ali responde sobre o
roteiro de anonimização, não sobre a produção.

Este débito já catalogava seis zeros desse tipo — `dashboard_colaboradores` e
`usuarios` —, e este é o sétimo. **A conta de zeros falsos é maior que a de
zeros verdadeiros neste débito**, e é a melhor razão para o conserto não
depender de medição na cópia: `EmailCompartilhadoTest` monta os dois cadastros
que a produção tem e a cópia não pode mostrar.

#### O saldo do cliente: R$ 408,00 numa linha, R$ 0,00 na outra

`clientes_creditos` guarda o saldo do cliente numa unidade — `pontuacao` é a
fidelidade, **`valor` é crédito em dinheiro que ele gasta**. Deveria haver uma
linha por (cliente, unidade), e não há nada no banco que o exija (**D29**).

São **três pares repetidos**, e num deles as duas linhas discordam:

| id | cliente | unidade | valor | pontuação |
|---:|---:|---:|---:|---:|
| 158334 | 169360 | 1 | **408,00** | 1944 |
| 251303 | 169360 | 1 | **0,00** | 1944 |

Sem `ORDER BY`, o saldo que a tela mostra era o de uma das duas.

##### Por que só o `valor` diverge, e a pontuação não

A explicação estava nos dois escritores, e ela é exata:

| Rotina | O que faz |
|---|---|
| `ClienteService::atualizarPontuacao` | percorre **todas** as linhas do cliente e grava a pontuação em cada uma — **espelha** |
| `sincronizarValorCreditoClienteComExtrato` | lê **uma** linha, a sorteada, e só nela grava o valor do extrato |

Por isso os três pares têm pontuação igual dos dois lados, e só o `valor`
diverge. **A ordem que faltava é a mesma que decide onde se grava** — com ela,
ler e gravar passam a ser sempre a mesma linha.

**A regra escrita: vale a mais antiga do par**, que é a que o
"lê-e-cria-se-não-existe" teria achado antes de a segunda aparecer, e onde o
dinheiro está hoje. **Isto não conserta o dado** — juntar os três pares é
decisão de quem manda, e mexe em produção congelada.

##### O teste que fica verde no MySQL e vermelho no PostgreSQL

Com o `ORDER BY` comentado de propósito:

| | |
|---|---|
| MySQL | **3 de 3 verdes** — o InnoDB guarda a tabela na ordem da chave primária, e a varredura devolve o menor id de qualquer jeito |
| PostgreSQL | **1 vermelho** — a tabela é um monte, a varredura devolve a ordem de gravação, e o teste recebe a linha de R$ 0,00 |

As duas linhas entram com a **mais nova primeiro**, para que a ordem física
discorde da ordem de id. No MySQL isso não muda nada; no PostgreSQL muda tudo.

**É a tese deste débito acontecendo diante do teste** — e a melhor defesa do job
`caracterizacao-postgres` (**D92**): sem ele, três jobs verdes esconderiam
exatamente este defeito.

#### O débito da venda: duplo clique, não duas dívidas

`ClienteDebitoRepository::selecionarDebitoPorVenda` alimenta
`VendaService::cancelar()`, que **estorna o débito** — o objeto é usado, não é
teste de existência.

**15 vendas têm mais de um débito ativo**, até três, e a assinatura é sempre a
mesma:

| id | venda | cliente | valor | criado em |
|---:|---:|---:|---:|---|
| 7669 | 1038323 | 148079 | 5,00 | 30/12/2021 08:50:48 |
| 7670 | 1038323 | 148079 | 5,00 | 30/12/2021 08:50:**56** |
| 7671 | 1038323 | 148079 | 5,00 | 30/12/2021 08:51:**09** |

Mesmo cliente, mesmo valor, segundos de diferença: **é duplo clique no botão,
não três dívidas**. Como as linhas são idênticas no valor, a escolha não muda
*quanto* se estorna — muda **qual** linha fica marcada, e sem ordem isso variava
entre duas execuções. A regra: a mais antiga, que é a original.

**As gêmeas continuam ativas depois do cancelamento**, e isso é defeito de dado,
não de ordem — limpá-las é decisão de quem manda, em produção congelada. É a
mesma corrida que o **D44** descreve, sem trava e sem unicidade.

Provado do mesmo jeito que o saldo: com o `ORDER BY` comentado, verde no MySQL e
**vermelho no PostgreSQL**.

#### Os textos que a unidade manda: o PostgreSQL escolheria o que ninguém revisou

`unidades_mensagerias` guarda as **mensagens enviadas ao cliente** —
aniversário, pesquisa de satisfação, "sentimos sua falta", o aviso de que o
cliente chegou. **Sete** lugares do sistema leem daqui, e o objeto devolvido
vira mensagem enviada.

A unidade 1 tem **duas linhas ativas**, criadas no mesmo segundo — outro duplo
clique — e elas **não são iguais**: `pesquisa_satisfacao_app`,
`sentimos_sua_falta_app` e `cliente_chegou_whatsapp` diferem entre as duas.

**A regra aqui é outra: vale a linha editada por último.** No saldo e no débito
a regra é a linha *original*, porque o dinheiro está nela; aqui o conteúdo é
mantido à mão, e a linha que alguém cuidou por último é a que representa a
intenção de quem escreve as mensagens. **Regra por família, e não uma regra
para todas** — é o que torna a triagem trabalho de leitura, não de substituição.

##### O caso que prova o D46, com o texto na mão

`dataAlteracao` aceita nulo, e nulo significa "nunca editada" — tem de **perder**
de uma linha editada. Medido com as três formas:

| Ordenação | MySQL | PostgreSQL |
|---|---|---|
| nenhuma | 2 vermelhos | — |
| `dataAlteracao DESC` cru | **verde** | **vermelho** |
| `Ordenacao::nulosPorUltimo()` | verde | verde |

Na linha do meio o teste mostra o que a unidade mandaria ao cliente:
**"texto que ninguem revisou"** em vez de "texto revisado". O `DESC` cru põe o
nulo no topo no PostgreSQL, e a unidade passa a enviar o rascunho que ninguém
aprovou.

**É o D46 acontecendo diante de um teste**, e não em uma tabela de contagens.

#### O cupom aplicado e o cupom queimado podiam ser dois

`selecionarCupomProdutoCliente` é chamada **duas vezes, por códigos
diferentes**:

| Quando | Quem | O que faz com a linha |
|---|---|---|
| ao lançar o produto | `CuponsController` | lê o desconto e o grava no motivo |
| no fechamento | `VendaPagamentoService` | marca **usado** e desativa |

Sem ordem, nada garantia que as duas achassem o mesmo cupom: **aplica-se o
desconto de um e queima-se o outro**. O cliente fica sem um cupom que não foi
usado e com um que já foi.

**Esta é diferente das anteriores.** Nas outras o dano era ler o valor errado;
aqui são duas leituras que precisam **concordar entre si**, e é por isso que o
teste mais importante do arquivo compara duas chamadas em vez de olhar uma.

**Zero casos vivos na cópia** — e um cliente pode legitimamente ter dois cupons
do mesmo produto, de campanhas diferentes. O cenário é montado, como no e-mail.
A regra é a do pacote: **vence antes, sai antes**, com o nulo por último.

Sem o `ORDER BY`, 2 dos 3 testes ficam vermelhos já no MySQL.

#### O NPS somava de uma linha e gravava em outra

A consolidação do NPS lê **três vezes** o mesmo conjunto, com três consultas
diferentes — os totalizadores da unidade, o resultado da unidade e o do
colaborador —, calcula a média com os totalizadores que leu e grava no resultado
que leu. **Linhas diferentes nas duas leituras somam de uma e gravam na outra.**

| Repetidas | |
|---:|---|
| 3 | combinações (unidade, mês, ano) |
| 5 | combinações (colaborador, mês, ano) |

Todas com `data_criacao` igual ao segundo: é a corrida do **D44**, sem trava. O
que este débito trata é a consequência dela na leitura — as três consultas
ganharam a mesma ordem, e a mais antiga é a canônica, como no saldo.

**Três de uma vez** — foi a maior queda do contador num só conserto.

##### O que o teste realmente guarda, e o que não guarda

O teste que compara as duas leituras **passa mesmo sem o `ORDER BY`**, nos dois
bancos: as duas consultas varrem a tabela do mesmo jeito e sorteiam a mesma
linha. Quem fica vermelho sem a correção é o outro teste, no PostgreSQL.

Registro isso em vez de deixar parecer que ele prova mais do que prova. Ele
guarda o dia em que os dois planos deixarem de coincidir — **um índice novo em
uma delas basta** —, e esse dia é mais próximo do que parece, porque as duas já
não fazem a mesma pergunta. Ver o **D93**.

#### O bilhete que vira ganhador, e mais duas dispensas

`SorteioService` sorteia uma venda e chama `selecionarVenda()` para pegar o
bilhete dela — **e usa o objeto**: marca aquele bilhete como ganhador e o grava
no sorteio. **Quatro vendas têm dois bilhetes.** Nas quatro os dois são do mesmo
sorteio, então o vencedor está certo; o que variava era a linha marcada. Regra:
o primeiro bilhete da venda.

**Duas dispensas honestas no mesmo passo:**

| Busca | Por que |
|---|---|
| `UsuarioRepository::selecionarPorTokenWeb` e `::selecionarPorTokenTocarSenha` | o filtro é um token de **100 caracteres** sorteados por `random_int` (`RandomString`); duas linhas iguais não acontecem |
| `SorteioVendaRepository::validarGanhador` | a única coluna projetada é `sorven.ganhador`, e o `WHERE` fixa `ganhador = true` — **qualquer** linha devolve o mesmo valor |

A dos tokens é sobre **colisão**, não sobre força do segredo: os valores
gravados antes de 01/09/2026 saíram do `mt_rand`, e essa parte é do **D77**.

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

| Restante | Situação |
|---:|---|
| 5 | `usuarios` por login/e-mail/autenticação — é o **D31**, e a correção é a unicidade, não a ordem |
| 4 | dashboards — é o **D40**, e as duplicatas saem no `pre-migracao-limpeza.sql` |
| 2 | royalties — é o **D71**, e volta com o desenho do corte |
| 1 | `push_app` — **parado por decisão sua**: "por hora, ignorar tudo do onesignal" |
| 2 | `pesquisas` por fidelidade, `usuarios_comissoes_extras`, `caixas` por usuário — zero duplicatas hoje, e o conserto é a restrição do **D29** |

**Nenhuma das 6 restantes tem consequência medida.** As oito que tinham foram
consertadas, cada uma com a regra escrita no método que a executa e presa por
teste. O que sobra depende de decisões que não são minhas: unicidade em
produção, a limpeza pré-migração, e o corte.

##### `autenticacao` não recebeu ordem, e é decisão, não esquecimento

Seria uma linha. **Mas qual ordem?** O **D31** descreve o risco: reativar um
colaborador antigo cujo login coincida com o de alguém ativo. Com `id ASC` ganha
o colaborador **antigo** — o pior resultado possível. Com `id DESC` ganha o mais
recente, o que parece certo e é um palpite meu sobre autenticação.

Escolher errado aqui é pior que não escolher: a ordem daria aparência de decisão
a um caso que precisa de **restrição de unicidade**, e esconderia o dia em que
dois logins ativos coexistirem. Fica na lista, de propósito.
#### Três números para a mesma grandeza, e o portão lia por método — 14/09/2026

**Este débito não fecha hoje**, e o motivo continua o mesmo: nas 6 que restam o
conserto certo é a restrição de unicidade do **D29**, que é schema em produção
congelada. Pôr `ORDER BY` ali seria o conserto **errado** — faria o código
tolerar em silêncio um estado que não deveria existir, e em `usuarios.login`
isso significa duas contas com a mesma credencial e o sistema escolhendo em qual
delas se entra. O que mudou é que o número publicado e o vigia ficaram honestos.

**Três lugares publicavam três números.** O título desta seção dizia **14**, o
comentário do passo de CI dizia **27**, e o código media **6**. É a doença do
**D186** outra vez, agora numa grandeza que tem portão: prosa e comentário não
são medidores, e envelhecem sem avisar.

Pior: a queda está registrada **pela metade**. O único degrau escrito é o do
**D110** — *"a linha de base do D72 caiu de 13 para 9"*. Os saltos 14 → 13 e
9 → 6 não estão em lugar nenhum; só o portão sabe. Por isso a tabela do contador
ganhou a **linha do total**: 6 + 42 + 37 + 13 = **98**, que é exatamente o número
de chamadas de `setMaxResults` em `module/` fora de comentário — medido em
14/09/2026, 107 no texto bruto e 98 fora dos comentários. Com o total à vista,
mexer numa das quatro sem mexer nas outras deixa de passar despercebido.

#### E o portão creditava como ordenada uma busca que não é

Ele lia **por método**: pegava o primeiro `SELECT ... FROM` da fatia e procurava
`ORDER BY` na fatia **inteira**. Método que monta três consultas e limita só a
primeira ficava creditado como ordenado por causa da ordem **das outras duas**.

Instância viva: `module/ISConfiguracao/src/Entity/UnidadeRepository.php`, método
`getUnidadeColaboradoresServicos`. O `setMaxResults(1)` pertence a uma DQL
`WHERE uni.id = :unidadeId` **sem ordem nenhuma**; o mesmo método traz
`ORDER BY usu.ordernacaoApp` e `ORDER BY p.nome`, de outras duas consultas. Ela
ia para o balde dos 43 "com `ORDER BY`".

Ela **não é defeito vivo** — `uni.id` é `@ORM\Id`, então é uma linha ou nenhuma —
e é justamente essa a pior forma de descobrir um buraco: **sem sintoma**, e
dentro do balde dos consertados.

O portão passou a ler **por chamada**: para cada `setMaxResults`, o dono é o
**último** `SELECT ... FROM` antes dele, e o `ORDER BY` só conta se estiver
**entre os dois**. Medido, e o delta é de exatamente um:

| | antes (por método) | depois (por chamada) |
|---|---:|---:|
| sem ordem | 6 | **7** |
| com `ORDER BY` | 43 | **42** |
| agregações | 37 | 37 |
| dispensadas | 12 | 12 |
| total | 98 | 98 |

Nenhum falso positivo novo. A que apareceu recebeu a **mesma dispensa** que a
família `selecionarPorIdHash` já tinha — filtro na chave primária —, e a linha de
base continua **6**.

**Falsificado com o portão antigo contra a árvore de hoje:** ele devolve 6 / 43 e
sai **verde**, cego para a busca; o portão novo, sem a dispensa, devolve 7 / 42 e
sai **vermelho**. Com a dispensa escrita, 6 / 42 e verde.

#### CORRIGIDO em 08/11/2026 -- as três que restavam têm escritor único e travado, e o portão está em zero

**A fatia 125 da fila** (CONSULTAS-MORTAS-E-PORTOES-DE-DQL). O `conferir-linha-sorteada.php` contava 3 (desde o D349, 04/10/2026):
`PesquisaRepository::selecionarPorFidelidade`, `UsuarioComissaoExtraRepository::selecionarPorUsuarioProduto` e
`CaixaRepository::selecionarPorUsuario`. O conserto que este registro esperava era a unicidade do D29, esquema em produção. **Não foi
preciso:** fatias posteriores deram a cada uma um escritor único, que trava antes de ler e grava na mesma transação, e "a segunda
linha não pode existir" passou a ser verdade pelo código, e não pela cópia:

| Busca | O escritor | O que fica vermelho sem a trava (nos dois bancos) |
|---|---|---|
| o caixa aberto do operador | `CaixaService::abrirSemDuplicar`, a única porta que abre caixa (a tela antiga e a `/adm/api/caixas`), trava a linha do usuário e só então pergunta se já há um aberto | `CaixaSobTravaTest::testAAberturaTravaOOperadorAntesDeConferir` e `::testAAberturaEsperaATravaDoOperador` (o mutante T1, sem o `FOR UPDATE`) |
| a pesquisa da adesão | `PesquisaService::marcarParaAdesao`, o único que põe a marca: trava todas em ordem de id, lê as marcadas com trava e desmarca as outras; a pesquisa nova nasce desmarcada | `PesquisasPelosControllersTest::testATrocaTravaAPesquisaEAsMarcadasAntesDeLer` (T2, sem o `travarTodas`; no PostgreSQL, também `testDuasTrocasAoMesmoTempoDeixamUmaSoNaAdesao`); `testATrocaPoeAOutraETiraSoAMarcada` e mais três (T3, sem o desmarcar) |
| a extra do par (pessoa, produto) | a janela antiga (`UsuarioService::comissaoExtraSalvar`) e o `PUT` da tela nova (`gravarCotaParte`) travam a pessoa e conferem o par de novo com ela travada (`outraExtraNoPar`; `RegraDaCotaParte::conferirExtras`) | `UsuarioCotaPartePelaApiTest::testATravaDaPessoaEDeVerdadeNasPortas` (T4, sem o `FOR UPDATE` da pessoa), `::testOParDaJanelaEConferidoDeNovoComAPessoaTravada` e `::testOParCriadoNoMeioVoltaAJanelaComAFraseESemAGemea` (T5, sem o par da janela), `::testARecusaDaRegraNaoGravaNada` (T6, sem o par do `PUT`) |

Cada busca ganhou o `@linha-sorteada-conferida` com esse motivo e com os casos que ficam vermelhos -- a regra "motivo na dispensa não
é guarda": cada trava citada foi arrancada, uma por vez, e o caso ficou vermelho. O "gravar" do `PUT` (`gravarAsExtras`) foi
conferido antes: ele só recebe as mudanças que a `conferirExtras` aprovou contra as linhas lidas com a pessoa travada. O comentário de
`outraExtraNoPar`, que ainda dizia "dois cadastros simultâneos do mesmo par passam os dois", foi corrigido.

**O que a dispensa não cobre, e fica dito nela:** um par herdado de antes das travas. Na pesquisa, a próxima troca o desfaz (ela
desmarca todas as outras). Contado de novo na cópia em 08/11/2026, só `SELECT`, nos dois bancos: 1 pesquisa marcada, 0
operadores com dois caixas abertos (entre 139 abertos) e 0 pares repetidos de extra. A restrição do D29 continua sendo a
garantia mais forte, e é do corte.

**O portão:** `LINHA_DE_BASE` de 3 para **0** -- daqui em diante é portão em zero (0 sem ordem, 46 com `ORDER BY`, 27 agregações -- eram
31; quatro saíram com as consultas mortas do D485 --, 20 dispensadas). Falsificado: arrancada a dispensa do caixa, o portão sai com 1
("SUBIU de 0 para 1"); devolvida, 0, com o arquivo igual pelo sha1.


### D73 — Injeção de DQL no relatório de clientes, alcançável pela barra de endereços — FEITO em 01/09/2026 — PARCIAL em 07/10/2026: saíram 4 dos 12 sítios sem chamador; ficam os outros 8, os de valor interno e a prova por HTTP — PARCIAL em 04/11/2026: os 8 sítios dos quatro selecionarAniversariantes* sem chamador saíram (a DE2); ficam os de valor interno e a prova por HTTP — CORRIGIDO em 08/11/2026: os valores internos viraram parâmetro, e a prova por HTTP perdeu o objeto com o relatório

Descoberto em 31/08/2026, ao medir as 8 buscas que o **D72** não alcançou
automaticamente. A primeira delas trouxe outra coisa:

```php
// LogradouroRepository:15
. " WHERE l.cep = '" . $cep . "'";
```

O valor entra na consulta por **concatenação de texto**, não como parâmetro.

#### A extensão

**59 linhas** do `module/` concatenam variável dentro de uma consulta. A família
confirmada como alcançável por dado de usuário é
`ClienteRepository::filtrosListagemIndexRelatorio`, com **14 interpolações de
`$request[...]` direto na DQL**:

```php
$dql .= " AND uni.id = " . $request['unidade'];
$dql .= " AND cli.origem = '" . $request['origem'] . "'";
```

E o `$request` vem cru da barra de endereços, sem validação nenhuma:

```php
$request['unidade'] = $this->params()->fromQuery('unidade', null);
```

O caminho é `ISRelatorio\Controller\ClientesController`, protegido por
`getAcesso('ler')` — ou seja, **qualquer usuário autenticado com permissão de
ler o relatório de clientes**.

#### A prova, feita localmente e sem tocar em HTTP

DQL não é SQL, e a defesa natural seria supor que o Doctrine reanalisa a string
e neutraliza o que vier. **Ele não neutraliza.** Montei as duas formas com um
valor hostil e pedi o SQL gerado:

| Forma no código | Valor injetado | SQL gerado |
|---|---|---|
| `= " . $valor` | `1 OR 1=1` | `... AND u1_.id = 1 OR 1 = 1` |
| `= '" . $valor . "'` | `x' OR '1'='1` | `... AND c0_.origem = 'x' OR '1' = '1'` |

**As aspas não protegem.** Nos dois casos o `OR` sobrevive à análise do Doctrine
e chega ao SQL, e um `OR 1 = 1` torna o `WHERE` inteiro verdadeiro.

No caso do `uni.id`, o `WHERE` que sobra devolve **os clientes de todas as
unidades**, não só a do usuário. São 242.586 clientes com nome, CPF, telefone e
e-mail.

#### O que eu NÃO fiz, e por quê

- **Não executei isto por HTTP contra a aplicação.** A prova no nível da DQL é
  suficiente e não deixa rastro em log de acesso nem dado alterado.
- **Não testei contra a produção**, que segue congelada.
- **Não executei isto por HTTP contra a aplicação** (repetido aqui porque
  continua valendo depois da conferência abaixo).

**Uma tentativa que falhou, e não vira conclusão:** injetar no *nome da coluna*
(`GROUP BY uni." . $coluna`, `UnidadeRepository:293`) foi recusado pelo
analisador com erro de sintaxe. Foi **uma** tentativa com **um** payload; isso
não prova que a forma seja segura.

#### As 54 conferidas, uma a uma — 31/08/2026

**O total não era 59 nem 52.** O primeiro número veio de `grep` e contava linhas,
não sítios. O segundo veio de um varredor meu que exigia `" AND "` **com espaço
à frente** — e perdeu `"AND u.unidade = " . $unidade`, que começa com `AND`.
Corrigido, são **54**, em 10 arquivos de repositório e 20 métodos.

| Categoria | Sítios |
|---|---:|
| **Alcançáveis por dado de usuário** | eram **18**, hoje **17** |
| Defendidas — duas delas por acaso | 6 |
| Valor interno: cron, sessão, entidade, literal | 26 |
| **Código morto — nenhum chamador** | 4 |

Somam 54.

**As 18 alcançáveis:**

| Onde | Sítios | Origem |
|---|---:|---|
| `ClienteRepository::filtrosListagemIndexRelatorio` | 14 | `fromQuery`, sem validação |
| `ClienteRepository::listagemIndexRelatorio` | 1 | o mesmo `$request` |
| `DashboardFaturamentoRepository::relatorio` e `relatorioUnidades` | 2 | `implode(', ', $request['ano'])` dentro de um `IN (...)` |
| ~~`LogradouroRepository::buscarEndereco`~~ | ~~1~~ | **CORRIGIDA em 31/08** — virou `setParameter` junto com a troca pelo ViaCEP (**D74**) |

O relatório de faturamento é achado novo desta conferência, e a busca de CEP
também — e **essa saiu em 31/08**, corrigida de passagem quando a consulta de
CEP foi trocada pelo ViaCEP (**D74**): virou `setParameter`. **Restam 17.**

As 14 já estavam descritas acima; eu havia contado 13 porque olhei linhas, e a
linha do `BETWEEN` traz **duas** interpolações.

**As 6 defendidas, e vale saber por quê:**

| Onde | O que a defende |
|---|---|
| `filtrosListagemIndexRelatorio`, o `$tag` | `str_replace("'", "", ...)` tira as aspas antes; sem aspa não há como sair do literal. **É defesa por acaso** — a remoção existe para outra coisa. |
| `listagemIndexClientesNovos` (5) | a unidade vem da sessão, e a data passa por `DataHora::stringDate`, que **normaliza**: testei `01/01/2026' OR '1'='1` e ela devolve `2026-01-01`. A faixa de 7 dias recusa depois. |

**As 4 mortas são justamente a forma mais perigosa.**
`UnidadeRepository::popularComboboxPorColuna` interpola o **nome da coluna** em
`GROUP BY` e `ORDER BY` — que é o caso que `setParameter` não resolve. Ela não
tem chamador nenhum, em `module/` ou em `public/`. Idem
`UsuarioRepository::selecionarTempoAtendimento`. Sorte, não desenho.

**Uma que parecia grave e não é:** `selecionarUsuariosRelatorio` interpola o
próprio parâmetro `$unidade`. Os três chamadores passam `null`,
`$unidade['id']` de um laço e `$sessao->getUnidade('id')` — nenhum vem de fora.

#### Por que isto é diferente dos outros itens de segurança

Sua instrução em vigor é *"não se preocupe com a exposição agora, os dados estão
seguros no servidor... vamos ver essa questão quando for fazer a migração"*. Ela
foi dada sobre **arquivos expostos e credenciais no repositório** — coisas que
dependem de alguém achar um arquivo.

Este é de outra natureza: é uma falha alcançável **de dentro da aplicação, por
um usuário legítimo**, e o que ela expõe é a separação entre as 32 unidades.
Registro aqui e levo a decisão a você em vez de arquivar junto com os demais.

#### Situação: DECISÃO EM ABERTO, por escolha sua em 31/08/2026

Você optou por deixar a decisão em aberto e voltar a ela depois. **Não é falta de
informação**: os 54 sítios estão conferidos um a um, e o que falta é escolha de
momento.

Está listada como a única pendência ativa da seção "um segundo grupo, que não
depende de produção", no roadmap — a lista chegou a esvaziar em 31/08 e voltou a
ter esta.

**Enquanto estiver em aberto, o que vale saber:** nada foi corrigido, nada foi
testado por HTTP, e a produção não foi tocada.

#### Ao resolver

`setParameter` no lugar da concatenação. **Feito em 01/09/2026** — ver a seção abaixo.

O que eu escrevi aqui antes — "é mudança mecânica" — estava certo
sobre cada linha e errado sobre o conjunto: o auxiliar devolvia só a string da
DQL, então não havia onde ligar valor nenhum. A mudança mecânica precisou
de uma mudança de assinatura antes.


#### RESOLVIDO em 01/09/2026 -- as 17 alcancaveis viraram parametro

A conta fecha exatamente nas 17 que faltavam:

| Onde | Sitios | O que virou |
|---|---:|---|
| `ClienteRepository::filtrosListagemIndexRelatorio` | 14 | `:unidade`, `:origem`, `:tipo`, `:area`, ... |
| `ClienteRepository::listagemIndexRelatorio` | 1 | a subconsulta da pontuacao, `:unidadePontuacao` |
| `DashboardFaturamentoRepository` (2 metodos) | 2 | `IN (:anos)`, com o array ligado direto |

**O obstaculo era estrutural, e nao de digitacao.** O auxiliar devolvia SO a
string da DQL; quem criava a consulta eram os chamadores, que nao tinham como
saber que valores ligar. Passou a receber `array &$params` por referencia, e os
dois chamadores -- `listagemIndexRelatorio` e `marketingPesquisarClientes` --
fazem o `setParameters`.

**Parametrizei tambem 8 sitios que ja estavam seguros** (as datas por
`DataHora::stringDate`, os valores em dinheiro por `Monetario::removeMask`) e os
5 de `listagemIndexClientesNovos`. Nenhum deles era alcancavel; a razao e que
depender da normalizacao e depender de efeito colateral -- basta mover a funcao
para o buraco voltar sem aviso.

#### Uma mudanca de comportamento, pequena e deliberada

A busca por tags se defendia com `str_replace("'", "", ...)` no termo
pesquisado. Isso mudava **o que o usuario procura** para proteger a consulta:
quem digitasse um nome com apostrofo procurava outra coisa, sem ser avisado. O
`str_replace` saiu junto com a concatenacao, e a busca voltou a ser pelo que foi
digitado.

#### A prova, e ela e comportamental

`tests/Caracterizacao/RelatorioClientesInjecaoTest.php` -- 4 testes que rodam a
consulta com o filtro honesto e depois com a carga, e comparam contagens.

Guardei a correcao num `git stash` e rodei os testes contra o codigo antigo:

| Carga | Codigo antigo | Hoje |
|---|---:|---:|
| `?origem=x' OR '1'='1` | **242.592** | 0 |
| `?unidade=90001000 OR 1=1` | **242.592** | 2 |

242.592 e a tabela inteira de clientes. **O teste sabe ficar vermelho** -- e por
isso o verde dele significa alguma coisa.

Tudo passa pelo PAGINADOR, que e o caminho da tela: o `Paginator` do Doctrine
CLONA a consulta para contar antes de buscar a pagina, e um parametro que nao
sobrevivesse ao clone estouraria em `Invalid parameter number` so em producao.

#### Dois erros meus nesta correcao, os dois pegos por medicao

**Contei um sitio alcancavel que nao era.** Anotei o `uni.id` de
`listagemIndexClientesNovos` como achado novo, fora das 18. Fui conferir o
chamador: `$request["unidade"] = $this->unidadeId` -- vem da SESSAO, nao da
query. O documento ja o classificava como defendido, e estava certo. As 18
continuam 18.

**A primeira versao dos testes era verde e nao valia nada.** Usei `count()` no
que volta do `AdapterQuery`, que e um `Laminas\Paginator\Paginator` -- e ali
`count()` devolve o numero de PAGINAS, nao de itens. As duas assercoes de
injecao passavam por sorte: o caso defendido tem zero linhas, e zero linhas dao
zero paginas. Trocado por `getTotalItemCount()`. **E a mesma licao de sempre: o
veredito nunca acusa, a contagem sempre acusa** -- e desta vez a contagem que eu
tinha escolhido contava a coisa errada.

**E a primeira versao levava minutos.** Ela chamava a consulta sem filtro e sem
paginar, o que hidratava os 242.587 clientes com joins e `GROUP BY` para dentro
do PHP. Com o paginador, 22 segundos.

#### O que continua em aberto

- **Nunca testei isto por HTTP** contra a aplicacao, nem antes nem depois. A
  prova e no nivel da DQL e da consulta, e nao cobre o caminho do controlador.
- **Os 4 sitios mortos seguem la**, e sao a forma que `setParameter` NAO
  resolve: interpolam o NOME DA COLUNA em `GROUP BY` e `ORDER BY`
  (`UnidadeRepository::popularComboboxPorColuna`,
  `UsuarioRepository::selecionarTempoAtendimento`). Nao tem chamador nenhum
  hoje; se ganharem um, precisam de lista branca, nao de parametro.
- **Os 26 de valor interno** ficaram como estavam. `fidelizacao` e
  `selecionarAniversariantes` foram reconferidos hoje: so o cron os chama, com
  `$colaborador->getId()`, `date("d")` e `date("m")`.

#### 4 dos 12 sítios mortos saíram em 07/10/2026 (o item CODIGO-MORTO-E-DOCS)

**Apagados** `UnidadeRepository::popularComboboxPorColuna` e
`UsuarioRepository::selecionarTempoAtendimento`. Conferido antes de apagar, de novo:
nenhum chamador em `module/`, `public/`, `scripts/`, `tests/` nem `frontend/`, nem nas
views e no JS, nem pelo nome montado em texto (o único despacho dinâmico de
repositório, `UsuariosController::validarCampoUnico`, monta `selecionarPor<Campo>`).
O `conferir-injecao-dql.php` desceu a linha de base de **32 para 28** -- 3 sítios do
primeiro (o nome da coluna no `SELECT`, no `GROUP BY` e no `ORDER BY`) e 1 do segundo
(`WHERE c.id = " . $id`) -- e o comentário que os citava diz o que houve.
**Falsificado:** devolvido o `popularComboboxPorColuna`, o portão sai com 1 ("SUBIU de 28
para 31"); tirado, volta a 0, com o arquivo igual byte a byte.

**Um erro deste registro, achado no caminho:** "nome de coluna interpolado" eram só os 3
de `popularComboboxPorColuna`; o `selecionarTempoAtendimento` interpolava valor, e o
portão dizia "seis vezes em cada arquivo" para a forma do nome de coluna. O que fica nos
dois arquivos (3 e 5 sítios) interpola **valor**: a data de corte que o cron passa com
`date('Y-m-d')` e o dia e o mês de três `selecionarAniversariantes*` -- o de
`UnidadeRepository` e os dois de `UsuarioRepository` (`selecionarAniversariantes` e
`selecionarAniversariantesTempoTrabalho`). **Esses três também não têm chamador**
(medido em 07/10/2026: o cron chama só o de `ClienteRepository`), e são 6 dos sítios
de valor interno. Não estavam no levantamento do item, e ficaram: apagá-los é o mesmo
gesto, com a mesma conferência, e cabe no próximo que passar por aqui.

**São quatro, e não três (verificação de 07/10/2026).** O parágrafo acima esqueceu
`UnidadeSocioRepository::selecionarAniversariantes`, que interpola o dia e o mês do
mesmo jeito (`DAY(soc.dataNascimento) = '" . $dia . "'`) e também não tem chamador: o
`git grep` de `selecionarAniversariantes` em `module/`, `public/`, `scripts/`, `tests/`
e `frontend/` acha um chamador só, a `CronJobService` (`:398`), e é o de
`ClienteRepository` (o `conferir-caminhos-postgres.php` usa o mesmo). O comentário da
linha de base o contava em "os demais (11)", como valor de entidade. **Os quatro métodos
mortos somam 8 dos 28 sítios** (2 cada), e **os sítios sem chamador eram 12: saíram 4,
ficam 8.** Quem for apagá-los, apague os quatro. O comentário do
`conferir-injecao-dql.php` foi corrigido junto.

**Fica de pé:** a prova por HTTP do relatório de clientes; os 8 sítios dos quatro
métodos sem chamador; e os sítios de valor interno com chamador. O portão conta hoje 28
concatenações, pelo critério dele, que não se compara com os 26 deste registro (o
próprio portão avisa); 8 das 28 estão nos quatro métodos sem chamador.

#### Os quatro métodos sem chamador saíram em 04/11/2026 (a fatia 76h, DATAS-DA-EQUIPE-DE2)

**Apagados** `UnidadeRepository::selecionarAniversariantes`, `UnidadeSocioRepository::selecionarAniversariantes` e
`UsuarioRepository::selecionarAniversariantes` e `::selecionarAniversariantesTempoTrabalho` -- os quatro do parágrafo de 07/10/2026,
o dia e o mês concatenados. Conferido antes de apagar: o `git grep` de `selecionarAniversariantes` em `module/`, `public/`,
`scripts/`, `tests/` e `frontend/` só acha o de `ClienteRepository` (o parabéns do cron, que FICA: o `AniversariantesSaiuTest` o
cobra junto, para um conserto largo demais ficar vermelho). Saíram no mesmo commit as quatro `relatorioSelecionarAniversariantes*` da
tela antiga, que já eram por parâmetro. O `conferir-injecao-dql.php` desceu a linha de base de **22 para 14** -- os 8 sítios, 2 por
método morto, medidos arquivo a arquivo (`UsuarioRepository` de 5 para 1, `UnidadeRepository` de 3 para 1, `UnidadeSocioRepository`
de 2 para 0); as `relatorioSelecionar*` contavam 0, porque o `" ORDER BY " . \ISBase\...` delas é um nome qualificado, e não
variável nem chamada. **Falsificado:** devolvido o de `UnidadeSocioRepository`, o portão sai com 1 ("SUBIU de 14 para 16"); com a
linha de base de volta a 22, sai com 1 ("DESCEU de 22 para 14"); devolvido, 0, com o arquivo igual pelo sha1.

**Fica de pé:** a prova por HTTP do relatório de clientes e os sítios de valor interno: os 14 que o portão conta hoje, pelo
critério dele (entre eles, a data de corte do cron em `UnidadeRepository` e `UsuarioRepository` e o parabéns e o índice no nome do
parâmetro em `ClienteRepository`). Não reconferi o chamador de cada um nesta fatia: só os quatro que saíram.

#### CORRIGIDO em 08/11/2026 -- os valores internos viraram parâmetro, e a prova por HTTP perdeu o objeto

**A fatia 125 da fila** (CONSULTAS-MORTAS-E-PORTOES-DE-DQL). **Reconferido o chamador de cada um dos 13 sítios** que o
`conferir-injecao-dql.php` contava (a leitura está no comentário da linha de base dele). Os que levavam **valor** -- todos interno,
nenhum alcançado por pedido -- viraram `setParameter`:

| Sítio | Quem chama | Sítios |
|---|---|---:|
| `ClienteRepository::selecionarAniversariantes`, o dia e o mês | a rotina do parabéns, com `$hoje->format('d')` e `('m')` | 2 |
| `UnidadeRepository::selecionarUnidadesRelatorioFranquias`, a data de corte | a rotina dos atendimentos, com `date('Y-m-d')` | 1 |
| `VendaPagamentoRepository::relatorioYungas`, o período e a unidade | a rotina da Yungas | 3 |
| `UsuarioRepository::selecionarUsuariosRelatorio`, a data de corte | ninguém: era o `$dataDashCola`, que saiu (o D485) | 1 |

**E um sítio que o portão não via:** `"AND u.unidade = " . $unidade`, no mesmo `selecionarUsuariosRelatorio` (o combo de
colaboradores do NPS antigo e do formulário dos atendimentos, com a unidade da sessão). O literal começava pelo `AND` sem o espaço
antes, e a lista de palavras do detector pedia ` AND `. **O detector ficou mais largo** (o literal que começa por `AND ` ou `OR `
conta; medido: só esse em `module/`), e o sítio virou parâmetro junto. Falsificado: com ele de volta, o portão novo conta 7 e sai com
1; o antigo, na mesma árvore, contava 6.

**E um terceiro, que nenhum dos dois detectores via** (achado pela verificação da fatia 125): `ConvenioRepository::popularCombobox`
punha a unidade da **sessão** no texto por **interpolação** -- `"... OR conuni.unidade = $unidadeId)"`, dentro das aspas, sem ponto --,
e o próprio registro deste débito já o chamava de "sítio do D73" (o combo de convênios do cadastro de clientes, dos Descontos e do
filtro de clientes; ver o parágrafo "E um caso precisou de sessão", acima). O contador lê `T_CONSTANT_ENCAPSED_STRING` seguido de ponto,
e o pedaço interpolado é outro token. **Virou `:unidade`** (sem sessão, o `null` ligado não casa com nenhuma unidade e vem só o ramo
sem unidade; antes a DQL ficava com `conuni.unidade = )` e o analisador recusava -- nenhuma tela chega ali sem login). **E o portão
ganhou um terceiro contador**, em zero: a variável **interpolada logo depois de um operador** (`=`, `<>`, `<`, `>`, `IN (`, `LIKE`,
`BETWEEN`), em literal de aspas duplas ou heredoc com palavra de consulta. Medido em `module/`, só leitura: 50 literais interpolados
com palavra de consulta, e nenhum outro depois de operador -- os demais são o nome do parâmetro (`LIKE :$param`, `= :produto_$key`), o
alias (`{$aliasInd}.dataConvite`) ou a coluna de lista fixa (`uni.{$coluna}`, `SELECT {$colunas}`). Falsificado: com o
`= $unidadeId` de volta na linha, o portão sai com 1 ("SUBIU de 0 para 1"); devolvido pelo sha1, 0. O caso
`D73ValorPorParametroTest::testAUnidadeDaSessaoNoComboDeConvenios` declara a unidade na sessão e pergunta ao banco; o mutante G6 (a
forma de antes, interpolada e sem `setParameter`) fica vermelho nele, nos dois bancos, e o `ComboDeConveniosNoRelatorioTest` (o
recorte em si) segue verde, como deve: o resultado é o mesmo.

**Ficam 6, e nenhum leva valor:** os dois índices no nome do parâmetro (`:tag" . $i` e `:busca' . $i`, em `ClienteRepository`: o DQL não
tem lista de `LIKE`, e uma condição por palavra pede um nome por palavra, com o número de `array_values`) e os quatro
`" ORDER BY " . Ordenacao::nulosPorUltimo(...)` do D72 (dois literais escritos no próprio arquivo). A linha de base desceu de **13 para
6** e o `ClienteRepository` de 4 para 2, no mesmo passo; falsificado: devolvido o dia e o mês concatenados do parabéns, o portão sai com
1 ("SUBIU de 6 para 8").

**O caso:** o `D73ValorPorParametroTest` pergunta ao banco, e não ao texto -- o SQL que cada uma das quatro manda não traz o valor, e os
parâmetros trazem -- e afirma que a unidade continua recortando o combo (nenhum caso afirmava: arrancado o filtro, o
`PesquisasNpsNoRelatorioTest` seguia verde). Os mutantes, nos dois bancos: G1 a G4 (cada valor de volta ao texto) e G5 (o
filtro da unidade trocado por `:unidade = :unidade`) vermelhos; devolvidos pelo sha1. O que cada consulta devolve continua afirmado
onde estava (`D41ParabensNoDiaDePreenchimentoTest`, `FranquiasNoRelatorioTest`, `ReceitaParaYungasTest`, `PesquisasNpsNoRelatorioTest`).

**A prova por HTTP do relatório de clientes perdeu o objeto:** o Clientes antigo saiu do ERP em 04/11/2026 (a fatia 76e, a CA4 de
`spec-carteira.md`) -- o alias `rel-clientes` e as quatro ações saíram, e `/mod-relatorios/rel-clientes/index` é 404 (o
`ClientesAntigoSaiuTest`); o `listagemIndexRelatorio` saiu junto. **O que fica do caminho de 01/09** é o
`filtrosListagemIndexRelatorio`, que as Campanhas usam para contar a audiência (`marketingPesquisarClientes`), todo por parâmetro desde
01/09 e cobrado no nível da DQL pelo `RelatorioClientesInjecaoTest`. **Não provado:** por HTTP, pela porta das Campanhas -- o débito
era sobre a barra de endereços do relatório, e essa porta não existe mais.


### D74 — O CEP passou a vir do ViaCEP, e o dicionario virou cache — FEITO em 31/08/2026 — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: decidir se e quando rodar scripts/limpar-dicionario-cep.sql em produção (antes do dump ou depois do corte); lá o dicionário segue com 709.440 logradouros.
>
> **06/10/2026:** decidido pelo dono (lote de perguntas, pergunta 10): o `limpar-dicionario-cep.sql` roda no passo 3 do corte, antes do dump. A verificação achou que ele apagava também as UFs do campo obrigatório de estado do ERP antigo, e ele passou a repor as 27 (o D430). Falta: o passo na janela, e duas confirmações do dono -- repor as 27 UFs, e como o cache volta a encher -- ver o fim deste registro.
>
> **08/10/2026:** as duas confirmações vieram em 07/10/2026 (página do lote, perguntas 30 e 34: o cache sob demanda, e as 27 UFs repostas). Falta só o passo na janela -- ver o fim deste registro.

Por decisao sua, depois de o **D72** medir que 2.083 CEPs tinham mais de um
logradouro no dicionario local — um deles com **37**.

#### O que havia

O sistema carregava um dicionario proprio: **709.440 logradouros, 38.777
bairros, 10.053 cidades, 27 estados**, de origem e data desconhecidas. Ele
alimentava uma unica tela: o preenchimento automatico de endereco a partir do
CEP.

Dois defeitos, e o segundo so apareceu porque o primeiro foi medido:

1. **Envelhecia sozinho.** Comparado com a fonte no mesmo CEP `01001000`, o
   dicionario dizia *"Praça da Sé - lado ímpar"* e o ViaCEP diz *"Praça da Sé"*.
2. **A consulta escolhia uma linha sem dizer qual** — `setMaxResults(1)` sem
   `ORDER BY`, com ate 37 candidatas (**D72**).

E um terceiro, achado no caminho: o CEP era **concatenado** na DQL entre aspas,
que e a injecao do **D73** — as aspas nao protegem.

#### O que passou a existir

| | |
|---|---|
| `ISBase\Util\ViaCep` | cliente HTTP da API, **com tempo limite** (5 s para conectar, 10 s no total) |
| `ISBase\Service\EnderecoService` | cache local primeiro, ViaCEP depois, e grava o que trouxe |
| `LogradouroRepository::buscarEndereco` | agora com `setParameter` e `ORDER BY` |
| `scripts/limpar-dicionario-cep.sql` | esvazia as quatro tabelas, com o porque escrito |

**O contrato da tela nao mudou.** O formulario espera
`{sucesso, endereco: {cidade, bairro, logradouro, uf}}` e continua recebendo
exatamente isso — conferido ponta a ponta por HTTP.

**As tabelas continuam existindo, vazias, e enchem sozinhas.** Medido depois de
esvaziar:

| Chamada | Tempo | Cache depois |
|---|---:|---|
| 1a, CEP novo | 890 ms | 1 logradouro, 1 bairro, 1 cidade, 1 estado |
| 2a, mesmo CEP | **3 ms** | inalterado |
| 3a, outro CEP | 446 ms | 2 de cada |
| CEP inexistente | 436 ms | inalterado — nao se cacheia ausencia |

#### O erro de ordem que eu cometi, e como ele acabou bem

**Esvaziei as tabelas antes de entender uma coluna delas.** O primeiro INSERT do
cache foi recusado: `logradouros.tipo` e `int NOT NULL` sem valor padrao, e a
entidade **nao a mapeava** — entao nao havia como preenche-la.

Nesse ponto o dado que explicaria a coluna ja tinha sido apagado do MySQL. Ele
sobreviveu por sorte, na copia migrada para PostgreSQL: **709.440 linhas, um
unico valor distinto — `1`.** A coluna nunca carregou informacao.

A ordem certa era medir a coluna antes de apagar a tabela. Que tenha dado certo
nao torna a ordem correta.

#### E isso derrubou um dos quatro comandos destrutivos

Mapear `tipo` na entidade teve efeito colateral bom: o `orm:schema-tool` parou
de propor `ALTER TABLE logradouros DROP tipo` — **um dos quatro comandos que o
D17 rastreia, e justamente o que estava nomeado como exemplo no texto dele.**

`scripts/conferir-schema-tool.sh` acusou a mudanca sozinho, dizendo *"sao menos
que a linha de base"* e mandando ler antes de comemorar. Estava certo em exigir:
um numero que cai pode ser conserto ou pode ser alguem mapeando o que era para
sumir. **Aqui foi conserto**, e a linha de base baixou de 4 para 3 com o motivo
escrito no proprio roteiro.

#### O que este desenho aceita de proposito

**O cache nunca invalida.** CEP muda com raridade, e o preco de um endereco
velho no preenchimento e o usuario corrigir um campo. Se incomodar, o conserto e
uma data de coleta em `logradouros` e um prazo.

**Nada lanca para cima.** ViaCEP fora do ar devolve `sucesso: false`, que e o
mesmo caso de "CEP nao encontrado" que a tela ja tratava desde sempre: digitar a
mao. Falha ao gravar o cache tambem nao interrompe — o endereco ja foi achado.

**Duas requisicoes simultaneas do mesmo CEP podem gravar duas linhas iguais.** E
o ler-e-criar-se-nao-existe do **D40** e do **D71** — mas aqui as duas linhas
seriam identicas, entao qual delas volta nao muda a resposta. La mudava; por
isso la e defeito e aqui nao e.

#### Procurei irmas dessa coluna, e nao ha

`logradouros.tipo` barrou o INSERT por ser `NOT NULL`, sem padrao e **nao
mapeada**. Qualquer outra coluna assim faria o mesmo, e em silencio ate alguem
tentar gravar. Varri as 84 tabelas em 31/08/2026, cruzando o metadado do
Doctrine com o `information_schema`:

| | |
|---|---:|
| Tabelas com entidade | 82 |
| Colunas **nao mapeadas** | **3** |
| Dessas, as que **barram INSERT** | **0** |

As tres sao `usuarios.senha_php83`, `usuarios_acessos.local` e
`vendas.desconto_motivo` — e nenhuma barra: as duas primeiras aceitam nulo ou
tem padrao `''`, e a terceira aceita nulo.

**As mesmas tres que o `conferir-schema-tool.sh` quer apagar.** Duas ferramentas
independentes chegando na mesma lista e o tipo de confirmacao que vale mais que
qualquer uma delas sozinha.

Ou seja: o `tipo` era caso unico, e agora esta resolvido.

#### Uma sobra que fica anotada, sem mexer

`LogradouroRepository::autoCompliteLogradouro` continua no arquivo e **nao tem
chamador nenhum** — conferido em `module/` e `public/`. Com o dicionario virando
cache, ela passaria a completar sobre um punhado de CEPs ja consultados, o que
nao serve para autocompletar nada.

Nao removi: e metodo sem rota e sem efeito, e tirar codigo que ninguem chama nao
era o que voce pediu. Fica dito para quem for limpar depois.

#### O que isto mexe no corte

**Nada quebra, e foi conferido.** `scripts/migrar-dados.sh:101` esvazia cada
tabela de destino antes de copiar, reiniciando a identidade em cascata. O
PostgreSQL sera limpo e preenchido com o que o MySQL tiver -- sem duplicata e
sem sobra do dicionario velho, que hoje ainda esta la com 709.440 linhas.

**Um numero do roadmap ficou velho, e a ressalva entrou nele.** A carga medida
-- "16,2 milhoes de linhas em 511 a 521 s" -- foi medida com o dicionario
dentro. Sao **758.297 linhas a menos** na proxima execucao: 709.440
logradouros, 38.777 bairros, 10.053 cidades e 27 estados. O tempo deve cair
junto; nao remedi, porque a carga leva mais de oito minutos e o numero so
importa de verdade no ensaio do corte.

#### Cobertura

`tests/Caracterizacao/EnderecoPorCepTest.php`, **5 testes e 20 assercoes**, e
**nenhum toca a rede**: o `ViaCep` entra pelo construtor e e trocado por um duble
que conta chamadas. A contagem e o que prova o cache — duas consultas ao servico,
uma ao ViaCEP.

Posto a prova: desliguei o cache de proposito e
`testOMesmoCepDeNovoNaoVaiAoViaCep` falhou com *"a segunda consulta tem de sair
do cache"*.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 10): a opção (b), "Sim, no passo 3,
antes do dump", com a observação dele: *"Sim, no passo 3, antes do dump. Utilize a api do ViaCEP para
popular as informações."* O passo 3 do corte passou a rodar `scripts/limpar-dicionario-cep.sql` depois
da limpeza e antes do dump. **A revisão de 06/10/2026 achou que "alimentava uma única tela", acima,
não vale para `estados`:** o ERP antigo monta dela a lista do campo obrigatório de estado, e o roteiro,
que a apagava, passou a repor as 27 UFs logo depois do `DELETE` -- é o **D430**, que espera a
confirmação do dono. `php scripts/ensaiar-sql-do-passo-3.php` roda o roteiro num banco descartável com
as tabelas do `mysql.sql` de hoje e linhas inventadas, e exige 0 logradouros, bairros e cidades e as 27
UFs, duas vezes seguidas; com 709.440 + 38.450 + 10.082 + 27 linhas inventadas (`--tamanho-de-producao`),
o roteiro inteiro levou 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 -- não é a produção, cujo disco e buffer pool
não se conhecem (o D189). **O modo de repovoar espera a
confirmação do dono:** "popular pelo ViaCEP" pode ser o que o código já faz -- o cache se enche sozinho
a cada CEP consultado, 890 ms na primeira vez e 3 ms depois (acima) -- ou carregar antes os CEPs dos
cadastros, que seria um roteiro novo, e não foi escrito. A pergunta está no lote, no item
DECISOES-NO-REGISTRO-E-NO-CORTE. O que resta deste débito: o passo na janela.

**Decidido pelo dono 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." O cache de endereços volta a encher a cada CEP consultado na tela,
pelo ViaCEP, como medido acima (890 ms na primeira vez, 3 ms depois) e como `EnderecoPorCepTest` prova (5
casos, verdes nos dois bancos em 08/10/2026); a pré-carga dos CEPs dos cadastros não será escrita. E na pergunta 34, a opção (a), "Apagar e repor as 27 UFs no mesmo roteiro" -- "A lista
da tela nova; o ensaio do passo 3 confere as 27." --, que é o que o roteiro faz desde 06/10/2026 (o
D430). As duas confirmações que este débito esperava estão dadas, e o passo 3 do
`docs/procedimento-de-corte.md` diz as duas desde 08/10/2026. Nada mudou no roteiro; o ensaio do passo 3
segue verde no 8.4.11 e no 8.0.46. O que resta deste débito: o passo na janela -- e é por isso que o
título fica PREPARADO.


### D75 — O inventario nao via servico guardado em propriedade — CORRIGIDO em 31/08/2026

Descoberto ao trocar a busca de CEP pelo ViaCEP (**D74**): a acao passou a usar
um servico, e a coluna `servicos` do inventario ficou **vazia**.

#### A causa

`scripts/inventariar-acoes.php` achava os servicos de uma acao procurando um
nome **comecando com maiuscula**. Isso acha `new ClienteService(...)` e
`ClienteService::class`, e **nao acha** `$this->authService->autenticar(...)` --
servico recebido no construtor e guardado em propriedade, que e nome de
variavel e comeca minusculo.

#### O NUMERO QUE EU DEI PRIMEIRO ESTAVA ERRADO POR UM FATOR DE 25

Registrei **4 acoes**. Sao **100**.

O erro foi do meu proprio medidor: eu procurava
`$this->([a-z]\w*[Ss]ervice)` -- propriedade cujo nome **termina** em
"service". A propriedade mais comum deste codigo se chama exatamente
`$this->service`, e ela nao casa com esse padrao: sobra "ervice" para o
`[Ss]ervice` casar, e ele nao casa.

Alem disso, os 4 so contavam acoes com a celula **inteiramente vazia**. As que
ja citavam um servico pelo nome e escondiam outro nem entravam na conta.

O quadro real, medido comparando o CSV antes e depois:

| | |
|---|---:|
| Linhas cuja coluna `servicos` mudou | **100** |
| Delas, a celula estava vazia antes | **91** |
| Delas, ja tinha algo e faltava outro servico | 9 |
| Inalteradas | 260 |

**Tres das 100 continuam sendo o caso que mais incomoda:**
`AutenticacaoController::entrar` agora diz
`AutenticacaoService AuthenticationServiceInterface` -- antes dizia nada.

#### A correcao

O gerador resolve a classe em duas tentativas: o tipo na assinatura do
construtor (`EnderecoService $enderecoService`), e, sem tipo, a **fabrica** do
controller, que faz `$service = $container->get(AplicativoService::class)`. O
casamento e por nome da variavel, que neste codigo e o mesmo dos dois lados.

**Sem classe resolvida, a celula fica vazia** -- nao se inventa nome. A primeira
versao punha `service (?)` como marcador, e o resultado foram cem linhas com
ruido que tinha cara de dado. Foi desfeito antes de commitar.

**E o padrao da fabrica falhou na primeira tentativa**, pelo mesmo motivo de
sempre: `[\w\\]` dentro de aspas simples do PHP vira `[\w\]`, classe de
caracteres nao terminada. O conserto foi tirar a barra do padrao, nao escrever
mais barras.

#### O espelho nos repositorios esta limpo

`getRepository(` e sempre chamado direto no corpo da acao, nunca por
propriedade -- **0 ocorrencias**. O buraco era so nos servicos.


### D76 — O login por cookie nao entrava no registro de acessos — CORRIGIDO em 01/09/2026 — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 12/10/2026 — CORRIGIDO em 13/10/2026 (a resposta 162)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decidir se usuarios_acessos.local é mapeada, com um valor por caminho (web, cookie), ou removida; e fazer o escolhido.

Descoberto em 31/08/2026, ao varrer colunas nao mapeadas depois do **D74**.
`usuarios_acessos.local` apareceu na varredura, e puxar o fio levou a outra
coisa.

#### O que foi medido

| | |
|---|---:|
| Linhas em `usuarios_acessos` | 1.880.078 |
| Com `local` preenchido | 1.804.389 |
| **Valores distintos em `local`** | **1** — sempre `web` |
| Ultima linha com `local` preenchido | **13/11/2025** |
| Acesso mais recente na copia | 16/08/2026 |

E o volume, por ano:

| Ano | Acessos |
|---|---:|
| 2022 | 232.550 |
| 2023 | 253.012 |
| 2024 | 324.099 |
| 2025 | 317.881 |
| **2026** (ate 16/08) | **59.985** |

**Nao e sazonalidade:** o primeiro semestre de 2026 tem 47.797 acessos contra
168.271 no de 2025 — **72% a menos**. E a virada do `local` acontece no mesmo
momento: zero linhas vazias ate outubro/2025, parciais em novembro, **100%
vazias de dezembro em diante**.

#### CORRECAO DE 01/09/2026: eu chamei de explicacao o que era hipotese

A secao abaixo dizia "o que explica", e nao explica. **O mecanismo e real e
verificado; a causa da queda de 72% nao esta estabelecida.**

Fui medir quantos usuarios tem `token_web` ativo, para dimensionar o caminho por
cookie. Achei **um**. Se so um usuario pode entrar por cookie, o cookie nao
explica queda nenhuma.

**So que esse numero tambem nao vale:** `scripts/anonimizar.sql.modelo:82` faz
`token_web = NULL`. A copia local nao tem os tokens, entao **o uso do login por
cookie nao e mensuravel aqui** -- nem para confirmar, nem para descartar.

E a quarta vez neste dia que a copia anonimizada esconde o que se quer medir --
depois do **D31** (login, e-mail, CPF), do **D40** (a limpeza pre-migracao
remove as duplicatas do dashboard) e do **D72**. Fica dito aqui tambem.

**O que continua de pe, medido:** a queda de 72%, a virada do `local` em
novembro de 2025, e o fato de que `entrarCookies` autentica sem registrar. **O
que virou hipotese de novo:** a ligacao entre eles.

#### O mecanismo, que e fato

Ha **dois caminhos de autenticacao**, e so um registra:

| Caminho | Registra acesso? |
|---|---|
| `AutenticacaoController::entrar` → `criarToken()` → `logAcesso()` | **sim** |
| `AutenticacaoController::entrarCookies()` — login por cookie `token_web` | **nao** |

`entrarCookies` autentica de verdade — monta o adaptador, chama
`authenticate()`, cria a sessao e a ACL — e **nao chama `logAcesso`**. Quem entra
com o cookie valido nao aparece no registro.

E o `local` esvaziou porque quem grava hoje e `logAcesso`, que cria a entidade
`UsuarioAcesso` — e essa entidade **nao mapeia a coluna `local`**. O banco
aplica o padrao `''`.

**O QUE EU NAO SEI, e nao vou afirmar:** por que a virada foi em novembro de
2025, nem se o caminho por cookie tem participacao nisso. O historico do repositorio comeca na importacao inicial, entao nao ha o
que comparar. Os dois sinais mudam juntos, o que sugere uma so alteracao no
fluxo de login, mas isso e leitura, nao medida.

#### Por que importa

**Nada de valor foi perdido na coluna** -- `local` sempre disse `web`, para
todo mundo, desde 2016. Perder uma constante nao perde informacao.

**O que se perde e o registro de quem entrou.** `usuarios_acessos` e a unica
trilha de acesso do sistema, e ela hoje ignora o caminho por cookie -- que,
pelo tamanho da queda, virou o caminho da maioria. Para auditoria, "este
usuario nao acessou" e indistinguivel de "este usuario acessou pelo cookie".

#### CORRIGIDO em 01/09/2026

`entrarCookies` passou a chamar `logAcesso`, depois de montar a sessao e a ACL.
Uma linha.

**Guardado por `scripts/conferir-registro-de-acesso.php`**, que confere pelo
tokenizador se a chamada continua dentro daquele metodo -- e sai com 1 quando
ela some. A guarda e estrutural, e o roteiro diz isso de si mesmo: alcancar
`entrarCookies` num teste exigiria montar cookie, sessao e ACL, e um teste que
precisa de tudo isso para provar uma linha nao vale a fragilidade.

`logAcesso` em si tem teste de comportamento, em `SenhaEAcessoTest`.

#### O que continua aberto

**E ai aparece a decisao:** se `local` for para valer alguma coisa, ela precisa
ser mapeada na entidade **e** receber valores diferentes por caminho -- `web`
para o formulario, algo como `cookie` para o outro. Se nao for, o mais honesto e
tira-la, e o `schema-tool` ja quer faze-lo.

**Nao mexi em nada.** E codigo de autenticacao, e a decisao entre instrumentar
ou remover e sua.

#### 12/10/2026 — O REGISTRO DIZ COMO A PESSOA ENTROU — pela resposta 104 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 104** (a opção "a"): *"O registro de acessos passa a dizer como a pessoa entrou: senha, cookie, ou assumida
por um administrador (e qual). Na lista de acessos da pessoa, a troca aparece como 'assumida por Beltrano'."* A coluna
`local` já existe nos dois bancos, e usá-la não muda o esquema.

##### O conserto

- **A entidade mapeia a coluna** (`UsuarioAcesso::$local`, `varchar(255)`, padrão `''`, como no `mysql.sql` e no
  `schema.sql`), com três valores, as constantes `PELA_SENHA` (`senha`), `PELO_COOKIE` (`cookie`) e `ASSUMIDA_POR`
  (`assumida:<id de quem assumiu>` -- o id, e não o nome, que muda). `AutenticacaoService::logAcesso` passou a exigir o
  `local`: o formulário (`criarToken`) grava `senha`; o cookie (`entrarCookies`), `cookie`; e a personificação
  (`UsuariosController::autenticarComoAction`, na entrada), uma linha **da pessoa assumida** com `assumida:<id>` -- até
  esta data ela não gravava nada (o D127). A volta não grava: quem volta já estava logado. As linhas de antes (`web` até
  13/11/2025, `''` depois) ficam como estão.
- **A lista de acessos da pessoa** (`UsuariosController::localizarAcessosAction`, o modal da lista de usuários) diz, ao
  lado da data, "pela senha", "pelo cookie" ou "assumida por Beltrano" (o nome escapado); as linhas de antes, só a data.
  **A da unidade** (`UnidadesController::localizarAcessosAction`) põe "(assumida por Beltrano)" ao lado do nome --
  sem isso a troca apareceria ali como se a pessoa tivesse entrado. O nome de quem assumiu sai de uma consulta só, pelos
  ids que aparecem (`UsuarioAcessoRepository::comQuemAssumiu`).
- O `schema-tool` deixou de propor apagar a coluna: a linha de base do `conferir-schema-tool.sh` desceu de 7 para 6.

##### Provado

- `RegistroDeAcessosTest` (novo, 13 execuções, nos dois bancos), despachado em processo com os controllers de verdade:
  o formulário grava `senha`; o cookie grava `cookie`; assumir grava a linha da pessoa assumida com `assumida:<id>`, e
  a volta e o administrador não ganham linha; as duas listas dizem como a pessoa entrou (as cinco formas, da mais nova à
  mais antiga). E o `testLogAcessoGravaUmaLinha` do `SenhaEAcessoTest` confere o `local` gravado.
- Por HTTP, o caso 22 do `ensaiar-sessao-do-erp.php` (abaixo, no D127): a linha `assumida:<id>` no banco de contrato,
  nos dois bancos. Falsificado por HTTP, no MySQL: sem a gravação da troca, o caso 22 fica vermelho (22 de 23). **A
  primeira versão do caso lia a última linha da pessoa assumida, e o mutante passou**: a linha de uma rodada anterior, sem
  o banco remontado, respondia por ele. Agora o caso lê só as linhas de id maior que o de antes do pedido, e o
  encerramento tira as que a troca gravou.
- Mutantes, um de cada vez, no MySQL, restaurados e conferidos pelo sha256, cada um com o seu vermelho: o formulário
  sem o `local` (o caso do formulário), o cookie gravando `senha` (o do cookie), a personificação sem a linha (o da
  troca), a lista sem o nome de quem assumiu e a lista da unidade sem o rótulo (o das listas).

**O que fica, e foi ao lote:** o "Tot. Acessos" e o "Últ. Acesso" de duas listas contam todas as linhas -- a de
usuários (as duas telas, `UsuarioRepository::listagemIndex`), onde a troca soma um acesso da pessoa e vira o último
acesso dela, e a de unidades (o ERP e a `/adm/api/unidades`, `UnidadeRepository::listagemIndex`, pelo `UnidadeJson`),
onde a mesma troca soma um acesso à unidade da pessoa e vira o último acesso da unidade, sem ninguém dela ter entrado.
Contar só as da própria pessoa, nas duas, é a pergunta `SESSAO-E-ROTAS (o D76, depois da resposta 104)`, com a
recomendação de contar só as dela. Hoje nenhuma linha assumida existe na cópia: elas começam agora. *(A primeira versão
deste parágrafo, e da pergunta, citava só a lista de usuários; a verificação do mesmo dia achou a de unidades.)*

##### A verificação do mesmo dia: os nomes escapados nas duas listas

**O escape do nome de quem assumiu não tinha prova:** o caso das listas usava "Gerente Teste", sem marca nenhuma, e
arrancar o `htmlspecialchars` de qualquer das duas listas deixava tudo verde. E, ao lado dele, **o nome da pessoa saía
cru na lista de acessos da unidade** (`UnidadesController::localizarAcessosAction`), desde sempre: a lista é HTML
montado no controller, que o `mod-configuracoes.js` põe na página pelo `.html()` -- a família do D203. Agora os dois
nomes são escapados. `RegistroDeAcessosTest::testONomeDoCadastroSaiEscapadoNasDuasListas`, nos dois bancos: o nome de
quem assumiu com `<b>`, as duas aspas e `&`, e o da pessoa com `<i>` e o apóstrofo, saem escapados nas duas listas, e
nenhuma marca sobra. Vermelho antes do conserto, no MySQL (o nome da pessoa, cru, na lista da unidade). Três mutantes, cada um
vermelho nesse caso, nos dois bancos: sem o escape de quem assumiu na lista da pessoa, na da unidade, e sem o do nome da
pessoa na da unidade. **Por onde o nome chega com marca** é o D476: o "Meus dados" grava o POST cru, sem o `StripTags`
do filtro.

#### 13/10/2026 — O TOTAL E O ÚLTIMO ACESSO SÃO DA PRÓPRIA PESSOA — pela resposta 162 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 162** (a opção "a", a recomendada): *"O 'Tot. Acessos' e o 'Último acesso' contam só as entradas da própria
pessoa (senha e cookie, e as linhas de antes), nas duas listas: a de usuários (a mesma consulta no ERP e na tela nova) e a
de unidades (`UnidadeRepository::listagemIndex`, a tela antiga e a `/adm/api/unidades`). A troca aparece só nas listas de
acessos, com o nome de quem assumiu."*

##### O conserto

- **A lista de usuários** (`UsuarioRepository::listagemIndex`, a do ERP e a `GET /adm/api/usuarios`): o `LEFT JOIN` dos
  acessos só junta as linhas cujo `local` não começa com `assumida:` -- `SUBSTRING(ace.local, 1, 9) <> 'assumida:'`, o
  prefixo pelo `SUBSTRING` como em `UsuarioAcessoRepository::entrouPeloNavegadorDesde`, e pela mesma razão. Contam a
  senha, o cookie e as linhas de antes (`web` e `''`). Quem só foi assumido continua na lista, com 0 acessos e sem último.
- **A lista de unidades** (`UnidadeRepository::listagemIndex`, a do ERP e a `GET /adm/api/unidades`): as duas subconsultas,
  a do `MAX` e a do `COUNT`, com a mesma condição.
- As listas de acessos (a da pessoa e a da unidade) não mudam: a troca continua nelas, com o nome de quem assumiu. Nenhuma
  tela mudou -- as quatro mostram o que a consulta devolve.

**Na cópia** (`franquiabv_dev`, só contagens, 13/10/2026): nenhuma linha assumida (quem as grava é o código de 12/10/2026, e a
cópia é anterior), e os `local` são `web` (1.804.389) e `''` (75.777): as listas dão hoje o mesmo número
de antes.

##### Provado

`D76AcessosDaPropriaPessoaNasListasTest` (novo, 3 casos), verdes no MySQL e no PostgreSQL, em série, despachados em
processo nas quatro portas -- o `index` das duas telas antigas e o das duas rotas da API --, com o registro de acessos
vazio e seis linhas de "Colaborador Teste" (`web` e `''` há quatro dias, a senha há três, o cookie há dois, e duas
assumidas por "Gerente Teste", há um dia e agora): as quatro dizem **4 acessos e o último há dois dias** (antes, 6 e
hoje); e com só as duas assumidas, a pessoa e a unidade dizem 0 e nenhum último, e as duas linhas continuam nas listas.
O `RegistroDeAcessosTest` (as listas de acessos com "assumida por Beltrano") segue verde nos dois bancos.

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-r30.py`, no rascunho do item) | vermelho, no MySQL e no PostgreSQL |
|---|---|
| a lista de usuários junta todas as linhas (a condição com `OR ace.id IS NOT NULL`) | a lista de usuários; quem só foi assumido |
| o último acesso da unidade conta a assumida | a lista de unidades; quem só foi assumido |
| o total da unidade conta a assumida | a lista de unidades; quem só foi assumido |

*(A primeira forma do mutante da lista de usuários arrancava a condição e deixava os dois parâmetros sobrando: o Doctrine
recusa a consulta, e o vermelho era de erro, e não de contagem. Refeito com a condição que junta tudo.)*

**Não provado:** as telas não foram abertas num navegador.


### D77 — O gerador trocado; os valores ja emitidos e que esperam decisao — PARCIAL, lido em 05/10/2026 — PARCIAL em 12/10/2026: o prazo do cookie (a resposta 103); o ALTER das três colunas e as rotações são da janela e da produção — PARCIAL em 06/11/2026: a entrada automática na tela nova decidida (317 a); o ALTER e as rotações seguem da janela

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: dar validade ao token_web; em produção, o ALTER TABLE das três colunas do hash anterior e as rotações dos hashes de unidade e de cliente.

Descoberto em 01/09/2026, ao investigar o login por cookie do **D76**.

#### O que foi lido

`ISBase\Util\RandomString::gerar()` monta a cadeia com **`mt_rand()`**:

```php
$rand = mt_rand(1, $len);
$retorno .= $caracteres[$rand - 1];
```

`mt_rand` e o Mersenne Twister. A propria documentacao do PHP diz que ele **nao
serve para criptografia** e manda usar `random_int()` / `random_bytes()` quando
o valor for segredo. Ele e deterministico: conhecido o estado interno, toda a
sequencia futura sai.

#### O que ele gera, e o que cada coisa abre

| Credencial | Tamanho | O que ela permite |
|---|---:|---|
| `usuarios.token_web` | 100 | **entrar como o usuario**, via cookie, sem senha |
| `usuarios.token_trocar_senha` | 100 | **redefinir a senha** de um usuario |
| `unidades.hash` | 60 | ler o faturamento em `/api/unidade/vendas`, `V2` e `V3` |
| `clientes.hash` | 8 | **abrir o cadastro do cliente**, sem login — ver a correção abaixo |
| `pesquisas.hash` | 30 | abrir a pesquisa de um cliente |
| `vipclass_materiais.hash` | 10 | abrir material de treinamento |

As tres primeiras sao **portadoras**: quem tem o valor entra, sem mais nada.

Quantas existem hoje, na copia:

| | |
|---|---:|
| Unidades com hash | **60** |
| Clientes com hash | **242.586** |

*(Os valores locais foram substituidos por MD5 na anonimizacao -- as contagens
valem, os valores nao.)*

#### Dois agravantes, medidos

**1. O `token_web` nao expira.** Nao ha coluna de validade, e
`selecionarPorTokenWeb` so confere `tokenWeb = :token AND status = 1`. Um cookie
vazado vale enquanto o usuario nao entrar de novo ou sair.

**2. Nenhuma comparacao de segredo e de tempo constante.** Sao **quatro**
comparacoes com `==` -- tres em `CronJobController` (o hash da unidade nas rotas
de vendas) e uma em `VipClassEduController`. **`hash_equals` nao aparece uma
unica vez no `module/`.**

#### O que eu NAO provei

**Nao demonstrei previsao de token.** Recuperar o estado do Mersenne Twister a
partir de saidas reduzidas a uma faixa de 62 valores e mais dificil que a partir
das saidas cruas de 32 bits, e eu nao tentei -- fazer isso exigiria muitos
tokens do mesmo processo, e nao ha por que produzi-los para provar um ponto que
ja esta no manual do PHP.

O que esta estabelecido e mais simples e basta: **estas sao credenciais de
seguranca geradas por um gerador que a documentacao da linguagem desaconselha
para credenciais de seguranca.**

Um detalhe do ambiente que amplia a duvida, e que tambem nao foi explorado: o
php-fpm reaproveita workers entre requisicoes, entao o estado do `mt_rand` de um
worker atravessa varios usuarios.

#### CORRIGIDO EM PARTE, em 01/09/2026

**`random_int()` no lugar de `mt_rand()`, dentro do `RandomString`.** Duas
linhas num arquivo, e as seis credenciais passaram a sair de gerador
criptografico -- sem tocar nos dezesseis arquivos que o consomem.

`random_int` lanca se nao houver fonte de aleatoriedade, e isso ficou de
proposito: credencial fraca gerada em silencio e pior que requisicao que falha
alto.

**Guardado por `scripts/conferir-registro-de-acesso.php`**, que le pelo
`token_get_all` -- o comentario do proprio arquivo menciona `mt_rand` para
contar esta historia, e comentario nao pode acusar. Posto a prova: com o
`mt_rand` devolvido, o portao sai com 1.

#### O QUE CONTINUA ABERTO

**Os valores ja emitidos.** Os 60 hashes de unidade e os 242.586 de cliente
continuam sendo os antigos ate serem regerados -- e **regerar o da unidade
quebra quem ja usa a URL de vendas**. Isso e decisao sua.

*(Em 02/09/2026 a parte do cliente ganhou ferramenta e numeros. Ver as duas
secoes abaixo.)*

~~**O `hash_equals` nas quatro comparacoes** foi FEITO em 01/09/2026 -- e a
justificativa que eu tinha dado para nao faze-lo estava errada.

Eu escrevi que "pedia teste que nao existe". Existe: a suite de contrato
exercita as tres rotas de vendas com hash valido, **e tem um teste dedicado a
recusa**, o `testUnidadeVendasRecusaHashErrado`, com o comentario *"o hash e a
unica credencial da API inteira, e precisa continuar valendo"*. Bastava olhar.

As quatro trocas: tres em `CronJobController` (as rotas `vendas`, `V2` e `V3`) e
uma em `VipClassEduController`. **Nao sobrou nenhuma comparacao de segredo com
`==` no `module/`**, e `hash_equals` passou de zero para quatro usos.

Posto a prova: substitui a checagem por `true` e a suite de contrato acusou com
**uma falha** -- exatamente o teste da credencial. Restaurada, 35 de 35.~~

**FEITO em 01/09/2026, e a frase riscada estava errada.** O teste existia: a
suite de contrato exercita `/api/unidade/vendas`, `V2` e `V3` com hash valido, e
tem um caso dedicado -- `testUnidadeVendasRecusaHashErrado` -- cujo proprio
comentario diz *"o hash e a unica credencial da API inteira, e precisa continuar
valendo"*. Eu afirmei que nao havia cobertura sem procurar.

As quatro comparacoes viraram
`hash_equals((string) $unidade->getHash(), (string) $hash)` -- tres em
`CronJobController`, uma em `VipClassEduController`.

> ~~**Nao sobrou nenhuma comparacao de segredo com `==` no `module/`.**~~
>
> **ERRADO, e corrigido em 04/09/2026.** Sobravam **27**: as comparacoes do
> token dos crons (`$token == $this->tokenPadrao`), 26 em `CronJobController` e
> uma em `VisagismoController`. Aquele trabalho trocou quatro comparacoes --
> todas do hash da unidade -- e eu generalizei para "nenhuma" sem procurar as
> outras. E a segunda vez que uma frase minha neste mesmo registro afirma mais
> do que eu tinha medido.
>
> As 27 viraram `hash_equals` agora, e a contagem foi feita por busca, nao por
> impressao: `git grep -cE '(==|===|!=) *\$this->tokenPadrao' -- module/`
> devolve zero.

Posto a prova: troquei a checagem por `true` e a suite de contrato acusou com
**uma falha**, a do hash errado. Com a checagem no lugar, 35 de 35.

**Nada disso foi feito.** Trocar o gerador nao invalida os tokens que ja
existem: os 242.586 hashes de cliente e os 60 de unidade continuariam sendo os
antigos ate serem regerados -- e regerar o hash da unidade **quebra quem ja usa
a URL de vendas**. Essa parte e decisao, nao conserto.


#### CORRIGIDO O QUE EU TINHA ESCRITO: o hash de cliente não "identifica" — ele ABRE

A tabela acima dizia que `clientes.hash` serve para *"identificar um cliente em
`selecionarPorIdHash`"*. **Subdimensionei.** Lido o caminho inteiro em
02/09/2026:

`FidelidadeController::cadastroAction` aceita `?id=<n>&hash=<8>` **sem
autenticação** e, para quem ainda não está no programa de fidelidade, renderiza
o formulário **preenchido** com nome, data de nascimento, telefone, e-mail, CEP,
estado, cidade, bairro, logradouro, número e profissão.

Não é identificação: é **exposição do cadastro**, e o hash é a única credencial.

Três agravantes, todos medidos:

1. **A busca não confere `status`, de propósito.** O comentário no repositório
   diz por quê: *"nao validar status pois os amigos são setados com status
   false"*. O convidado precisa abrir o convite antes de existir de verdade — e
   a consequência é que cliente inativo também fica alcançável.
2. **O alfabeto é menor do que "8 caracteres" sugere.** `setHash()` chama
   `gerar(8, false, true, false)`: minúsculas e dígitos, **36 símbolos**. São
   36^8 ≈ 2,8 × 10¹², e não 62^8.
3. **A superfície é quase toda a base.** Dos 242.589 clientes, **217.133 estão
   fora do programa de fidelidade** — ou seja, caem no ramo que renderiza o
   cadastro.

*O que isso não é:* não é um furo explorável por força bruta. 36^8 para um `id`
escolhido não se percorre por HTTP. O problema é o do D77 desde o começo —
**previsibilidade**, não tamanho: os valores saíram de `mt_rand`.

**Caracterizado em `tests/Caracterizacao/HashDoClienteTest.php`**, quatro casos,
nos dois bancos. Um deles força uma colisão de hash de propósito e mostra que
ela é inofensiva: a busca é pelo **par** `id + hash`, então hash repetido não
abre o cadastro de outra pessoa. Outro guarda um acoplamento que ninguém vê — o
controller exige `strlen($hash) == 8` **antes** de consultar, e quem produz o
valor é `Cliente::setHash()`, noutro módulo. Mudar o tamanho ou o alfabeto lá
para "reforçar" a credencial faria a tela recusar todo mundo, com um 404 mudo
por sintoma.

#### FERRAMENTA PRONTA para a metade do cliente, em 02/09/2026

`scripts/rotacionar-hashes-clientes.php`. **Sem `--aplicar` ele só mede**, e não
imprime hash nenhum — nem antigo nem novo.

**O que a rotação quebraria, e por que quase nada.** O link
`fidelidade?id=..&hash=..` é montado **na hora** em quase todos os caminhos: o
aplicativo o remonta a cada listagem (`AplicativoController:325`) e a tela o
reemite ao renderizar o formulário. Para esses, rotacionar é invisível.

O único caminho que **não** remonta é o convite: `FidelidadeService:75` manda o
link por WhatsApp ao amigo indicado, e a mensagem fica no telefone da pessoa.

E é aí que o número decide:

| | |
|---|---:|
| Indicados pendentes (`origem` "Indica…", `status = 0`) | **910** |
| Mais antigo | 04/06/2015 |
| Dos últimos 90 dias | **71** |

Ou seja: rotacionar tudo mataria 910 convites, dos quais **839 têm mais de três
meses** e alguns têm onze anos. Por isso o padrão preserva a janela de 90 dias —
**rotaciona 242.515 e poupa 71**. A janela é ajustável, e
`--preservar-dias=0` rotaciona todos.

**Não foi rodado em produção**, que segue congelada. É ferramenta pronta, como
`scripts/medir-senhas-producao.sql` — com a diferença de que aquele só lê, e este
escreve; daí o `--aplicar` obrigatório.

**Também não foi rodado na cópia local, e por um motivo:** rotacionar 242 mil
hashes só no MySQL deixaria o PostgreSQL divergente em 242 mil linhas, e
`conferir-migracao.php` compara os dois no ensaio de corte. O caminho de escrita
foi provado por teste, não sujando a cópia.

#### A METADE DA UNIDADE — FEITA em 03/09/2026

> *"ataque o D77"*

O que estava escrito aqui era: rotacionar `unidades.hash` quebra quem já usa
`/api/unidade/vendas`, `V2` e `V3`, e **não se sabe quem é** — o inventário das
consultas do Power BI é o **D9**, e depende da produção descongelada. Rotacionar
sem ele seria apagar a luz para procurar a chave.

Também estava escrito o caminho que tira a decisão do impasse, e que eu não fiz
sem você pedir: **guardar o hash anterior e aceitá-lo por uma janela,
registrando quem ainda o usa.** É o que foi feito.

##### As três colunas, e o que cada uma resolve

| coluna | o que resolve |
|---|---|
| `hash_anterior` | rotacionar deixa de derrubar quem não trocou |
| `hash_anterior_expira_em` | sem prazo não há rotação, só acúmulo |
| `hash_anterior_usado_em` | **o inventário que faltava se escreve sozinho** |

A terceira é a que muda o problema de lugar. Ela não diz *quem* consulta — para
isso seria preciso guardar origem, e guardar origem de quem chama uma API é
outra decisão —, mas diz **se** alguém ainda consulta. E era só isso que faltava
para saber quando derrubar o antigo.

**O D9 continua valendo pelo que ele é:** saber quais consultas existem e o que
fazem. O que a janela desfez foi o *bloqueio* — não é mais preciso esperar por
ele para rotacionar.

*(Em 09/09/2026 o Power BI foi removido e o D9 encolheu para revogar a
credencial que sobrou em produção. A conclusão sobre a rotação não muda: ela
já não dependia do inventário.)*

##### O ciclo inteiro, medido contra a aplicação de pé

| | |
|---|---|
| hash novo | **200** |
| hash antigo, janela aberta | **200**, e `hash_anterior_usado_em` gravado |
| hash antigo, janela vencida | **Acesso negado.** |
| hash inventado | **Acesso negado.** |

##### UM DEFEITO ENCONTRADO NO CAMINHO, e ele era um oráculo

As três ações usavam `getReference`, que **nunca devolve nulo**: monta um proxy
mesmo para id inexistente, e a exceção só estoura quando alguém toca no objeto.
O `!empty($unidade)` ao lado era decorativo.

Medido antes de mexer: **id inexistente respondia HTTP 500**, e id existente com
hash errado respondia `Acesso negado.` — ou seja, **o código de status contava se
a unidade existia**, sobre ids sequenciais, para quem não apresentou credencial
nenhuma. Agora as duas respostas são idênticas.

##### A ferramenta

`scripts/rotacionar-hashes-unidades.php`. Sem `--aplicar` ela só mede, e **nunca
imprime hash na tela**.

Três coisas nela são deliberadas:

**`--saida=<arquivo>` é obrigatório com `--aplicar`**, e é conferido *antes* de
escrever no banco. Credencial impressa no terminal fica no histórico do shell e
na rolagem de quem estiver olhando — e estes valores abrem o faturamento.
Rotacionar e depois não conseguir gravar o valor novo deixaria a unidade com uma
credencial que ninguém conhece, e a janela correndo.

**Ela se recusa a sobrescrever um CSV existente.** Ele pode ser a saída de uma
rotação anterior ainda não aplicada no Power BI.

**`--unidade=<id>` é o modo normal.** Rotacionar as 62 de uma vez obriga a
reconfigurar 62 consultas no mesmo minuto; sem o parâmetro ela mexe em todas, e
isso é deliberadamente mais difícil de digitar.

##### Onde está a prova

**`tests/Caracterizacao/HashDaUnidadeTest.php`** — 12 casos nos dois bancos.
Inclui dois que descrevem comportamento que ninguém deveria descobrir por
acidente: `hash_equals('', '')` é **verdadeiro**, então vazio e nulo são
recusados antes da comparação; e **rotacionar duas vezes não empilha** — a
janela é uma, não uma fila, e quem rotaciona de novo antes de ela fechar derruba
quem estava no hash de duas rotações atrás.

**Uma entrada nova em `conferir-autorizacao-privilegiada.php`**, para a decisão
dentro do controller que teste nenhum alcança. Ela exige o registro do uso: sem
ele a janela vira só uma segunda credencial permanente. Removi a linha e o
portão acusou.

##### DE PASSAGEM: o inventário atribuía o auxiliar à ação anterior

A quarta ocorrência da mesma família de rótulo falso — as outras estão no **D70**
e em `docs/f2-fatia-pdv.md`.

`scripts/inventariar-acoes.php` fatiava o código de uma **ação até a ação
seguinte**, por deslocamento. Um método **privado** no meio caía dentro do corpo
da ação anterior, e tudo o que ele fizesse era atribuído a ela.

Apareceu porque `unidadeAutorizada()` ficou logo depois de `unidadeVendasV3Action`
— e o inventário passou a dizer que **só o V3 muta e escreve direto**. As três
ações chamam o mesmo auxiliar e nenhuma escreve no próprio corpo. O rótulo era
falso de um jeito pior que errado: era **inconsistente entre três ações
idênticas**, o que sugere uma diferença que não existe.

A fatia agora termina no **próximo método**, e não na próxima ação. O efeito é
maior do que o caso que o revelou: **28 das 360 linhas mudaram**, quase todas em
controllers com auxiliar privado entre ações — serviços e repositórios que a
ação não usa deixaram de ser atribuídos a ela, e o total de `CRIAR` caiu de 158
para 157.

Uma ação passou a ter veredito **`CONFERIR`**: `CaixasController::gerenciar`
delega para dois métodos privados e não devolve nada por conta própria. O
veredito anterior vinha do corpo do `abrir()`. *"Não sei, leia antes de
decidir"* é mais útil que um palpite bem formatado.

##### O QUE PRECISA ACONTECER NO DEPLOY

O `ALTER TABLE` das três colunas entra na lista que espera o descongelamento —
agora são **quatro** mudanças aditivas esperando a mesma janela (D54, D86, D88 e
esta). **Sem elas o código não roda**: `hashAnteriorConfere` lê colunas que não
existiriam.

A rotação em si é operação, não deploy: uma unidade por vez, trocando o hash na
consulta do Power BI, e conferindo pelo relatório quem ainda não trocou antes de
encerrar o antigo.

#### 12/10/2026 — O COOKIE TEM O PRAZO DO NAVEGADOR — pela resposta 103 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 103** (a opção "a"): *"12 horas depois da última entrada, como o navegador -- o servidor confere a hora da
última entrada da pessoa (o registro de acessos já guarda, também a do cookie desde 01/09) e o valor copiado para de
entrar 12 horas depois da última entrada. Sem mudar o banco."*

##### O conserto

`AutenticacaoController::entrarCookies` só aceita o `token_web` quando `AutenticacaoService::cookieAindaVale` diz que a
pessoa entrou **pela senha ou pelo cookie** nas últimas 12 horas (`UsuarioAcessoRepository::entrouPeloNavegadorDesde`,
pelo índice `(usuario, data)` dos dois bancos). As 12 horas moram num lugar só, `SessaoAcl::PRAZO_DO_COOKIE` (43.200
s), que o `setCookie` também usa -- é o prazo que o navegador já aplicava. **A entrada assumida por um administrador
não conta** (o D76): ela não toca no cookie da pessoa, e contar faria o valor copiado dela valer mais 12 horas a cada
vez que alguém a assumisse. As linhas de antes (`web`, `''`) contam: foram todas de senha ou de cookie. Fora do prazo, a
resposta é a página do formulário, como a do cookie que não existe, e nada se grava. **Ninguém percebe:** o navegador já
apagava o cookie nesse prazo, e cada entrada pelo cookie renova as 12 horas, como antes.

##### Provado

- `RegistroDeAcessosTest`, nos dois bancos: oito situações pelo `entrar` de verdade (pela senha há quase 12 horas e
  pelo cookie há 11, entram; há 12 horas e um minuto, há dois dias e sem entrada nenhuma, não; as linhas de antes, `web`
  e vazia, de uma hora atrás, entram; a assumida de uma hora atrás com a da pessoa de 13 horas, não) -- e só o cookie
  que entra grava; e a borda com o relógio na mão (o segundo 43.200 vale, o 43.201 não). Os arneses do D315 e do D347
  (`cookieQueEntra`, o `setUp` do `SenhaTrocadaTest`) ganharam a entrada que o login de verdade deixa.
- Por HTTP, o caso 23 do `ensaiar-sessao-do-erp.php`, nos dois bancos de contrato: com as entradas do gerente das
  últimas 12 horas deslocadas 13 horas para trás, o mesmo `token_web` de um login novo responde o formulário (200); de
  volta às horas, ele entra (302). Falsificado por HTTP, no MySQL: sem o prazo, o caso 23 fica vermelho (22 de 23).
- Mutantes, no MySQL, cada um vermelho: o `entrarCookies` sem o prazo (os quatro casos de fora do prazo), a entrada
  assumida contando (o dela) e a borda estrita (`>` no lugar de `>=`, o da borda).

##### A verificação do mesmo dia

**A entrada tem de ser da própria pessoa, e nada o provava.** Sem o `ace.usuario = :usuarioId` na consulta de
`entrouPeloNavegadorDesde`, o `token_web` de qualquer pessoa entraria sempre que alguém tivesse entrado nas últimas 12
horas -- em produção, sempre --, e nenhum caso o via (o verificador conferiu os 990): todo conjunto grava entradas só
da pessoa, e os bancos de teste não têm linha nenhuma em `usuarios_acessos`. Por HTTP, o caso 23 pegaria só por acaso (a linha `senha` do
administrador que o caso 22 cria). Agora o `RegistroDeAcessosTest` tem a nona situação -- outra pessoa entrou pela senha
há uma hora, e a pessoa há 13 horas: não entra -- e a borda confere a consulta com o id de outra pessoa. Mutante, nos
dois bancos: a consulta sem o usuário (a condição e o parâmetro) fica vermelha nos dois casos.

**O resíduo, que é o desenho da 103.** Cada entrada pelo cookie também é "a última entrada": ela grava a linha `cookie`,
e o `setCookie` dá mais 12 horas ao navegador -- é o "como o navegador" da resposta. Por isso **o valor copiado que é
usado a cada menos de 12 horas não para de entrar**: quem o copiou renova o prazo a cada uso, como o navegador da pessoa
renovaria. Ele cai quando a pessoa entra pela senha (o `criarToken` gira o `token_web`), clica em Sair, ou passa 12
horas sem que ninguém use o valor; e cada uso de quem o copiou fica no registro de acessos da pessoa, "pelo cookie". O
"Ninguém percebe" acima vale para quem usa o próprio navegador; o "para sempre" de quem para sem sair só acaba se o
valor copiado também parar. Fechar esse resíduo é um teto contado da última entrada pela senha -- a opção (b) da
pergunta 103 ("as 12 horas, e a senha de novo a cada 30 dias"), que o dono não escolheu. Fica registrado, sem pergunta
nova.

**O que continua aberto, e não é daqui:** o `ALTER TABLE` das três colunas do hash anterior e as rotações dos hashes de
unidade e de cliente -- janela e produção. (A pesquisa do `pesquisas.hash`, a quinta credencial da tabela acima, deixou
de abrir qualquer coisa em 12/10/2026: o link público avulso saiu, o D399.)

**06/11/2026: a entrada automática na tela nova decidida pela resposta 317 (a) do dono** (gravada na página do lote em
07/11/2026 01:14 UTC, sem observação): "Fica como está: entra sozinho, pela mesma regra do ERP". A fatia 94 já fazia assim
(`POST /adm/api/sessao/retomar`, pela `Permissions\EntradaPeloCookie`, a mesma regra do `entrarCookies`), e nada muda. O
estado deste débito não muda por isso: o que continua aberto é o de cima -- o `ALTER TABLE` e as rotações, da janela e da
produção.


### D78 — A coluna chamada `salt` guarda a senha, e ela e reversivel

Descoberto em 01/09/2026, seguindo o gerador de credenciais do **D77**.

O **D7** olhou para esta coluna em 17/08 e escreveu: *"Salt nao e segredo por
definicao, entao o impacto e menor"*. **A frase esta errada, e errou pelo nome
da coluna.**

#### O que a coluna guarda

```php
public function setSalt($salt) {
    $this->salt = openssl_encrypt($salt, 'AES-128-CBC', '0', 0, '0123456789101112');
}

public function getSalt() {
    return openssl_decrypt($this->salt, 'AES-128-CBC', '0', 0, '0123456789101112');
}
```

E quem a preenche:

```php
public function criptografarSenha($senha) {
    $this->setSalt($senha);                          // <- a SENHA entra aqui
    return password_hash($senha, PASSWORD_BCRYPT);
}
```

**A coluna guarda a senha do usuario, cifrada de forma reversivel**, com chave
`'0'` e vetor `'0123456789101112'` -- os dois literais no codigo versionado.

Provado com valor sintetico, sem tocar em senha real:

```
senha inventada:  MinhaSenhaInventada#2026
o que vai na coluna: opsGt+LTk0mzOgAxemAe79abhBag8LFeNOuObZ4TYUI=
de volta com a chave do codigo: MinhaSenhaInventada#2026
```

#### E o bcrypt e decoracao

A coluna `senha` guarda `password_hash(..., PASSWORD_BCRYPT)`, que esta certo. Mas
**a autenticacao nao usa esse hash**:

```php
// UsuarioRepository::autenticacao
if (!empty($usuario) && $senha == $usuario->getSalt()) {
```

Compara o que o usuario digitou com o retorno de `getSalt()` -- isto e, com a
senha decifrada. O `entrarCookies` faz o mesmo: `setCredential($usuario->getSalt())`.

**Quem tem o dump e o repositorio tem as senhas de todos, em texto claro.** E
senha se reusa: o estrago nao para neste sistema.

#### O que atenua, e o que nao

**Atenua:** `scripts/anonimizar.sql.modelo:81` faz `salt = NULL`, entao as copias
que circulam para desenvolvimento nao levam as senhas. O **D2** e o **D3** ja
tratam de credenciais versionadas.

**Nao atenua:** a producao tem a coluna cheia, e o esquema de criptografia esta
no repositorio. Um dump de producao vazado -- ou um acesso de leitura ao banco --
entrega as senhas.

#### DECISAO DE 01/09/2026: medir a producao antes

Apresentei tres caminhos. Voce escolheu **nao escolher ainda**, e medir primeiro
-- e o pedido esta certo, porque um dos tres pode trancar usuarios para fora.

**A pergunta que decide:** o conserto troca a autenticacao por `password_verify`
contra a coluna `senha`. Isso so e seguro se **todo usuario ativo tiver um bcrypt
valido ali**. Quem nao tiver perde o acesso no instante da mudanca.

**A copia local nao responde.** A anonimizacao faz `salt = NULL` e troca `senha`
por um hash de desenvolvimento, entao qualquer medicao aqui devolve verde falso.
Rodei para conferir o roteiro, e ele diz "0 ativos sem bcrypt valido" -- numero
que **nao vale nada**, porque mede o hash que a propria anonimizacao gravou.

`scripts/medir-senhas-producao.sql` esta pronto: **so SELECT**, nenhuma senha
impressa, e a unica listagem individual traz `id` e data. Ele espera a leitura da
producao ser descongelada.

#### O levantamento que encolheu o problema

Ao preparar as opcoes, tres fatos mudaram o tamanho do conserto:

**1. O login principal ja esta correto.** `config/autoload/doctrine.global.php`
configura o adaptador com `credential_callable` fazendo
`password_verify($senhaDigitada, $user->getSenha())`. O caminho que todo mundo
usa todo dia ja autentica por bcrypt -- e o fato de as pessoas entrarem e prova
empirica de que a coluna `senha` tem hashes validos.

**2. A senha reversivel serve a tres lugares, e so:**

| Onde | Para que |
|---|---|
| `AutenticacaoController::entrarCookies` | login por cookie |
| `UsuarioService::autenticarComo` | entrar como outro usuario -- sem chamador; APAGADO em 03/10/2026 |
| `UsuarioRepository::autenticacao` | confirmar a senha atual |

Os dois primeiros recuperam a senha apenas para **simular** um login no
adaptador. O terceiro compara texto com texto.

**3. Trocar o codigo nao remove a exposicao.** As linhas ja gravadas continuam
com a senha recuperavel ate serem sobrescritas. Qualquer conserto real precisa de
um `UPDATE` -- e ele tem de vir **depois** do codigo: na ordem inversa, o login
por cookie e o "entrar como" param de funcionar.

#### Ao resolver

Autenticar pelo `password_verify` contra a coluna `senha`, que ja existe e ja
esta preenchida com bcrypt. Nos dois fluxos de impersonacao, escrever a
identidade na sessao direto, sem passar pelo adaptador -- o token ja foi
validado antes. Depois o `UPDATE`, e so entao **apagar a coluna `salt`**.

**Nao e mudanca de uma linha, e por isso e decisao sua:** `entrarCookies` e
`autenticacao()` dependem de recuperar a senha, e trocar isso muda o fluxo de
login. E a coluna `salt` e `NOT NULL`.

**Nao fiz nada.** E o caminho de autenticacao de um sistema em producao.

### D79 — `setSenha()` estava quebrado desde 17/08/2026 — CORRIGIDO em 01/09/2026

Descoberto em 01/09/2026, ao ler o `criptografarSenha()` do **D78**.

```php
public function criptografarSenha($senha) {
    $this->setSalt($senha);
    $this->setSenhaPhp83($senha);   // <- este metodo NAO EXISTE
    return password_hash($senha, PASSWORD_BCRYPT);
}
```

O commit `8f95f86` -- "Fecha o portao da F1.3" -- removeu o mapeamento, o
`getSenhaPhp83()` e o `setSenhaPhp83()` da entidade, e **manteve a chamada**.

Conferido executando, com valor sintetico: `setSenha()` lanca
`Error: Call to undefined method ...::setSenhaPhp83()`.

#### O que isso derruba

Tudo que passa por `setSenha()`: criar usuario com senha, trocar a propria senha,
e confirmar a redefinicao por token (`AutenticacaoService::senhaConfirmar`).

#### Por que ninguem viu

O **D7** afirma que *"nada fora da propria entidade os chamava"*. Fora, nao --
**dentro** chamava, na linha seguinte ao `setSalt()`. A verificacao olhou para o
lado errado da fronteira.

E os *"67 testes de caracterizacao continuam passando"* passavam porque
**nenhum deles define uma senha**. Verde que nao cobre o caminho -- o mesmo
desenho do **D55**, onde nove contratos congelavam resposta vazia.

#### O alcance real, e ele e menor do que parece

**A producao nao esta quebrada**: ela roda o codigo de antes da modernizacao e
segue congelada desde 23/08. O que esta quebrado e o repositorio -- e o que
estiver rodando no BVIP.

**Mas o corte leva o defeito junto**, se ninguem o consertar antes.

#### CORRIGIDO em 01/09/2026

A linha `$this->setSenhaPhp83($senha);` saiu. `setSenha()` volta a funcionar --
criar usuario com senha, trocar a propria senha e confirmar redefinicao por
token.

**E agora existe o teste que faltava.** `tests/Caracterizacao/SenhaEAcessoTest.php`
define uma senha e confere que o bcrypt gravado bate com ela. Posto a prova:
devolvi a linha quebrada de proposito e **tres dos quatro testes deram erro**,
com exatamente a mensagem que ficou quinze dias escondida.

O `setSalt()` continua ali, e continua errado -- e o **D78**, que espera a
medicao da producao.

#### O que era, antes de ser corrigido

*(A versao anterior deste registro dizia que o conserto esperava o D78, porque
`criptografarSenha` faz duas coisas. Por decisao sua de 01/09, as duas foram
separadas: a linha quebrada saiu hoje, e o `setSalt` fica ate a producao ser
medida. Destravar a troca de senha nao piora o D78 -- so devolve o que ja
existia antes de 17/08.)*


### D80 — A fixture do caixa tinha validade de um dia — CORRIGIDO em 01/09/2026

Descoberto em 01/09/2026, na primeira vez que a suite rodou num dia diferente do
dia em que as fixtures foram carregadas. **25 dos 119 testes cairam de uma vez.**

#### O que aconteceu

A guarda do **D28**, escrita em 31/08, recusa venda em caixa de data anterior --
que e a regra que voce enunciou. As fixtures gravam o caixa assim:

```sql
INSERT INTO caixas (..., data_criacao, ...) VALUES (..., CONCAT(CURDATE(), ' 08:00:00'), ...)
```

`CURDATE()` e avaliado **no momento da carga**. Carregou dia 31, rodou dia 1: o
caixa e de ontem, a guarda recusa toda venda, e a suite desaba.

**A guarda estava certa.** A fixture e que tinha prazo de validade de vinte e
quatro horas, e ninguem percebeu porque naquele dia ela foi carregada e usada na
mesma tarde.

#### O sintoma enganava

As falhas nao diziam "caixa de outro dia". Diziam:

```
venda paga a menor deveria gerar debito      Failed asserting that false is not false
produto nao-servico deve dar baixa no estoque  Failed asserting that 10.0 is identical to 9.0
                                              -'89,90'  +'0,00'
```

Tudo zerado, nada gravado -- o formato de quem **nao chegou a executar**. Sao
sete arquivos de teste diferentes acusando sete coisas diferentes, e a causa era
uma so, tres camadas abaixo.

#### A correcao

`CaracterizacaoTestCase::setUp` passou a por os caixas de fixture na data de hoje,
**dentro da transacao** que ele ja abre e reverte. O acerto vai ali, e nao na
fixture, porque so ali existe a garantia de ser "hoje" no momento do teste.

Toca so a faixa 90001000+, e so quando a data difere -- nenhuma linha real e
alterada, e nada sobrevive ao `tearDown`.

#### A licao, e ela e generalizavel

**Fixture que depende do calendario quebra sozinha, e quebra longe.** Nao houve
mudanca de codigo entre o verde e o vermelho: mudou o dia. Qualquer dado de teste
montado com `CURDATE()`, `NOW()` ou equivalente tem esse prazo embutido, e o
custo aparece na forma mais cara -- vinte e cinco falhas que parecem vinte e
cinco defeitos.

*(As outras fixtures usam datas fixas, como `2026-01-01`. A do caixa precisou ser
relativa por causa da propria regra do D28, e foi por isso que so ela teve o
problema.)*

#### E havia uma segunda, no mesmo dia e por outro caminho

Consertados os 25, sobrou **um** teste falhando:
`CronTravaSimultaneaTest::testODashboardDesisteQuandoJaHaExecucaoEmCurso`, com
*"o cenario precisa de uma linha do mes corrente para observar"*.

Mesma familia, origem diferente. Ele exigia que a **copia da producao** ja
tivesse uma linha de `dashboard_colaboradores` do mes em curso -- e a copia e um
retrato de 28/07, com dados ate agosto. No dia 1o de setembro o mes virou e a
linha deixou de existir.

**O que aquele teste mede e a trava, nao a existencia do dado.** Entao ele passa
a montar o proprio cenario na faixa 90001000+, dentro da transacao, e usa a
linha da copia quando ela existe.

#### Procurei uma terceira, e nao ha

Depois de consertar as duas, varri os testes de caracterizacao atras de tudo que
usa `CURDATE()`, `NOW()`, `date(` ou `strtotime`. Sao 27 pontos, e a separacao e
limpa:

| | |
|---|---|
| **Montam** a data que usam | 25 -- inserem a linha com `date(...)` e depois a observam |
| **Encontram** | 2 -- eram exatamente as duas que quebraram, e ja foram corrigidas |

Um caso mereceu leitura por parecer risco e nao ser:
`ConvenioDescontoTest` escolhe a coluna do dia da semana com `date('w')` e a
**desabilita**, para verificar que o convenio deixa de valer. Adapta-se ao dia em
vez de depender dele.

E o que o tornaria falso -- desabilitar um dia que ja estivesse desabilitado, e
passar sem provar nada -- foi conferido: a fixture da unidade 90001000 traz os
**sete dias habilitados**.

**Duas quebras por calendario no mesmo dia, em lugares independentes.** Nao e
coincidencia: e o que acontece quando dado de teste depende de "hoje" sem que
ninguem tenha decidido isso explicitamente. Vale como regra: **teste que precisa
de uma data relativa monta essa data, nao a encontra.**

#### E o conserto do D80 quebrou o PostgreSQL, por trinta horas

Descoberto em 01/09/2026, na primeira rodada completa depois da correcao.

O metodo que eu escrevi para por o caixa no dia de hoje usa `CURDATE()`,
`TIME()` e `CONCAT()`. **As tres sao do MySQL.** No PostgreSQL a rodada morreu
com `syntax error at or near "data_criacao"`, e nao em um teste: **nos 123**, porque
o metodo roda no `setUp`.

```
Tests: 123, Assertions: 0, Errors: 123.
```

**O erro nao foi escrever SQL do MySQL. Foi conferir num banco so.** O roteiro
`scripts/rodar-caracterizacao.sh` existe justamente para rodar nos dois, e eu
tinha rodado o `phpunit` direto, contra o MySQL, porque era mais rapido. O verde
que eu vi era metade do verde que eu precisava.

Nao ha expressao unica que sirva aos dois bancos aqui: o `+` de data soma
INTERVALO no PostgreSQL e NUMERO no MySQL. O metodo passou a ramificar por
plataforma, o que e feio e honesto:

```php
$postgres = stripos($conexao->getDatabasePlatform()->getName(), 'postgre') !== false;
```

**A regra que fica**, ao lado da do calendario: **verde em um banco nao e verde**
— e este projeto tem dois justamente porque um vai substituir o outro.


### D81 — `status = 1` compara inteiro com coluna booleana, e o PostgreSQL recusa — CORRIGIDO em 02/09/2026

> **Ver tambem o D82**, que e o mesmo erro de tipo na direcao contraria: valor
> booleano indo para coluna inteira. Ele apareceu justamente ao corrigir este.

Descoberto em 01/09/2026, quando o teste novo do **D73** foi o primeiro a
exercitar o relatorio de clientes contra o PostgreSQL.

#### O que acontece

O auxiliar dos filtros abre assim:

```php
$dql .= " WHERE cli.status = 1";
```

No MySQL, `status` e `tinyint` e a comparacao passa. No PostgreSQL a coluna e
`boolean`, e o literal `1` nao tem operador:

```
SQLSTATE[42883]: Undefined function: 7 ERROR:  operator does not exist: boolean = integer
HINT:  No operator matches the given name and argument types.
```

**O relatorio de clientes inteiro nao roda no PostgreSQL** — nao devolve
resultado errado, nao devolve resultado nenhum. E o mesmo vale para todo caminho
que compare uma coluna booleana com `0` ou `1`.

#### A escala, agora medida de verdade — 02/09/2026

**O primeiro numero deste registro estava errado, e eu o marquei como "piso"
para disfarcar que era chute.** Ele dizia "37 comparacoes em 12 arquivos", e
saiu de um `grep` sobre nomes de coluna que eu ADIVINHEI. Adivinhar nome e
depois chamar o resultado de piso nao e medir com margem: e nao medir.

`scripts/conferir-booleanos-em-consulta.php` mede. A lista de campos booleanos vem
do **metadata do Doctrine**, a mesma fonte que o ORM usa para gerar SQL, e o
roteiro RESOLVE O APELIDO: junta os pedacos de DQL de cada metodo, le o
`FROM`/`UPDATE`/`DELETE <Entidade> <apelido>` e cada `JOIN <apelido>.<relacao>`,
segue as associacoes, e so entao pergunta o tipo do campo NAQUELA entidade.

| | |
|---|---:|
| **Quebram no PostgreSQL** | **26** |
| — literal na consulta, resolvidas pelo roteiro | 23 |
| — literal na consulta, resolvidas a mao | 3 |
| Inofensivas | 10 |

**As 10 inofensivas sao o motivo de o numero antigo nao servir.** `status` esta
mapeado como **boolean, string E integer**, conforme a entidade; `x.status = 1`
so quebra onde o apelido cai numa entidade em que ele e booleano. Contar por
nome misturava as duas coisas.

**As 3 nao resolvidas pelo roteiro** sao todas de `filtrosListagemIndexRelatorio`, que recebe o inicio da DQL por
parametro e por isso nao declara `FROM` no proprio corpo. Os dois chamadores
usam `FROM ISCadastro\Entity\Cliente cli`, e `Cliente::status`,
`Cliente::programaFidelidade` sao ambos `boolean`: as tres quebram. (O
`marketing` daquele metodo NAO quebra -- ele e parametro; ver a correcao
abaixo.)

#### CORRIGINDO O QUE EU ESCREVI AQUI — medido em 02/09/2026

Eu registrei, e commitei, que `cli.marketing = :marketing` com
`$params['marketing'] = (int) $request[...]` **quebraria igual ao literal**, e
que a correcao do D73 tinha "mudado o defeito de lugar" — da consulta, onde um
grep o acha, para o parametro, onde nao se ve.

**Isso esta errado.** Fui medir contra o PostgreSQL, com a coluna
`clientes.marketing`, que e `boolean`:

| forma | resultado |
|---|---|
| `:m` = int `1` | OK — 242.412 |
| `:m` = string `'1'` | OK — 242.412 |
| `:m` = bool `true` | OK — 242.412 |
| `:m` = int `0` / string `'0'` | OK — 175 |
| **literal `= 1`** | **FALHA**: `operator does not exist: boolean = integer` |
| literal `= true` | OK |

**Parametro funciona em qualquer forma.** O valor chega tipado pelo driver e o
PostgreSQL converte; o literal `1` vira inteiro na SINTAXE, e ai nao ha
operador. O defeito e so o literal escrito na consulta.

**Consequencias, e elas mudam o conselho:**

1. **A correcao do D73 CONSERTOU aquele sitio**, nao o escondeu. Trocar
   `" AND cli.marketing = " . (int) $x` por `= :marketing` fez a comparacao
   passar a funcionar no PostgreSQL.

2. **O total volta a 26**, nao 27. O caso do `marketing` nunca foi defeito.

3. **Para quem for atacar o D81: parametrizar tambem resolve.** Nao e preciso
   escolher entre `= true` e `= :flag` — as duas formas funcionam nos dois
   bancos, e a segunda tem o beneficio extra de nao ser injetavel.

4. A guarda perdeu a categoria `porParametro`, que media uma coisa que nao e
   defeito. Contar nao-defeito e pior que nao contar: infla o trabalho aparente.

**Como o erro aconteceu:** eu raciocinei que "inteiro em coluna booleana falha"
e apliquei isso ao parametro sem testar, porque o literal falhava. A diferenca
entre sintaxe e valor ligado e exatamente o assunto do D73, que eu tinha
acabado de tratar — e ainda assim escrevi o contrario.

#### Dois erros na propria medicao, e o segundo e o pior tipo

**Adivinhei os nomes das colunas** na primeira contagem. Corrigido lendo o
metadata.

**E a primeira versao do roteiro so procurava `FROM`.** DQL de `UPDATE` declara
o apelido em `UPDATE <Entidade> <apelido> SET ...`, entao `atualizarFidelizacao`
e `removerPesquisasPlanoFidelidade` caiam em "nao resolvidas" — e as duas do
`Cliente` quebram. O numero saiu de 21 para 23 quando isso foi corrigido.

**Errar para menos e o pior sentido**, porque numero baixo e o que faz alguem
concluir que da para adiar.

#### O que a medicao NAO alcanca

**Parametro ligado como inteiro para coluna booleana.** `$params['marketing'] =
(int) $x` com `cli.marketing = :marketing` falha igual no PostgreSQL, e nao
aparece em varredura nenhuma: o valor so existe em execucao. **A correcao do
D73 criou uma dessas**, no filtro de marketing do relatorio de clientes.

Consulta montada fora de `module/`, se houver, tambem fica de fora.

#### Por que nao consertei junto

A mudanca do D73 e sobre injecao. Trocar `= 1` por `= true` em 37 lugares e uma
mudanca de outra natureza, sem cobertura, e **consertar so o sitio que o meu
teste alcanca seria pior que nao consertar**: daria verde na suite e deixaria os
outros 36, com a aparencia de resolvido.

#### Como isto aparece hoje

`tests/Caracterizacao/RelatorioClientesInjecaoTest.php` **salta no PostgreSQL**, com
a mensagem dizendo qual e o defeito. Nao e `@group` silencioso: quem rodar a
suite le o motivo. A prova do D73 naquele arquivo vale contra o MySQL; a do
dashboard de faturamento roda nos dois.

Quando o D81 sair, a condicao do salto sai junto, e o teste passa a cobrar os
dois bancos — o que tambem serve de guarda contra o D81 voltar.

#### CORRIGIDO em 02/09/2026

Os 26 literais viraram `= true` / `= false`, por SITIO EXATO — arquivo, linha e
trecho, nao busca-e-substitui. A distincao importa: `status` e booleano em
algumas entidades e `string` ou `integer` noutras, e uma troca cega quebraria as
10 comparacoes que ja estavam certas.

23 linhas em 12 arquivos. No MySQL o Doctrine traduz `true` de volta para `1`,
entao a mudanca preserva o comportamento la e corrige aqui.

**A prova nao e a minha sondagem, e sim a suite:**

|  | antes | depois |
|---|---|---|
| caracterizacao MySQL | OK (126, 240) | OK (126, 240) |
| caracterizacao PostgreSQL | 126, **4 saltados** | **OK (126, 239)** |

O `RelatorioClientesInjecaoTest` **perdeu o salto do PostgreSQL** — era a
condicao escrita quando o salto foi posto ("quando o D81 sair, esta condicao sai
junto"). Os 126 testes passaram a rodar nos dois bancos.

A diferenca de uma assercao tem explicacao exata: no teste da carga `OR 1=1` o
MySQL converte o valor e cai no `assertLessThanOrEqual`, que conta DUAS
assercoes por ser restricao composta; o PostgreSQL recusa e cai no `assertNull`,
que conta uma.

E as sondagens, reexecutadas sem alteracao: dos 15 caminhos que quebravam,
**14 passaram a rodar**. O 15o e o assunto do D82.

#### O QUE PARA, POR TELA — rastreado em 02/09/2026

"26 comparacoes em 12 repositorios" nao ajuda ninguem a priorizar. Subi de cada
sitio ate quem o chama.

**Seis dos 26 estao em CODIGO MORTO** — sem chamador em `module/` nem em
`public/`:

| Metodo | Sitios |
|---|---:|
| `UnidadeRepository::selecionarAniversariantes` | 1 |
| `UnidadeSocioRepository::selecionarAniversariantes` | 2 |
| `UsuarioRepository::selecionarAniversariantes` e `...TempoTrabalho` | 2 |
| `ClienteRepository::selecionarClientesAtivos` | 1 |

**Sobram 20 vivos**, e estes sao os caminhos:

| O que para | Onde | Sitios |
|---|---|---:|
| **O PDV** — `VendaFinalizarModal` e `VendaIndex` montam a lista de formas de pagamento | `FormaPagamentoRepository::popularCombobox` | 1 |
| **A API do aplicativo** — `AplicativoController:792` busca cliente por telefone | `ClienteRepository::selecionarClientesPorTelefone` | 1 |
| Cadastro de cliente e tela de fidelidade (mesmo metodo) | `ClientesController`, `FidelidadeController` | — |
| Tela de vendas — o debito do cliente | `ClienteDebitoRepository::selecionarValorDebitosCliente` | 1 |
| Financeiro — formularios de saida | `SaidaTipoRepository::popularCombobox` | 1 |
| Servicos, usuarios e o formulario do relatorio de clientes | `AreaAtuacaoRepository::popularCombobox` | 1 |
| Assistentes e mensagens | `GrupoRepository::popularCombobox` | 1 |
| Lista de convenios | `ConvenioRepository::listagemIndex` | 1 |
| Relatorio de caixas | `CaixaRepository::selecionarPorPeriodoUnidade` | 1 |
| Agenda — clientes novos | `ClienteRepository::listagemIndexClientesNovos` | 1 |
| Marketing — pesquisa de clientes | `ClienteRepository::marketingPesquisarClientes` | 1 |
| Relatorio de clientes (as 3 do auxiliar) | `filtrosListagemIndexRelatorio` | 3 |
| **Cron** — fidelizacao, aniversariantes, clientes sem retorno | `CronJobService` | 7 |

**O que isso reordena.** O relatorio de clientes, que foi por onde tudo isso
apareceu, e um item entre treze — e nao o mais grave. **O PDV e a API do
aplicativo estao na lista**, e sao respectivamente o que as 32 unidades usam
para fechar venda e o que ~800 clientes por dia usam no telefone.

#### E entao eu executei, em vez de inferir — 02/09/2026

A tabela acima nasceu de rastro de CHAMADOR, o que e inferencia. Chamei os
metodos, um a um, contra o PostgreSQL:

| caminho | resultado |
|---|---|
| **o PDV** (formas de pagamento) | **QUEBRA** |
| **API do app** (cliente por telefone) | **QUEBRA** |
| tela de vendas (debito do cliente) | **QUEBRA** |
| financeiro (tipos de saida) | **QUEBRA** |
| servicos/usuarios (areas de atuacao) | **QUEBRA** |
| cron: aniversariantes | **QUEBRA** |
| cron: clientes sem retorno | **QUEBRA** |
| cron: fidelizacao | **QUEBRA** |

**Oito de oito**, todas com a mesma mensagem:
`operator does not exist: boolean = integer`.

Nao e mais suposicao: **essas telas nao executam no PostgreSQL hoje.**

**E depois alcancei os seis que faltavam.** Eles pediam objeto de sessao ou o
`$request` da tela; montei um substituto minimo de sessao (so
`getUsuario('administrador')` e `getUnidade('id')`) e as chaves que cada um le:

| caminho | resultado |
|---|---|
| assistentes/mensagens (grupos, **nao-admin**) | **QUEBRA** |
| assistentes/mensagens (grupos, **admin**) | **QUEBRA** |
| relatorio de caixas (periodo) | **QUEBRA** |
| lista de convenios | **QUEBRA** |
| marketing (pesquisa de clientes) | **QUEBRA** |
| agenda (clientes novos) | **QUEBRA** |
| cron: fidelizacao por colaborador | **QUEBRA** |

Onde o codigo ramifica por administrador, rodei os dois ramos — um caminho
que so quebrasse para admin passaria despercebido.

**Somando as duas rodadas: 15 execucoes, 15 quebram.**

#### DUAS VEZES EU LI VERDE ONDE NAO HAVIA CONSULTA

A lista de convenios deu "PASSOU" duas vezes antes de quebrar, e as duas foram
erro meu:

1. **O metodo devolve um paginador, que e PREGUICOSO.** Chamei e larguei; nao
   executou consulta nenhuma. "Passou" significava "nao rodou".

2. Forcei com `getTotalItemCount()` — e ainda passou. Porque aquilo executa a
   consulta de CONTAGEM, e o Paginator **descarta a subconsulta do `SELECT`** ao
   monta-la. E na subconsulta que mora o `cli.status = 1`.

So `getCurrentItems()`, que busca as linhas, alcancou a subconsulta. **E a
terceira vez nesta semana que um verde meu era ausencia de execucao** — as
outras duas foram o `count()` no paginador (que devolve paginas, nao itens) e o
`git stash` de um arquivo ja commitado (que nao guardou nada).

**Fica de fora, ainda, `ClienteRepository::atualizarFidelizacao`:** e um `UPDATE`
que zera `fidelizadoColaborador` e `fidelizadoUnidade`, e sondagem que escreve
deixa de ser sondagem. Pelo texto, ele quebra igual — mas isso e leitura,
nao execucao, e esta escrito assim de proposito.

**E um ficou de fora de proposito:** `ClienteRepository::atualizarFidelizacao` e
um `UPDATE` que zera `fidelizadoColaborador` e `fidelizadoUnidade`. Sondagem que
escreve deixa de ser sondagem.

#### A pergunta que ficou aberta, e o quanto dela ja da para responder — 02/09

Eu escrevi aqui: "se o relatorio de clientes nao roda, quantas outras telas nao
rodam?" A pergunta continua sem resposta completa — isso se responde
exercitando as telas, nao varrendo codigo — mas tres das quatro maneiras
conhecidas de um sistema MySQL nao rodar no PostgreSQL ja foram olhadas.

**1. SQL cru na producao: nao e risco.** `module/` inteiro tem **UMA** execucao
de SQL direto, em `ISBase\Doctrine\DeclararInquilino`, e ela e PostgreSQL-only
por desenho (guardada por `instanceof PostgreSqlPlatform`) e usa parametro. Tudo
o mais passa por DQL. As ~110 execucoes de SQL cru do projeto estao nos TESTES,
que rodam contra os dois bancos — e onde havia MySQL puro, ja quebrou e foi
corrigido (o `DATE_SUB(..., INTERVAL ? DAY)` de `CaixaNaoAtravessaODiaTest`).

**2. Funcoes de DQL: tratadas de proposito.** `DATE`, `DAY`, `MONTH`, `YEAR`,
`DAYOFWEEK`, `TIMESTAMPDIFF` e as demais sao classes proprias em
`module/ISBase/src/Doctrine/`, e elas RAMIFICAM POR PLATAFORMA — `DateFunction`
emite `DATE(x)` no MySQL e `CAST(x AS date)` no PostgreSQL. Nao e sorte: esta
escrito no codigo, com o motivo.

**3. `GROUP BY` estrito: provavelmente nao e problema, e nao verifiquei.** O
PostgreSQL exige que toda coluna selecionada e nao agregada esteja no `GROUP BY`
— o MySQL nao, com `ONLY_FULL_GROUP_BY` desligado. O relatorio de clientes
seleciona ~25 colunas e agrupa por tres. **Mas as tres sao chaves primarias**
(`cli.id`, `con.id`, `colab.id`), e o PostgreSQL aceita dependencia funcional
desde a versao 9.1: agrupar pela PK cobre as colunas daquela tabela. Pela
leitura, passa. **Nao vi rodar**, porque o erro do booleano acontece antes —
e um defeito atras de outro foi exatamente o que aconteceu com o D80 hoje.

**4. Booleano comparado com inteiro: e esta, e sao 27.** A unica das quatro que
sobra como defeito medido e aberto.

**O que isso muda para a decisao:** o D81 nao e a ponta de um iceberg
desconhecido. E o item que restou depois de tres classes vizinhas terem sido
olhadas — duas resolvidas e uma provavelmente inofensiva. Continua valendo
exercitar as telas antes do corte; o que muda e que isso deixa de ser uma
expedicao as cegas.


### D82 — O espelho do D81: valor booleano indo para coluna inteira — CORRIGIDO em 02/09/2026

> **Ver tambem o D81**, corrigido em 02/09: literal inteiro contra coluna
> booleana. Sao direcoes opostas do mesmo erro de tipo, com correcoes distintas.

Descoberto em 02/09/2026, no instante em que o D81 saiu da frente. **Nao e
regressao da correcao** — e um defeito que estava atras dela.

#### O caso confirmado

`CronJobService:587` monta os parametros do relatorio de fidelizacao assim:

```php
$parametros = [..., 'colaborador' => $colaborador['id'], 'status' => true];
```

E `VendaRepository::fidelizacaoPorColaborador` liga esse valor a
`ven.status = :status`. Mas **`Venda::status` e `integer`**, nao booleano. No
MySQL o `true` vira `1` e passa; no PostgreSQL vira `'t'`, e a coluna recusa:

```
SQLSTATE[22P02]: invalid input syntax for type integer: "t"
```

E o mesmo erro de tipo do D81, **na direcao contraria**: la era literal inteiro
numa coluna booleana; aqui e valor booleano numa coluna inteira.

#### A escala, medida em 02/09/2026 — sao 8

`scripts/conferir-booleano-em-coluna-inteira.php`, o espelho da guarda do D81: le o
tipo do campo no metadata, resolve o apelido pelo `FROM`/`UPDATE`/`JOIN`, e so
entao procura o literal `true`/`false`.

| | |
|---|---:|
| No MESMO metodo | 6 |
| Montado por QUEM CHAMA | 2 |
| **Total** | **8** |
| Nao resolvidas (conferidas a mao, nenhuma e caso do D82) | 13 |

**As 6 do mesmo metodo:**

| onde | campo |
|---|---|
| `VendaRepository:933` (x2) | `Venda::npsNotificacao` e `npsRespondido`, ambos `integer` |
| `VendaProdutoRepository:178` e `:200` | `Venda::npsRespondido`, `integer` |
| `VipClassRepository:129` | `VipClass::status`, `integer` |
| `TesteEstiloColunaRepository:12` | `TesteEstiloColuna::status`, `integer` |

**As 2 do chamador**, as duas em `CronJobService:574`, do mesmo array
`$parametros`: `fidelizacaoPorColaborador()` e `evasaoPorColaboradorTotalVendas()`,
ambas ligando `status => true` a `Venda::status`, que e `integer`. **A primeira
e a que foi confirmada por execucao**; a segunda eu nao conhecia.

#### DUAS VEZES O MEDIDOR ERROU, e as duas foram pegas pelo caso conhecido

Ter UM caso confirmado por execucao foi o que permitiu conferir o medidor — e
ele falhou duas vezes antes de acertar:

1. **A primeira versao devolveu 6 e NAO continha o caso conhecido.** Ela so
   procurava o literal dentro do mesmo metodo, e o `CronJobService` monta o
   array num arquivo enquanto a DQL vive noutro. Um medidor que nao ve o caso
   que eu sei que existe nao mede nada.

2. **A segunda criou uma categoria "A CONFERIR" com 316 linhas.** Ela listava
   TODO parametro ligado a coluna nao-booleana — inclusive `est.uf = :estado`,
   que e texto indo para coluna de texto. Isso e o erro oposto ao chute do D81:
   em vez de subestimar, **superestimava por cinquenta vezes**.

So a terceira — seguir o chamador, um salto — produziu um numero que significa
alguma coisa.

**O que o medidor ainda nao ve**, e esta escrito nele: valor que chega por
variavel atribuida a mais de um salto de distancia, e consulta montada com
`QueryBuilder` em vez de DQL em texto (o `VisagismoController:278` e uma dessas
— conferida a mao, e inofensiva: `Visagismo::status` e `string` e recebe
`'finalizado'`).

#### CORRIGIDO em 02/09/2026

Os 8 sitios viraram o inteiro que significavam: `true` para `1`, `false` para
`0`. Sao 6 ligacoes em 5 arquivos — duas delas servem a dois sitios cada.

No MySQL, `true` ja se ligava como `1` para coluna inteira; a mudanca preserva o
comportamento la e corrige aqui.

    medicao          quebram 0, pelo chamador 0
    caminhos         16 verdes, 0 vermelhos conhecidos, 16 caminhos

**E O PORTAO CONTINUOU VERMELHO DEPOIS DA CORRECAO** — o que foi bom. Ele chamava
`fidelizacaoPorColaborador` com o proprio `status => true`: eu tinha corrigido a
producao e deixado o dado de teste com o valor antigo. O valor do portao passou a
espelhar o que `CronJobService:587` passa hoje.

**O limite disso esta escrito no portao:** como o valor mora nele, ele nao veria
alguem devolver o `true` no chamador. Quem vigia isso e
`scripts/conferir-booleano-em-coluna-inteira.php`, que le o codigo. **Um
executa, o outro le, e nenhum dos dois sozinho cobre o caso.**

A medicao virou guarda, com linha de base 0/0/13 e falhando nos dois sentidos.

#### E os caminhos viraram portao — 02/09/2026

As sondagens que acharam o D81 e o D82 viviam no scratchpad, e sumiriam.
`scripts/conferir-caminhos-postgres.php` e a versao que fica: executa 15
caminhos reais da aplicacao contra o PostgreSQL, com o D82 listado como
**vermelho conhecido, com o motivo escrito**.

Ele preenche um buraco de METODO, e nao so de codigo. Antes dele o projeto tinha
`verificar-schema-postgres.php` (compara esquema) e `gerar-ddl-postgres.php`
(gera DDL) — ambos necessarios, nenhum suficiente. O D81 passou despercebido
justamente por ser erro que so aparece ao RODAR a consulta.

    14 verdes, 1 vermelho conhecido, 15 caminhos

**Falha nos dois sentidos, e foi provado:** tirando o D82 da lista de conhecidos,
o portao acusa `REGRESSAO` e sai com codigo 1. Quando o D82 for corrigido, ele
avisa que a linha de base precisa descer junto.

**E ele diz o que NAO e:** 15 caminhos nao sao o sistema. Responde "estes
continuam rodando?", nao "o sistema roda no PostgreSQL".

#### Por que nao consertei junto

Trocar `true` por `1` naquela linha resolveria o unico caso que me atrapalhava e
deixaria os outros, com a aparencia de resolvido. E exatamente o argumento que
usei para nao remendar o D81 pela metade, e vale igual aqui.

#### O que este achado ensina sobre o metodo

Os 15 caminhos do D81 foram medidos por execucao, e 14 passaram a funcionar. O
15o revelou ESTE defeito — que so era visivel depois do outro sair. E a terceira
vez que isso acontece no projeto: antes foram o D80 (tres quebras do caixa atras
de uma) e o `DATE_SUB` escondido atras da falha total do PostgreSQL.

**Corrigir um defeito e um ato de medicao:** ele revela o proximo.


### D83 — A F1.1 vai para a homologação — DECIDIDO em 02/09/2026 — REMISSÃO ao D1, D2, D3 e D7, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está reduzido a remissão: o defeito mora em outro débito. Remete ao D1, D2, D3 e D7, onde moram as pendências da F1.1 (rota migrar-senhas, credenciais, arquivos do webroot, senha_php83).

Por decisão sua: *"anotar f1.1, f1.7 e f1.9 como débito tecnico, isso será
resolvido na homologação. Preciso avançar na fase 2 e 3"*.

A F1.1 é a remediação que se aplica **no servidor**, não no código. Ela vinha da
Fase 0, foi congelada em 23/08 e agora sai do caminho crítico da Fase 1.

#### O que fica pendente, e continua valendo em produção

| | |
|---|---|
| `/autenticacao/migrar-senhas` | rota pública que, **num GET**, sobrescreve o hash de senha de TODOS os usuários ativos (**D2**) |
| Seis credenciais em texto claro | versionadas, aguardando rotação (**D3**) |
| Arquivos com dados pessoais no webroot | acessíveis por URL (**D1**) |
| `usuarios.senha_php83` | 2.430 senhas com criptografia reversível e chave fixa no código (**D7**) |

#### O que já está pronto e parado

`scripts/remediacao-dia0.sh` (audita por padrão; `--aplicar` move para quarentena
datada e grava a apuração LGPD) e `scripts/bloquear-migrar-senhas.sh`, este
**provado contra um nginx real**. Nenhum dos dois precisa de trabalho novo: o que
falta é a janela e a autorização.

#### O que esta decisão significa, dito uma vez

**A rota `migrar-senhas` continua ativa até a homologação.** Um GET nela
sobrescreve o hash de todos os usuários ativos, e ela não pede autenticação. O
risco não mudou de tamanho por ter mudado de lugar no plano — mudou de prazo.

Isso não é objeção à decisão, que é sua e está tomada: é o registro do que se
aceita ao adiar, para que a escolha continue visível quando a homologação chegar.


### D84 — A F1.7 vai para a homologação — DECIDIDO em 02/09/2026 — PARCIAL, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: na homologação: VPN em malha, três nós de etcd, arquivos fora do disco local (D33), troca de tráfego por DNS e o ensaio no parque.

Alta disponibilidade com failover automático, ensaiada em laboratório e não no
parque. Sai do caminho crítico pela mesma decisão de 02/09.

#### O que está provado, e continua valendo

| | |
|---|---|
| Promoção automática, sem intervenção | provada **três vezes** |
| Tempo | **30 a 36 s** contra meta de 300 |
| Reintegração do nó caído | automática |
| RPO 0 | provado, com `sync_standby` designado |

O laboratório fica versionado e repetível (`f1-7-ensaio-failover.md`). Quando os
cinco itens abaixo estiverem prontos, o mesmo roteiro roda no parque, e a
comparação entre os dois números dirá quanto a rede real custa.

#### Os cinco itens, em ordem de dependência

| | O que falta | Por quê |
|---|---|---|
| 1 | VPN em malha | o Orange Pi está atrás de CGNAT: não recebe conexão, logo não vota nem assume |
| 2 | Três nós de etcd | um só não tem quórum; sem maioria, um nó isolado pode promover indevidamente |
| 3 | Arquivos fora do disco local | **D33** — a abstração está pronta desde a F1.5; falta ligar o driver S3 |
| 4 | Troca de tráfego | DNS com verificação de saúde, contando o TTL no tempo total |
| 5 | Ensaio no parque | primário real derrubado sem aviso, cronometrado |

#### A consequência para o corte

**O corte vai de nó único.** Isso era uma decisão em aberto e passou a ser
consequência desta: sem os cinco itens, não há failover automático no dia. Se o
primário cair durante a janela, a volta atrás é manual, pelo
`procedimento-de-corte.md`.


### D85 — A F1.9 vai para a homologação — ENCERRADO em 09/09/2026

Repontar o BI. Sai do caminho crítico pela mesma decisão de 02/09.

#### O lado do banco está pronto e provado

O papel `bvip_bi` é **somente leitura** (cinco tentativas de escrita recusadas),
com escopo por inquilino e contraprova, lendo de **réplica** — onde nem o
superusuário escreve.

#### O lado da ferramenta não foi tocado

Trocar o conector, instalar o provedor Npgsql no gateway e republicar. Nada
disso é trabalho de banco, e é o que depende da Microsoft (**D9**).

#### O RISCO QUE ESTA DECISÃO CARREGA, e é o maior dos três

**Hoje o Power BI lê o MySQL de produção direto.** Se o banco migrar sem
repontar, o BI **quebra no corte** — não degrada, para.

Adiar a F1.9 para a homologação só é seguro se o repontamento acontecer **antes**
do corte, e não depois. O portão de saída da F1.9 continua sendo "BI lendo do
PostgreSQL, com credencial própria e sem permissão de escrita", e ele precisa
fechar antes do passo 1 do `procedimento-de-corte.md`.

#### ENCERRADO EM 09/09/2026 — o risco saiu junto com o BI

O maior risco registrado acima era: *"Se o banco migrar sem repontar, o BI
quebra no corte — não degrada, para."*

**Não há mais o que quebrar.** O Power BI saiu do sistema por decisão do dono
(**D9**), e o portão de saída desta frente deixou de ter sujeito. O
repontamento sai do pré-requisito do passo 1 do `procedimento-de-corte.md`.

**O ganho no corte é medível, e é por subtração:** um papel a menos para criar,
dez provas a menos para conferir — `scripts/conferir-papeis.sh` foi de dezesseis
para seis —, um roteiro a menos (`conferir-bi-replica.sh`, 159 linhas), uma
variável de ambiente a menos em quatro roteiros, e a Microsoft fora do caminho
crítico.

**O que este encerramento NÃO resolve** está no D9: a credencial que o Power BI
usava continua existindo no MySQL de produção. Tirar o consumidor não revoga o
usuário.

#### E o achado que esta frente revelou continua aberto

Ao começar a F1.9 apareceu o **D48**: as 75 políticas de isolamento por
inquilino da F1.3 **não estavam em vigor**, porque a aplicação conectava como
superusuário — que está acima de qualquer política. Medido, sem declarar
inquilino, o superusuário via **61 unidades**. Corrigido no ambiente com
`bvip_app`; **em produção, ainda não.**

---

### D86 — A autorização da venda em fiado não autoriza: é um aviso no navegador — PARCIAL em 02/09/2026

> **Ver também o D165** — a outra metade, achada em 09/09/2026. Aqui a
> autorização **não existia** de verdade; lá ela **existe demais**: uma vez
> gravada, nada a limpa, e ela sobrevive ao fiado que a motivou.

Requisito do responsável pelo projeto, 02/09/2026, em três partes:

> 1. A autorização deve ser verificável e rastreável. Caso uma venda seja
>    lançada como fiado, junto com o operador de caixa deve aparecer o gerente
>    que autorizou a venda (hoje não existe isso — expandir visualização e
>    registro).
> 2. Somente o gerente daquela unidade ou gerente geral podem autorizar
>    (verificar antes de autorizar).
> 3. Conferir as credenciais de fato e validar da forma correta no banco de
>    dados.

**O que é a venda em fiado.** O cliente não paga na hora, mas o lançamento
precisa acontecer porque o serviço foi de fato prestado — é ele que produz a
conta-parte do barbeiro. O pagamento entra depois, pela quitação de débitos, com
a forma correta, e só então cai no caixa da unidade. A autorização do gerente é
o que separa "crédito concedido" de "venda que ninguém pagou".

O mecanismo **já existe**: `pdvAutorizarVendaFiado`
(`public/layout/scripts/mod-vendas.js:856`) abre o modal, e
`VendasPagamentosController::autorizarVendaFiadoAction` (linha 106) responde.
Ele tem quatro buracos, medidos hoje no código.

#### Buraco 1 — a autorização não é aplicada, é apenas consultada

`autorizarVendaFiadoAction` devolve `{sucesso: true}` e **não grava nada**. Quem
aplica o resultado é o navegador: `mod-vendas.js:875` faz
`if (response.sucesso === true) pdvFinalizarVenda();`.

`finalizarAction` (linha 72) não menciona fiado, nem autorização, nem gerente —
verificado. **Chamar `finalizar` direto pula o modal inteiro.** Não é uma
suposição sobre o que dá para fazer: é a ausência da verificação no único lugar
que decide.

Enquanto a decisão morar no navegador, o requisito 1 não tem o que registrar,
porque não há momento no servidor em que "esta venda foi autorizada por fulano"
seja um fato.

#### Buraco 2 — não é o gerente **daquela** unidade

O teste é `$usuario->getGrupo()->getGerente()` (linha 126): um sinalizador do
**grupo**, não da unidade. Gerente de qualquer uma das 32 unidades autoriza
fiado em qualquer outra.

Os campos do `Grupo` são `administrador`, `gerente`, `caixa`, `colaborador` e
`status` — **não existe "gerente geral"** como sinalizador próprio. O que existe
é `administrador`, e o próprio sistema já o usa com esse sentido em
`VendasController.php:177`:

```php
$venda->getUnidade()->getId() == $usuario->getUnidade()->getId()
    || $usuario->getGrupo()->getAdministrador()
```

Esse é o precedente a seguir: **mesma unidade, ou administrador**. `Usuario`
tem `unidade` como `ManyToOne` — uma só —, então "gerente daquela unidade" é
verificável.

Uma ressalva medida: `Venda::getUnidade()` (linha 188) **é derivada**, não
guardada — devolve `getUsuario()->getUnidade()`. A unidade da venda é a unidade
*atual* do operador. Se o operador for transferido, a unidade das vendas
passadas dele muda junto. Para a verificação no ato isso não atrapalha; para a
auditoria depois, atrapalha, e é mais um motivo para o requisito 1 gravar.

#### Buraco 3 — a senha é comparada em texto claro

`UsuarioRepository::autenticacao` (linha 111):

```php
if (!empty($usuario) && $senha == $usuario->getSalt()) {
```

`Usuario::getSalt()` (linha 815) faz
`openssl_decrypt($this->salt, 'AES-128-CBC', '0', 0, '0123456789101112')` — chave
e IV fixos, no código-fonte. A coluna `salt` **não guarda um salt**: guarda a
senha em texto claro, cifrada de forma reversível (`criptografarSenha`, linha
1485, grava o bcrypt em `senha` e chama `setSalt($senha)` com o texto claro).

Então a comparação é texto claro contra texto claro, com `==` e não
`hash_equals`.

**A forma correta já está no projeto**, e é a que o requisito 3 pede:
`config/autoload/doctrine.global.php:100` autentica o login principal com
`password_verify($passwordSent, $user->getSenha())`, contra a coluna `senha`,
que é bcrypt. O conserto aqui é usar o mesmo mecanismo, não inventar um.

**Consequência que liga este débito ao D78:** enquanto a autorização do fiado
depender de `getSalt()`, remover a coluna reversível **quebra a autorização**.
Consertar o buraco 3 tira um usuário da coluna e diminui o D78.

**O que ainda não dá para medir aqui.** Trocar para `password_verify` só é
seguro se todo usuário ativo tiver bcrypt válido em `senha`. A cópia local não
responde: a anonimização faz `salt = NULL` e troca `senha` por um hash fixo de
desenvolvimento. `scripts/medir-senhas-producao.sql` (seção 4) é o número que
decide, e ele continua esperando a leitura de produção — a mesma que o D78 e o
D79 esperam.

#### Buraco 4 — o modal pede e-mail, a consulta procura login

O modal manda o campo `email` (`mod-vendas.js:868`) e
`autorizarVendaFiadoAction:120` o repassa para `autenticacao($dados['email'],
...)`, cuja DQL filtra `usu.login = :login`. São colunas diferentes — `Usuario`
tem `login` e `email` separados, e existe um `selecionarPorEmail` à parte.

Isso funciona hoje só na medida em que login e e-mail coincidam. É pequeno, mas
entra no requisito 2: "verificar antes de autorizar" exige saber com certeza
**quem** está sendo verificado.

#### O que o requisito 1 custa: mudança aditiva de esquema

`Venda` tem `usuario` (o operador), `cliente`, `caixa`, `convenio`, `debito`,
valores, `nps`, `status` e as datas — verificado campo a campo. **Não há coluna
para o autorizador.** Gravar quem autorizou é coluna nova em `vendas`
(`ManyToOne` para `Usuario`, mais o instante da autorização).

Isso põe o D86 na mesma categoria do **D54**: mudança aditiva que precisa estar
**em produção antes do corte**, senão a tela nova encontra coluna que não
existe. A dependência é para verificar no `procedimento-de-corte.md`, não para
assumir.

#### O que foi corrigido em 02/09/2026

**Buraco 3 — fechado, e o alcance era maior do que este endereço.** `autenticacao`
não é do fiado: **quatro** telas dependem dela — cancelar venda
(`VendasController:171`), autorizar fiado (`VendasPagamentosController:120`),
uma ação de clientes (`ClientesController:654`) e a troca da própria senha
(`UsuariosController:658`). As quatro comparavam texto claro. Agora usam
`password_verify` contra a coluna `senha`.

*A espera pela produção não se aplicava aqui, e isso foi verificado.* A dúvida
legítima — "e quem tiver `senha` sem bcrypt válido?" — se responde no código:
o login por formulário (`entrarAction:31`) manda a senha digitada direto ao
adapter, e o login por cookie (`entrarCookies:181`) manda o texto claro ao
**mesmo** adapter. Nenhum caminho de entrada evita o `password_verify`. Quem não
passa por ele não está logado, e portanto não está diante do PDV.
`medir-senhas-producao.sql` continua sendo o portão do **D78**, que é outra
pergunta: quem perde o **login** quando a coluna sair.

**Buraco 2 — fechado, igualando à tela que já estava certa.** O teste passou a
ser gerente **e** (unidade da sessão **ou** administrador) — a mesma regra de
`VendasController:177`. Nada foi inventado; as duas telas passaram a concordar.

**Provas.** `tests/Caracterizacao/AutorizacaoFiadoTest.php`, cinco casos. O
terceiro é o que separa o antes do depois: planta na coluna reversível um valor
diferente da senha e exige que ele **não** autentique. Com o código antigo
reposto, a suíte dá **duas falhas**; com o novo, nenhuma — medido, não suposto.

A regra de unidade mora no controller, e alcançá-la em teste exigiria montar
sessão e ACL. Segue a decisão já registrada no **D76**:
`scripts/conferir-autorizacao-privilegiada.php` a vigia estruturalmente, junto
com o cancelamento, para que as duas telas não voltem a divergir. A guarda falha
nos dois sentidos — conferido removendo os termos.

#### O buraco 1: o registro existe, a aplicação ainda não

**A coluna nasceu, e o número que ela revelou é o tamanho do problema.** A
migração `scripts/migracao-venda-autorizacao-fiado.sql` acrescenta
`vendas.autorizado_por` (FK para `usuarios`) e `vendas.autorizado_em`. Ao ser
aplicada à base local: **2.269.212 vendas, 36.978 em fiado, e nenhuma com
autorizador registrado.** Não era uma lacuna ocasional — era a totalidade.

O endpoint passou a gravar as duas colunas, e a tela de detalhes da venda mostra
"Fiado autorizado por: *nome*, em *data*" logo abaixo do operador de caixa —
onde quem decide pediu. Quando o registro falta numa venda que é fiado, a tela
diz **"Sem registro de autorização"** em vermelho, em vez de omitir a linha:
venda antiga e venda burlada precisam ser visíveis, não silenciosas.

*Por que a linha não aparece em venda à vista:* não há o que autorizar, e um "—"
em toda venda treinaria o olho a ignorar o campo.

*Por que só nessa tela:* "junto com o operador de caixa" tem um lugar só. A
expressão "Operador de Caixa" aparece em **um** ponto do sistema inteiro,
`detalhes.phtml:92` — a listagem de vendas não mostra o operador em nenhuma
coluna, só o oferece como filtro. Foi conferido antes de escolher onde pôr, e
não por suposição.

**A aplicação no servidor ficou de fora, e a razão é de implantação, não de
código.** `finalizarAction` ainda não recusa fiado sem autorização, e o motivo
está em `module/ISBase/view/layout/layout.phtml`: o JavaScript é servido com
`?cache=`, e o navegador do operador guarda a versão antiga. Se o servidor
passasse a exigir o registro **no mesmo instante** em que o navegador começa a
enviá-lo, toda estação ainda com o script em cache pararia de finalizar venda em
fiado — **PDV parado nas 32 unidades** até o cache virar.

Por isso a ordem é de dois tempos, e o segundo depende de o primeiro estar no ar:

1. *(feito)* servidor **grava** quando recebe `venda_id`, e **concede** quando
   não recebe; navegador passa a enviar; `?cache=161` subiu para `162`.
2. *(pendente)* servidor **recusa** fiado sem autorização registrada — só depois
   de confirmado que nenhuma estação usa o script antigo.

O lugar do passo 2 já está localizado: `validarFinalizarVenda`
(`VendasPagamentosController:214`), que junta mensagens de recusa e é a mesma
função onde o D28 pôs a recusa do caixa de dia anterior. A condição é
`getValorRecebido() < getValorTotal() && getAutorizadoPor() === null`.

#### O PASSO 2 EXISTE desde 03/09/2026 — mas só na API nova

A primeira fatia da Fase 2 (`docs/f2-fatia-pdv.md`) trouxe
`POST /adm/api/comandas/:id/finalizacao`, e **ele recusa** fiado sem autorização
registrada — HTTP 409, medido contra a aplicação de pé:

> `Venda em fiado exige autorização de um gerente antes de ser finalizada.`

**Por que lá pode e aqui não.** A trava nunca foi de rigor: era o cache. O
navegador do operador pode estar com o script anterior, e exigir de repente
pararia o PDV das 32 unidades. **A API nova não tem consumidor nenhum** — pode
exigir desde o primeiro dia.

E `POST /adm/api/comandas/:id/autorizacao-fiado` **grava** o autorizador, em vez
de responder um sim que o navegador aplica. Ela nasce com a regra das outras
telas — gerente **e** (unidade da sessão **ou** administrador) — e é a **quinta**
entrada de `conferir-autorizacao-privilegiada.php`.

Três respostas distinguidas, medidas: credencial que não confere dá **422**
(dado errado, e não se diz qual dos dois falhou); credencial boa de quem não
pode autorizar dá **403**; comanda que não é fiado dá **409**, porque não há o
que autorizar.

**A tela velha continua como estava**, e o passo 2 dela continua esperando o
cache virar. O que mudou é que o caminho novo não herda a dívida.

*E a regra ganhou nome:* `Venda::ehFiado()` e `Venda::fiadoSemAutorizacao()`. A
comparação `valorRecebido < valorTotal` estava escrita em **quatro** lugares — o
serviço, o controller antigo, o novo e o apresentador da API. Regra repetida
diverge, e a primeira cópia a mudar cria o defeito.

Enquanto o passo 2 não acontece, chamar `finalizar` direto continua pulando o
modal. **O registro é confiável para o que ele afirma** — quem está gravado
autorizou de fato, com credencial conferida — mas a ausência dele ainda não
impede a venda.

**Esta migração precisa estar em produção ANTES do corte**, pela mesma armadilha
do **D54**: o passo 1 do `procedimento-de-corte.md` refaz o `mysql.sql` a partir
da estrutura de produção, e é dele que `gerar-ddl-postgres.php` parte. Sem o
`ALTER` aplicado antes, o schema PostgreSQL nasce sem as colunas e a tela de
vendas morre na primeira consulta. Aplicada aos quatro lugares no ambiente:
banco MySQL local, `mysql.sql` versionado, `postgres/schema.sql` regerado e o
PostgreSQL de pé — conferido por `verificar-schema-postgres.php` (0 colunas
ausentes) e por dois testes que escrevem nas colunas novas **nos dois bancos**,
que é a falha de sequência que o D54 ensinou a temer.

**ERAM CINCO LUGARES, e o quinto só apareceu no dia seguinte.** Existe um
`franquiabv_contrato`, montado uma vez a partir do `mysql.sql`, contra o qual
roda a suíte de contrato. Ele ficou para trás.

O sintoma **mentia**: três testes de contrato falharam com HTTP 500 e a mensagem
dizia *"o contrato do endpoint 'cliente-consumo' mudou"*. Não mudou. As
entidades passaram a mapear duas colunas que aquele banco não tinha, e qualquer
consulta que tocasse `vendas` morria.

*E eu diagnostiquei errado na primeira tentativa.* Fiz `git stash` para saber se
as falhas eram minhas, vi que persistiam e as chamei de pré-existentes — mas o
`stash` só desfaz o que não está commitado, e a causa estava no commit
`173b1bd`, desta mesma sessão. O teste que resolveu foi comparar o catálogo dos
dois bancos: `franquiabv_dev` tinha as duas colunas, `franquiabv_contrato` tinha
zero.

**`scripts/rodar-contrato.sh` passou a conferir antes de rodar.** Ele compara os
pares tabela/coluna do `mysql.sql` com os do banco e, se faltar algum, nomeia a
coluna e manda remontar — em vez de deixar a suíte acusar a coisa errada.

*A primeira versão dessa conferência contava colunas dos dois lados.* Os números
não batiam por um (887 contra 888), e com isso **uma** coluna faltando passaria
despercebida — 887 não é menor que 887. Contagem aproximada num portão é o mesmo
que portão nenhum. A versão que ficou compara os pares, e foi posta à prova
derrubando exatamente uma coluna: acusou `vendas.autorizado_em` pelo nome.

*E quase reintroduzi o estrago que aquele roteiro existe para evitar:* a
conferência criava arquivos temporários e eu instalei um `trap ... EXIT` para
limpá-los — o que **substitui** o `trap restaurar`, deixando o `local.php`
apontando para o banco de contrato depois da execução. Os temporários passaram a
sair à mão, e o teste confirma que a linha `ambiente restaurado` continua saindo.

#### O buraco 4, fechado: o rótulo pedia e-mail, a consulta queria login

**A dúvida que travava isto era falsa, e quem decide a desfez em 02/09/2026:**

> *"o login pode ser apenas um nome de usuário ou um endereço de e-mail (login)"*

O identificador **é o `login`**. Ele *pode ter forma de e-mail* — e era
justamente isso que fazia o defeito passar despercebido por anos: para o gerente
que cadastrou o login como o próprio endereço, o campo rotulado "E-mail
(gerente)" funcionava. Para o gerente cujo login é um nome de usuário, digitar o
e-mail dava **"Não foi possível localizar o usuário"** — credencial boa,
recusada, com uma mensagem que sugeria que a pessoa não existe.

`login` e `email` são colunas distintas de `usuarios`, e `autenticacao` sempre
filtrou `usu.login`.

**O que mudou, e o que deliberadamente não mudou.** A consulta continua em
`login`: aceitar também a coluna `email` **alargaria** quem pode autorizar e
abriria colisão — o e-mail de um poderia ser o login de outro. O que estava
errado era o rótulo, e é ele que foi consertado:

- o campo passa a se chamar **"*Login (gerente):"**, com a dica *"nome de usuário
  ou e-mail"*, nos dois modais de fiado (`novo.phtml`, `comanda.phtml`) e no
  formulário de cancelamento (`VendaCancelarModal`);
- o navegador manda a chave `login`;
- a mensagem de recusa deixou de dizer "não foi possível localizar o usuário"
  quando a senha é que estava errada. Agora diz **"Login ou senha não conferem"**
  — verdade sem revelar qual dos dois falhou.

**A redação não foi inventada.** A terceira tela que reconfirma senha — a
devolução de crédito (`ISCadastro\Form\CreditoDevolucao:27`) — **já** rotulava
o campo como *"\*Login (gerente)"* e já lia `$request['login']`. Ela estava
certa desde sempre; eram as duas telas de venda que destoavam. Conferido antes
de escolher o texto, e agora as três dizem a mesma coisa.

**O cancelamento levou a mesma correção**, e não por zelo: ele tinha o defeito
idêntico (`VendasController:171` lia `$request['email']`), e o portão
`conferir-autorizacao-privilegiada.php` existe exatamente para as duas telas não
divergirem. Deixar metade consertada era criar a divergência que o portão vigia.

**Duas coisas ficaram no lugar antigo, com motivo escrito:**

1. *A chave `email` continua aceita no servidor.* O `layout.phtml` serve o
   JavaScript com `?cache=`, e o navegador do operador pode estar com o script
   anterior — que manda `email`. Recusá-la trocaria um defeito de rótulo por um
   PDV parado. O `?cache=` subiu de `162` para `163`.
2. *O `id` do campo continua `email`.* É por esse `id` que o script seleciona o
   campo; trocá-lo agora faria o script em cache não achá-lo. Renomear depois
   que o cache virar — o servidor já aceita as duas chaves.

**O que a cópia local não pôde medir, e por isso não foi medido:** quantos
usuários reais têm login em forma de e-mail. A anonimização sobrescreve
`usuarios.login` para `user<id>` e `email` para `colaborador<id>@exemplo.invalid`
(`anonimizar.sql.modelo`, linhas 77 e 78) — qualquer contagem daqui seria
artefato, o mesmo motivo pelo qual `medir-senhas-producao.sql` existe. **A
correção não depende desse número:** ninguém que autoriza hoje deixa de
autorizar, porque a consulta não mudou.

#### Um achado de ambiente, encontrado por acidente ao escrever o teste

**Os dois bancos de desenvolvimento não partem do mesmo estado**, e nada avisava.

O PostgreSQL local é povoado por `scripts/migrar-dados.sh`, que copia do MySQL.
As linhas de `tests/Caracterizacao/fixtures.sql` que ele tem são, portanto, tão
antigas quanto a última carga — e o `fixtures.sql` cresceu desde então. Medido
em 02/09/2026, para os identificadores `90001000+`:

| tabela | MySQL | PostgreSQL |
|---|---:|---:|
| unidades | 2 | 1 |
| usuarios | 3 | 2 |
| clientes | 3 | 1 |
| caixas | 2 | 1 |
| **vendas** | **3** | **0** |
| vendas_pagamentos | 3 | 0 |

**Por que ninguém tinha visto:** todo teste que precisa de venda já insere a sua
própria, dentro da transação desfeita — `FechamentoVendaTest`,
`ConvenioDescontoTest`, `CupomTest` e os demais. O hábito, que existe por outro
motivo, escondia a diferença.

Foi este débito que a revelou: a primeira versão de `AutorizacaoFiadoTest` usou
a venda de fixture, **passou no MySQL** e morreu no PostgreSQL com
`Call to a member function getAutorizadoPor() on null`. Mais uma vez a mesma
lição do **D80** — *verde em um banco não é verde* —, agora pelo lado do dado e
não do SQL.

**Não foi consertado aqui, e o motivo é de custo:** igualar os dois exigiria
recarregar o PostgreSQL com `migrar-dados.sh`, que copia 2,27 milhões de vendas
e 242 mil clientes; é ferramenta de ensaio de corte, não de rotina de teste. O
teste foi feito autossuficiente, como os outros. Fica registrado que a suíte do
PostgreSQL **não** exercita as fixtures posteriores à última carga, e que
qualquer teste novo que dependa delas vai falhar só lá.

#### O que continua aberto

**O irmão encontrado no caminho — corrigido em 02/09/2026, por decisão de quem
opera.** `ClientesController::creditoDevolucaoAction` exigia gerente e **não**
conferia a unidade. É o mesmo defeito do buraco 2, em outra tela e num fluxo em
que **sai dinheiro**: devolve crédito ao cliente. Ficara registrado sem conserto
porque mudar quem pode autorizar devolução de crédito não é decisão de quem
edita o código.

A regra é a mesma das outras duas — gerente **e** (unidade da sessão **ou**
administrador) — e a unidade certa não foi escolhida por analogia: o crédito é
buscado com `$this->unidadeId`, logo o dinheiro é da unidade da sessão, e quem
autoriza a saída dele responde por essa unidade.

A mensagem de recusa também deixou de mentir aqui, como nas outras duas: dizia
"não foi possível localizar o usuário" mesmo quando a senha é que estava errada.

**A guarda ganhou a quarta entrada, e um par de termos em vez de um.** O sinal
óbvio seria exigir `unidadeId` no método — e ele **não prova nada aqui**, o que
foi medido, não suposto: a busca do crédito usa `$this->unidadeId` de qualquer
forma, então o termo continua presente mesmo com a comparação apagada. Ao apagar
o bloco inteiro num teste de regressão, `unidadeId` de fato **não** acusou; quem
acusou foram `getAdministrador` e `getUnidade`, este último porque no método só
aparece na comparação. Por isso os dois andam em par.

*(É o mesmo erro de raciocínio que este projeto vem corrigindo: o termo estava
presente, e presença não é evidência de que ele faz o que se supõe.)*

**Uma armadilha para quem for planejar a fatia do PDV — e que esta correção
fechou.** O inventário marcava `autorizarVendaFiado` como **Escreve: sim**
quando ele não escrevia nada. A coluna vem de uma heurística que o próprio
gerador declara — *"uma ação que recebe POST quase sempre muta, ainda que pela
mão de outro"* (`scripts/inventariar-acoes.php:303`) — e esta ação era o
contraexemplo dela. Ler o inventário sem essa ressalva escondia exatamente o
buraco 1: o rótulo prometia uma escrita que não existia.

**Deixou de ser contraexemplo em 02/09/2026**, porque a ação passou a gravar de
verdade: o `escreve_direto` do inventário virou `sim` pelo sinal forte, e não
pela aproximação. A advertência sobre a heurística continua valendo — ela é
aproximação por construção —, mas este já não é o caso que a ilustra.

*(O buraco 4 foi fechado em 02/09/2026 — ver a seção abaixo.)*

*10/10/2026, a verificação do item PDV-TERCEIRA-RODADA (o D380):* mais um caminho em que a tela velha pulava o modal
sem ninguém chamar o `finalizar` à mão. O "Quitar" sobe o total, e o `pdvPagarDebitos` não refazia o par
"Finalizar"/"Finalizar fiado": com o pagamento já cobrindo o total de antes, ficava o "Finalizar", e os débitos
fechavam como fiado sem o gerente -- inclusive na saída da recusa da pergunta 55. A tela foi consertada (a resposta do
"Quitar" traz o `fiado` e a tabela, como a do pagamento, `mod-vendas.js?cache=166`); o servidor do ERP continua sem
conferir a autorização, que é este buraco 1.

---

### D87 — Um teste que não sobrevivia à meia-noite — CORRIGIDO em 03/09/2026

Descoberto por acidente: a suíte de caracterização rodou atravessando as 00h e o
MySQL acusou **uma falha** que o PostgreSQL, rodando minutos depois, não teve.

`RoyaltiesTest::testCamposDeLavaVipVaoParaAColunaCerta` falhou. Nenhuma linha de
código havia mudado entre uma coisa e outra — o que mudou foi o dia.

#### O mecanismo

O cenário fazia duas leituras do relógio, em momentos diferentes:

1. `vendaFinalizadaHoje()` grava a venda com `date('Y-m-d H:i:s')`;
2. minutos depois, `royaltiesDeHoje()` procura a linha com outro `date('Y-m-d')`.

Entre as duas passa o cálculo dos royalties, que não é rápido — a classe inteira
leva cerca de dez minutos. Atravessando a meia-noite, as duas chamadas devolvem
**dias diferentes**, e a busca não acha nada. A asserção vê `0,00` onde esperava
o faturamento.

#### NÃO É O D42, e a diferença é o que importa

O **D42** foi a **fonte** do relógio: o MySQL do contêiner em UTC contra o PHP em
`America/Sao_Paulo`, três horas de divergência, e os testes passavam às 19h34 e
falhavam às 21h. A correção de lá foi comparar com a mesma fonte.

Este é o **momento**: mesma fonte, lida duas vezes, com a virada do dia no meio.
A correção de lá não alcançava este, e não tinha como alcançar.

#### A correção

Fixar o dia **uma vez**, no instante em que a venda é criada, e usar o mesmo
valor nos dois lados — inclusive no período pedido ao serviço, no teste que
compara os dois caminhos do relatório.

Ela funciona porque `royaltiesAtualizar()` recalcula **ontem e hoje**
(`FranquiaService:45-48`). A linha do dia da venda existe mesmo que o serviço
rode já no dia seguinte — então não há janela em que o cenário fique sem
resposta.

#### A prova, nos dois sentidos

A meia-noite não se agenda, então o cenário foi **simulado**: a venda passou a
ser datada de ontem, que é exatamente o estado em que o teste se encontra depois
da virada.

| | |
|---|---|
| Com a correção, venda de ontem | **passou** |
| Sem a correção, venda de ontem | **falhou** — `'89,90'` esperado, `'0,00'` obtido |

O segundo é o mesmo sintoma visto às 00h. Restaurado o arquivo, 5 de 5.

#### O que isto custava, e por isso não ficou como está

Uma falha por relógio é pior que uma falha comum: ela aparece sozinha, some
sozinha, e ensina quem vê a desconfiar do vermelho. Este projeto passou semanas
construindo portões para que vermelho signifique alguma coisa — um teste que
falha uma vez por dia, sempre à mesma hora, desfaz isso em silêncio.

#### A busca pelos outros — feita em seguida, e o conserto virou sistêmico

A primeira versão deste registro dizia *"não foi procurado se há outros"*. Foi,
logo depois. Das 26 classes de caracterização, **11 liam o relógio**, num total
de 23 leituras. Três tinham o mesmo defeito, com frequências diferentes:

| Onde | Quando quebraria |
|---|---|
| `ConvenioDescontoTest` | escolhe a coluna do **dia da semana** por `date('w')` depois de inserir a venda — risco **diário** |
| `CronTravaSimultaneaTest` | lê `date('m')` e `date('Y')` duas vezes, e ainda misturava `NOW()` do banco — risco **mensal** |
| `DashboardFaturamentoAnosTest` | monta o intervalo esperado com `date('Y')` — risco **anual** |

Consertar um a um deixaria o próximo para o próximo. O conserto foi outro:
**`CaracterizacaoTestCase::agora()`**, que lê o relógio **uma vez por teste** e
devolve sempre o mesmo instante, com deslocamento relativo opcional aplicado
sobre ele. As 23 leituras viraram chamadas a ele, menos as que **não são**
leitura de relógio — e essas estão nomeadas.

**Um caso pedia asserção diferente, não troca mecânica.**
`PagamentoTrocoDebitoTest` afirmava `assertStringStartsWith(date('Y-m-d'), $data)`
sobre uma data carimbada pela *produção* ao finalizar. Aí não há instante único
possível: quem grava é o código, quem confere é o teste, e a meia-noite pode
cair entre os dois. Passou a comparar a **distância** até agora (600 s), que diz
a mesma coisa — *"o campo vale o momento do fechamento"* — sem depender do dia.

**Guardado por `scripts/conferir-relogio-nos-testes.php`**, o vigésimo portão.
Ele lê pelo `token_get_all` — os comentários deste projeto citam `date()` e
`CURDATE()` para contar a história, e comentário não pode acusar. Falha nos dois
sentidos: leitura nova acusa, e exceção que ninguém usa mais também, porque lista
de exceção que não encolhe deixa de descrever o código. Conferido dos dois lados.

**Ele já pagou na primeira execução**, achando o que eu não tinha visto:
`CronTravaSimultaneaTest` inseria `data_criacao` com `NOW()` **do banco** na
mesma instrução em que o ano e o mês vinham do PHP. Hoje as duas fontes
coincidem, mas coincidir por configuração não é ser uma fonte só.

**E corrigiu um comentário que envelheceu.** `RoyaltiesTest` afirmava que *"o
MySQL do contêiner roda em UTC"*. Não roda: o `docker-compose.yml` passou a
subi-lo com `--default-time-zone=-03:00` em 27/08/2026, para espelhar a réplica
de produção. Medido em 03/09 — `@@global.time_zone` e `@@session.time_zone` são
`-03:00`, e o `NOW()` do banco bate com o `date()` do PHP no segundo. **O D42
continuava aberto assim mesmo**, por dois motivos: a aplicação ainda misturava as
fontes, e a produção não foi medida.

> **Metade disso caiu em 13/09/2026.** A aplicação não mistura mais — zero
> ocorrências em 658 arquivos de `module/`, medido por token —, e agora há portão
> (`conferir-relogio-do-banco.php`). **A outra metade continua de pé:** o relógio
> de produção segue sem medida, e medi-lo é ação de servidor, que espera a janela
> do corte.

---

### D88 — Um código mestre resgatava a pontuação de qualquer cliente — CORRIGIDO em 03/09/2026 — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: aplicar em produção, na janela, o ALTER TABLE de pontuacoes_historicos.autorizado_por; sem a coluna, o resgate autorizado falha.

Encontrado em 03/09/2026, ao procurar como este sistema já provava a identidade
de um cliente — porque a autenticação do **D59** precisava saber se havia
precedente. Havia, e ele tem uma chave-mestra.

O resgate de pontos do programa de fidelidade manda um código de seis dígitos ao
cliente por WhatsApp e pede que ele seja digitado no balcão. A conferência está
em `ClientesController::programaFidelidadeResgatarPontuacaoAction`:

```php
if ($credito->getCliente()->getCodigoApp() == $request['codigo'] || $request['codigo'] == "000000") {
```

**O segundo termo aceita `000000` para qualquer cliente**, sem consultar código
nenhum. O código existe para provar que o cliente está ali e concorda; com essa
linha, quem opera a tela resgata a pontuação de quem quiser sem que a pessoa
saiba — e o resgate vira crédito, que é dinheiro na conta da unidade.

**Não é hipótese de invasor externo:** a tela exige sessão do ERP, então quem
alcança isso é funcionário. É exatamente o risco que o código de verificação
existia para cobrir.

**Um segundo defeito na mesma linha, menor e real.** A comparação é `==`, e o PHP
compara duas strings numéricas como NÚMEROS. `'012345' == '12345'` é verdadeiro,
e o gerador (`RandomString::gerarSomenteNumeros`) produz códigos com zero à
esquerda. Para esses, o espaço de busca encolhe.

**Por que não corrigi na descoberta.** `000000` tem cara de saída operacional
deliberada — cliente sem sinal, WhatsApp fora do ar, cliente sem o celular na
mão. Apagar a linha travaria o balcão numa situação real, e isso era decisão sua.
Você mandou atacar no mesmo dia.

**Não há como medir o uso histórico**, e isso não mudou: `PontuacaoHistorico`
grava quem resgatou e quanto, e nunca gravou qual código foi aceito. Os 373.104
resgates da base não se separam entre "o cliente confirmou" e "alguém digitou
seis zeros". Daqui para a frente, separam.

#### O QUE FOI FEITO — a saída continua existindo, e agora tem nome

A chave-mestra virou uma **autorização registrada**, com o mesmo desenho do fiado
(**D86**), porque é a mesma pergunta: um gerente reconfirma a **própria senha**, o
sistema confere contra o bcrypt, exige o papel de gerente e a unidade da sessão —
ou `administrador`, que é o que faz o gerente geral atravessar unidades — e grava
quem autorizou.

| | antes | depois |
|---|---|---|
| Resgatar sem o código | digitar `000000` | login e senha de um gerente |
| Quem podia | qualquer operador da tela | gerente da unidade, ou administrador |
| O que fica registrado | nada que distinga | `pontuacoes_historicos.autorizado_por` |

**A coluna, e não só a descrição.** A descrição também passou a dizer, porque é
ela que aparece na tela do histórico — mas quem responde *"quantos resgates
saíram sem o cliente confirmar"* é a coluna. Texto livre de 200 caracteres
serve para ler, não para perguntar.

#### O SEGUNDO DEFEITO DA MESMA LINHA, e ele era real

`==` no PHP compara duas strings numéricas como **números**: um código com zero à
esquerda conferia com o mesmo número sem o zero, e o gerador produz códigos assim
(`RandomString::gerarSomenteNumeros`). Para esses, bastava tentar o número sem o
zero.

A comparação virou uma regra com nome na entidade — `Cliente::codigoAppConfere()`
—, estrita e de texto. Sair do controller não foi arrumação: privada lá dentro,
ela não tinha como ser testada, e é a regra que decide se uma credencial vale.

#### ONDE ESTÁ A PROVA

**`tests/Caracterizacao/ResgateSemCodigoTest.php`** — 8 testes. Restaurei a
comparação antiga por um instante e **três deles ficaram vermelhos**: os seis
zeros como chave-mestra, o zero à esquerda, e o cliente sem código gerado. Um
teste que nunca falhou não prova que pega.

Um deles diz o contrário do que se esperaria e está certo: **seis zeros
continuam valendo quando são o código do cliente**. É um dos 10⁶ que o gerador
pode sortear, e recusá-lo por precaução deixaria alguém sem conseguir resgatar.

**Três entradas novas em `scripts/conferir-autorizacao-privilegiada.php`**, para
a metade que teste nenhum alcança — a decisão dentro do controller exigiria
montar sessão e ACL, a mesma ressalva do fiado. O portão ganhou uma forma que
não tinha: **proibir um literal**. A chave-mestra não era função nem variável,
era o texto `"000000"`; vigiar só identificadores a deixaria voltar em silêncio.
Reintroduzi a linha e o portão acusou.

#### O QUE PRECISA ACONTECER NO DEPLOY, e a ordem importa

**1. O `ALTER TABLE`.** `pontuacoes_historicos.autorizado_por` entrou em
`mysql.sql` e em `postgres/schema.sql`, e está aplicado nos três bancos locais.
Em produção ele é mais um da lista que espera o descongelamento. **Sem a coluna,
o resgate autorizado falha** — o resgate com código, que é a esmagadora maioria,
continua funcionando.

**2. O `?cache=` foi de 163 para 164.** A tela ganhou uma etapa nova, e o
navegador do operador precisa buscar o script novo. Sem o número novo, quem
estiver com o script antigo em cache vê a tela velha — que já não aceita seis
zeros, e não oferece a autorização. **Uma página já aberta no navegador precisa
ser recarregada.**

**O contraste vale ser dito:** a autenticação do cliente construída no mesmo dia
(**D59**) usa o mesmo canal e o mesmo tamanho de código, e lá `000000` sempre foi
um palpite errado como qualquer outro.

---

### D89 — O portão de segredos do CI não via atribuição a índice — CORRIGIDO em 03/09/2026

Encontrado em 03/09/2026, lendo `CronJobService` para entender a fila do
WhatsApp.

`CronJobService::yungasAutenticacao()` montava a autenticação de um serviço
externo com **apikey, e-mail e senha escritos no arquivo**.

> **A CREDENCIAL EM SI JÁ ERA CONHECIDA, e o mérito não é meu.** Ela é a oitava
> linha da tabela do **D3**, com o arquivo e a função nomeados desde 31/08/2026.
> Eu a encontrei lendo o `CronJobService` por outro motivo e a registrei como
> descoberta, sem conferir se já estava registrada — foi o portão de débitos
> repetidos que me corrigiu.
>
> **A rotação continua sendo assunto do D3**, que é dono da lista das oito. O que
> é deste débito é outra coisa: *por que o portão do CI não a viu.*

**O portão existe e não pegou.** O passo "Nenhum segredo versionado" do CI
procura o nome do segredo seguido de `=>`, `=` ou `:` e então o valor entre
aspas. Aqui a atribuição é a um ÍNDICE — o colchete e a aspa de fechamento ficam
entre o nome e o `=`, e a expressão não casa. Três credenciais passaram por baixo
de um portão feito para isso.

**O portão foi ampliado**, com a forma exata que escapou, e as credenciais
saíram do código para `config/autoload/local.php`. Isso é o que era trabalho de
código, e está feito.

#### DECIDIDO em 03/09/2026: não é prioritário, e talvez a integração saia

> *"colocar yungas como débito técnico, não é prioritário e talvez seja
> descontinuado"*

A rotação das credenciais **não** entra na fila de coisas a fazer antes do
corte. A integração pode ser descontinuada, e trocar credencial de um serviço
que vai sair é trabalho jogado fora.

**O que isso muda, e o que não muda.**

*Muda:* este débito deixa de aparecer como pendência ativa nas listas de "o que
espera você". Ele fica aqui, registrado, à espera da decisão sobre a integração
em si.

*Não muda:* as três credenciais **continuam válidas** no serviço externo, e
continuam no histórico do git. Tirá-las do arquivo impede que novas cópias
apareçam; não desfaz as antigas. Se a Yungas for descontinuada, o certo é
**revogar** a conta no fornecedor — o que resolve melhor que rotacionar, porque
uma credencial revogada não vale nem no histórico.

**Se a integração for mantida**, a ordem é contraintuitiva e vale relembrar:
trocar no fornecedor primeiro, preencher o `local.php` depois. Ao contrário, a
rotina fica sem autenticar até alguém chegar no painel.

**A rotina não cala quando falta credencial** — `yungasFaturamentoAtualizar`
registra no log que não sincronizou nada, em vez de rodar em silêncio sobre
nenhuma unidade.

---

### D90 — O par `.js`/`.min.js`, e qual dos dois o navegador recebe — CORRIGIDO em 03/09/2026 — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 15/10/2026: a 121 (a)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: gerar os .min.js a partir do fonte na implantação, com uma etapa de construção; os oito pares seguem cópia manual. Decisão do dono.

Notado ao mexer no `mod-vendas.js` pelo **D88**, e o que eu registrei primeiro
descrevia só metade.

`public/layout/scripts/` guarda os scripts da casa **em pares**: `geral.js` e
`geral.min.js`, `mod-vendas.js` e `mod-vendas.min.js`. A tela escolhe **um** dos
dois no `appendFile`, e o outro fica ali, parecido, sem rodar em lugar nenhum.

**A medição, feita antes de apagar qualquer coisa:**

| | |
|---|---:|
| Pares na pasta | 11 |
| Que servem o **minificado** | **8** |
| Que servem o **fonte** | **3** |

Os três que servem o fonte — `mod-cadastros`, `mod-vendas`, `theme-switcher` —
têm minificados que **nunca foram servidos na história deste repositório**:
desde a importação inicial (`eaf1fca`, 15/08/2026) o `layout.phtml` aponta para
o `.js`. E um deles tinha acabado de divergir, porque o **D88** acrescentou a
etapa de autorização do resgate só no arquivo que roda.

*Quase escrevi o oposto por contar em vez de olhar.* Uma primeira contagem disse
que os três tinham "1 referência" cada — e a referência era este próprio débito,
que os cita pelo nome. Contagem que inclui o documento que fala do problema não
mede o problema.

#### A ARMADILHA QUE IMPORTA É A OUTRA, e ela vale para 8 arquivos

O minificado órfão é cópia velha de um script vivo: quem um dia trocar o servido
pelo minificado "para ficar mais leve" leva junto o estado do arquivo no dia em
que ele parou de ser atualizado. Chato, e limitado a três.

**O caso inverso é pior, e alcança os outros oito.** Onde o servido é o
minificado, editar o `.js` — que é o gesto natural, e o único legível — **não
muda nada em produção**. O botão continua chamando uma função que existe no
fonte e não existe no arquivo entregue, e o erro só aparece no console de quem
clicar.

**Medido: não tinha disparado.** Os **127 manipuladores** citados nas 15 telas
que apendam script existem todos em algum arquivo servido. Nenhum botão morto
hoje.

#### O QUE FOI FEITO

**Os três minificados órfãos foram removidos.** Ninguém os servia, o git guarda
o histórico, e o `mod-vendas.min.js` já contradizia o arquivo vivo.

**Um portão novo, `scripts/conferir-scripts-servidos.php`**, com as duas
verificações:

1. nenhum `.min.js` da casa pode estar sem quem o sirva;
2. todo manipulador citado numa tela precisa existir em algum script **servido**.

Provei as duas: recolocando um órfão, e trocando um `onclick` por um nome que só
existiria num arquivo não servido. As duas acusaram, e o verde voltou.

**O que o portão NÃO faz, de propósito:** comparar o minificado com o fonte.
Minificador renomeia função local e reescreve `function x()` como
`x=function()`, então diferença de texto entre os dois não prova nada — foi a
primeira medição que tentei, e ela acusava `layout` e `mod-autenticacao` sem que
houvesse defeito. O que se afirma com honestidade é mais estreito e mais útil: o
nome que a tela chama aparece no arquivo que a tela manda servir.

#### O QUE CONTINUA ABERTO, e é decisão sua

**Os oito pares continuam sendo cópia manual.** Ninguém gera o `.min.js` a partir
do `.js`; alguém minificou uma vez e versionou o resultado. O portão impede que
a divergência passe despercebida **quando ela chega às telas**, e não impede a
divergência em si.

A saída definitiva é **gerar o minificado na implantação**, a partir do fonte —
aí eles deixam de ser cópia e voltam a ser derivados. Isso exige uma etapa de
construção que este sistema não tem hoje, e é trabalho a agendar, não a
improvisar.

#### FECHADO EM 15/10/2026 — a resposta 121 do dono, opção (a)

"Servir o legível e apagar os oito minificados" (a fatia 5b, RESPOSTA-121-SCRIPTS-LEGIVEIS,
o pedido "continue"). A opção pedia comparar cada par antes, porque o que rodava era o
minificado, e o legível podia ter ficado para trás.

**A comparação foi pelo comportamento, e não pelo texto** — a régua de 03/09 tinha razão:
diff de texto entre fonte e minificado não prova nada. Os dois lados de cada par passaram
pelo **mesmo** minificador (o do `rolldown` do front, com compressão e troca de nomes
locais); a comparação de Yoda do minificador antigo foi desfeita (`!0===e.sucesso` →
`e.sucesso===!0`, `1<t.length` → `t.length>1`); os nomes locais viraram um só; e o diff
foi por token, lido trecho a trecho. Ao lado, a contagem dos literais de texto, números,
nomes de propriedade e chaves de objeto dos dois arquivos crus.

| Par | Tokens (legível × minificado) | Trechos que diferem | O que muda no comportamento |
|---|---:|---:|---|
| `fidelidade_cadastro` | 1.277 × 1.279 | 1 | só o `'use strict'` do topo do minificado — trazido ao legível (depois: 0) |
| `geral` | 856 × 856 | 0 | nada |
| `layout` | 2.991 × 2.968 | 54 | só o `'use strict'` do topo do minificado — trazido; o resto é reescrita: parâmetro reaproveitado como variável, `if` virando `&&`, o `return;` dentro do `.each` (que não para o laço) tirado, a variável `-200` posta no lugar |
| `mod-autenticacao` | 809 × 787 | 13 | o legível é estrito dentro da função, e o minificado não; o `formToObject` do legível nunca é chamado, e o minificado não o tem; o resto é reescrita |
| `mod-configuracoes` | 11.830 × 11.829 | 4 | nada: o minificado reaproveita o parâmetro `e` como variável, em duas funções |
| `mod-financeiro` | 1.055 × 1.055 | 0 | nada |
| `mod-marketing` | 2.130 × 2.130 | 0 | nada |
| `mod-relatorios` | 2.412 × 2.401 | 12 | nada: o contador do laço do telefone no parâmetro `e`, e o `!a&&!b&&!c&&X` como `a\|\|b\|\|c\|\|X` |

**Nenhum minificado tinha código que o legível não tivesse, e o legível não tinha
ficado para trás em ponto nenhum.** Pelo git, todo commit que mexeu num lado de um par
mexeu no outro — cinco, de 04/10 a 13/10 (Contas a Pagar duas vezes, a sessão e as
rotas, o PDV e o cadastro, o Profissional Parceiro) —, e o conserto ficou igual nos dois.

**O modo estrito era a única diferença, e foi medido.** O `layout.min.js` e o
`fidelidade_cadastro.min.js` começavam por `'use strict'` (o minificador antigo o
punha), e os legíveis não. Com a diretiva no topo, os dois legíveis compilam, não
escrevem em nome não declarado e não leem o `this` de função chamada pelo nome — o
medidor acha, no `mod-configuracoes.js`, as duas escritas que ele tem (`boundx` e
`boundy`, em nenhum lado estrito), então ele mede. A diretiva veio para os dois
legíveis, com o motivo escrito, para que trocar o arquivo servido não trocasse também
o modo. O `mod-autenticacao.js` é o contrário — o legível já era estrito dentro da
função, e o minificado não — e ficou como está: a mesma medição não acha nada que o
modo mude ali, e no navegador a entrada e a redefinição da senha ligam os seus envios
sem erro no console. Desde a revisão do mesmo dia, o `D90PortoesDosScriptsTest` prende
a diretiva como a primeira instrução dos dois legíveis (arrancada, ou posta depois da
primeira linha de código, o caso fica vermelho); que nada neles muda com ela segue
sendo a medição acima, e não um caso.

**O que mudou:**

- **As sete telas pedem o legível.** O layout do ERP pedia seis (`layout`, `geral`,
  `mod-configuracoes`, `mod-financeiro`, `mod-marketing`, `mod-relatorios`); a entrada, a
  redefinição e a confirmação da senha, o `mod-autenticacao`; a adesão à fidelidade, o
  `fidelidade_cadastro`; as duas páginas da pesquisa NPS do balcão, o `mod-relatorios`. O
  `?cache=` de cada uma ficou: nenhum commit jamais pediu o endereço do legível, então
  nenhum navegador o guarda.
- **Os oito minificados saíram do repositório.** O git guarda o histórico.
- **`scripts/conferir-scripts-servidos.php` vigia o contrário do que vigiava:** nenhum
  `.min.js` na pasta da casa, nenhuma tela pedindo `.min.js` da casa, todo script da casa
  pedido existe (um 404 calado deixaria a tela sem os botões), e os 125 manipuladores
  como antes. Na revisão, mais três: todo arquivo da pasta da casa é pedido por alguma
  tela (a cópia órfã com outro nome, `mod-financeiro.antigo.js` ou `geral.js.bak`, que o
  servidor entregaria); o manipulador vale pelo nome inteiro e fora de comentário (os
  legíveis têm 104 comentários, e os minificados não tinham nenhum: uma função apagada
  que sobrasse num comentário passaria); e os seis pares do tema são os declarados.
- **`scripts/conferir-segredos.sh` deixou de pular `public/layout/scripts/` e todo
  `*.min.js`.** As duas exclusões vinham da importação, sem motivo escrito; o que sobra
  na pasta é o legível, editado à mão e entregue a todo navegador. Medido antes de
  tirar: nenhuma linha casava nelas. Com uma chave inventada plantada no `geral.js`, o
  portão de antes saía 0, e o de agora sai 1 — e, desde a revisão, o
  `D90PortoesDosScriptsTest` faz isso num repositório de rascunho, nos dois modos do
  portão: a volta de qualquer das duas exclusões o deixa vermelho.
- **`D90ScriptsLegiveisTest`** desenha as sete telas pelo `ViewRenderer` da aplicação —
  o layout com a sessão de quem entrou (`SessaoAcl::setAcl`), a adesão pelo despacho da
  ação — e confere que cada uma pede só legível, pede o que pedia minificado, e que o
  arquivo existe; no layout, também a ordem dos dez scripts da casa.
- **`contasAPagarDaTelaAntiga.teste.ts`** roda só o legível (rodava os dois), e confere
  que o layout pede o `mod-financeiro.js` com o cache de pelo menos 166, e não o
  `mod-financeiro.min.js`; os outros cinco minificados do layout ficam com o
  `D90ScriptsLegiveisTest` e com o portão.
- Os comentários que mandavam "mudar também o `.min.js`" (`mod-financeiro.js`,
  `remover-por-post.js`, `SaidasController.php`, `SaidasDoErpTest.php`) dizem o de hoje.

**No navegador**, no ambiente local: a entrada (`/autenticacao/entrar`) e a redefinição
da senha recebem o `mod-autenticacao.js` (200, 6.466 bytes) e ligam o envio do
formulário; as duas páginas da pesquisa NPS recebem o `mod-relatorios.js` (200), todo
`onclick` delas acha a sua função, e o teclado numérico monta `(11) 98765-4321` dígito a
dígito — o ramo do décimo primeiro dígito era um dos trechos que o minificador
reescrevia. Nenhum erro no console. E, numa página em branco do mesmo ambiente (a do
404), os 41 scripts do layout do ERP, na ordem dele, carregam com o `layout.js` estrito
e sem um erro; 123 dos 125 manipuladores das telas são funções ali — os outros dois são
o `buscarEndereco` da adesão (do `fidelidade_cadastro.js`) e um `y` que o portão lê num
comentário do mural. Os 17 da adesão também carregam sem erro, com o
`fidelidade_cadastro.js` estrito, e as máscaras dele formatam o telefone e o CEP.

**O que não vi no navegador:** o layout do ERP logado (daqui não há sessão), a
confirmação da senha (pede um token válido) e a adesão à fidelidade de verdade (pede um
cliente, com dado pessoal). Nessas três, a prova é o teste em processo, a comparação dos
pares e os scripts carregados na página em branco — e não o clique de quem usa.

**Fica de fora, de propósito:** os seis pares do tema, de terceiros, em
`public/global/scripts/` (`app`, `quick-sidebar`) e `public/pages/scripts/`
(`components-color-pickers`, `components-select2`, `login-4`, `login-5`). As telas seguem
pedindo o `.min.js` deles: o `app.min.js`, o layout e mais doze telas (as três da senha,
as da fidelidade, da pesquisa NPS e dos assistentes); o `quick-sidebar`, o
`components-color-pickers` e o `components-select2`, o layout; o `login-4.min.js`, as
três telas da senha; e o script do `login-5` ninguém pede (as telas públicas usam só o
CSS dele). Nenhum dos doze arquivos mudou desde a importação (`eaf1fca`) — mas quem
editar o legível de um deles não muda a tela, a mesma armadilha deste débito. O
`conferir-scripts-servidos.php` os conta e diz quem pede cada um. E o
`conferir-segredos.sh` segue pulando `public/global/plugins/`, que também não casa nada
hoje — não é desta resposta.

#### A revisão de 15/10/2026 — oito achados, todos tratados

| Achado | O que se fez | O que o pega |
|---|---|---|
| Os pares do tema eram seis, e não quatro: faltavam o `login-4` (pedido pelas três telas da senha, e não só pelo layout) e o `login-5` | a contagem e a lista acima, no cabeçalho do portão e no roadmap | `PARES_DO_TEMA`, no portão: um par a mais ou a menos o reprova; no repositório, seis |
| O comentário do `SaidasController` dava ao minificado servido até 15/10 o defeito que só o de antes de 04/10 tinha — o `recarrega` entrou nele em `c18ba81` | diz "o de antes de 04/10", como o `SaidasDoErpTest` | nenhum caso: é texto, e a prova é o `git show c18ba81` |
| As notas novas dos desenhos ficaram ao lado de `layout.phtml:761` e `:758`, que são comentários | `:765` (o `mod-configuracoes.js`) e `:747` (o `geral.js`), em `tela-tags.md`, `tela-pesquisas.md` e `tela-versao-app.md` | um medidor de rascunho: as três referências apontavam para comentário, e agora para o `appendFile`. O `conferir-referencias-docs.php` só confere que o arquivo existe |
| Nenhum caso prendia o `'use strict'` dos dois legíveis | o caso | `D90PortoesDosScriptsTest` |
| Nenhum caso pegava a volta das exclusões do `conferir-segredos.sh` | o caso, num repositório git de rascunho, com a credencial inventada montada por pedaços | `D90PortoesDosScriptsTest`, na árvore e no commit |
| O docblock do `D90ScriptsLegiveisTest` dizia que nenhum lado tinha código a mais, e o `mod-autenticacao.js` tem o `formToObject`; e a linha do teste de Contas a Pagar dizia "nenhum minificado" | os dois textos dizem o que se mediu | nenhum caso: é texto |
| O portão lia os comentários dos legíveis: um manipulador que só sobrasse num comentário passava | os comentários saem antes da busca (`jsSemComentarios`, que dá o mesmo texto que o `@babel/parser` nos doze scripts da casa, nos seis legíveis do tema e em três plugins), também no `<script>` embutido; o nome vale inteiro; e as funções do navegador ficam declaradas (`DO_NAVEGADOR`) | `D90PortoesDosScriptsTest`: o nome só num comentário, ou só como pedaço de outro, reprova |
| O portão não acusava a cópia órfã com outro nome na pasta da casa | a checagem nova: todo arquivo da pasta é pedido por alguma tela | `D90PortoesDosScriptsTest` |

Medido antes de ligar o portão novo: dos 125 manipuladores, só o `alert` — o aviso do
limite da exportação, em `index-vendas.phtml` — estava só num comentário (uma linha
comentada do `mod-cadastros.js`), e ele é do navegador; nenhum passava só como pedaço
de outro nome; e os doze arquivos da pasta da casa são os doze que as telas pedem.

---

### D91 — A API do aplicativo aceita AGIR EM NOME de qualquer cliente — PARCIAL, lido em 05/10/2026 — ANOTADO em 14/10/2026: a 189 (b)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: o aplicativo novo, falando /api/v4, em campo antes do deploy que fecha as quatro rotas antigas (passo 2 do D59, W3).

Levantado em 03/09/2026, ao varrer as ações que mandam
`Access-Control-Allow-Origin: *` — e é de natureza diferente de tudo o que o
**D59** registra.

> **O cancelamento já estava no D13**, que diz desde 16/08/2026 que
> *"`/api/agendas/cancelar` permite cancelar agendamentos de terceiros"*. Chamei
> de achado o que já estava escrito, pela segunda vez no mesmo dia. O que este
> registro acrescenta é a medida do estrago — que apaga o **bloco inteiro**, e
> que as outras três ações têm o mesmo defeito — e a correção.

**O D59 é sobre LER. Este é sobre ESCREVER.**

> **Não confundir com o D88**, que é a família vizinha: lá é uma *tela do ERP*,
> que exige sessão de funcionário, e o remédio foi trocar a chave-mestra por
> autorização com rastro. Aqui é a *API do aplicativo*, sem sessão nenhuma, e o
> remédio foi exigir credencial. A família é a mesma — agir como outra pessoa
> sem provar que se é ela —, o defeito não.

Quatro ações recebem o id do cliente de quem chama, sem credencial nenhuma, e
agem como se fossem ele:

| Ação | O que quem chamar consegue fazer |
|---|---|
| `POST /api/agendas/desmarcar` | **apagar** o agendamento de qualquer pessoa |
| `POST /api/agendas/salvarV2` | marcar horário em nome de qualquer pessoa |
| `POST /api/v3/agendas/salvar` | idem, na versão nova |
| `POST /api/cliente/indicacao` | convidar um terceiro **em nome** de alguém |

##### O cancelamento é o pior, e ele APAGA

`AgendaService::desmarcar()` chama `$this->em->remove()` — a linha sai da tabela.
Há arquivamento antes (`arquivarAgendaExclusao`), então o rastro existe; a agenda
do dia, não.

E ele apaga o **bloco inteiro**: `selecionarHorariosFilhos($idPai)` pega todos os
horários do mesmo atendimento. Um serviço de uma hora em grade de quinze minutos
são quatro linhas, e um `id` derruba as quatro.

**Os ids de agenda são sequenciais.** Quem percorrer a faixa esvazia a agenda das
32 unidades. Não é exposição de dado: é a operação do dia.

##### O que isso NÃO é

Não é hipótese de invasor sofisticado, e não depende de saber nada: basta um
`POST` com um número inteiro. E não precisa nem de navegador — o
`Access-Control-Allow-Origin: *` só torna possível fazê-lo a partir de qualquer
página, com o IP de quem visita.

##### CORRIGIDO em 03/09/2026, e o que isso custa

As quatro passaram a exigir sessão, como as cinco de leitura do **D59**. O custo
é maior aqui, e precisa ser dito: **o aplicativo instalado deixa de marcar e de
desmarcar**, que é a função principal dele — não só a consulta.

Isso não muda a data de nada: o deploy de tudo o que foi feito hoje já dependia
do aplicativo novo em campo (passo 2 do D59), que depende de saber quais versões
estão em uso (**W3**). O que muda é o tamanho do que o aplicativo novo precisa
cobrir antes do corte.

##### O QUE FALTA, e é trabalho de desenho

Os sucessores autenticados. O cancelamento é pequeno e está feito:
`DELETE /api/v4/agendamentos/:id`, que só aceita agendamento de um cliente do
telefone autenticado — e responde **igual** para "não existe" e "não é seu", pelo
mesmo motivo do oráculo que o **D77** fechou.

##### MARCAR E INDICAR — FEITOS em 04/09/2026

| | |
|---|---|
| `POST /api/v4/agendamentos` | marca, só para cliente do telefone |
| `POST /api/v4/clientes/:id/indicacoes` | convida, e o indicador vem da ROTA |

**As regras de negócio não foram reescritas — foram compartilhadas.** Era esse o
risco de fazer às pressas: seis regras que valem agenda e dinheiro (cliente
inativo, bloqueio de agendamento pelo app, teto de três pendentes, colaborador
inativo, quantidade de horários do serviço, horários consecutivos e livres),
duplicadas, garantiriam que uma das duas cópias envelhecesse sozinha.

`agendaSalvarV2ValidarFormulario` saiu do `AplicativoController` e virou
`AplicativoService::validarAgendamento()`, pública. As duas superfícies chamam a
mesma.

**E o movimento pagou uma dívida antiga na hora.** Enquanto era `private` num
controller, aquela lógica **não tinha teste nenhum** — chegar nela exigiria
montar a requisição inteira. Virar pública a tornou testável, e
`tests/Caracterizacao/ValidacaoDeAgendamentoTest.php` cobre as seis regras em 12
casos.

Um deles escreve algo que ninguém percebe lendo: o teto de pendentes é `> 3`, e
não `>= 3` — com três agendamentos futuros o cliente restrito ainda marca o
quarto. A diferença entre `>` e `>=` num teto muda o comportamento para milhares
de pessoas.

*O relógio saiu de dentro dela de passagem* (**D42**): a contagem de pendentes
chamava `date("Y-m-d")` no meio; agora quem chama lê uma vez e passa.

##### A fronteira converte, e não contamina

A convenção nova pede ISO 8601; o código compartilhado espera `dd-mm-aaaa`. A
conversão mora no controller do `/api/v4`, e não lá dentro — mexer no formato
que a rota congelada usa seria mudar o mundo congelado para agradar o novo.
`horarios` também virou **lista**, em vez de texto separado por `;`.

*E o `checkdate` importa:* `2026-02-31` casa com a expressão de data e não
existe.

##### Medido contra a aplicação de pé

| | |
|---|---|
| sem token | 401 |
| cliente de outro telefone | 403 |
| data em formato brasileiro | 422, com o campo |
| 31 de fevereiro | 422 |
| sem horários | 422 |
| colaborador inexistente | 422, com a regra de negócio |
| válido | **201**, com o id |
| o mesmo horário de novo | 422, *"não está mais disponível"* |

A última linha é a que importa: é a regra compartilhada funcionando através da
superfície nova.

#### ANOTADO em 14/10/2026 — as dez rotas do aplicativo instalado reabertas, pela resposta 189 (b) do dono

> *"A etapa 1 sobe com as dez rotas abertas ao aplicativo de hoje: como estão hoje na produção, e elas fecham quando o
> aplicativo novo estiver em campo. A exposição fica a de hoje, nem maior nem menor. É trabalho de código, antes da
> janela."* (o pedido da rodada: *"pode começar pelas dez rotas do aplicativo"*)

**O defeito deste débito volta a ficar de pé, por decisão do dono.** As quatro escritas (`POST /api/agendas/cancelar`,
`/api/v2/agendas/agendar`, `/api/v3/agendas/agendar` e `/api/cliente/indicacao`) e as seis leituras do **D59**
respondem de novo sem sessão, como a produção de hoje: quem souber um id desmarca — o bloco inteiro —, marca e indica em
nome de qualquer cliente. É o preço que a 189 (b) aceitou para o aplicativo instalado, que não tem sessão nenhuma,
continuar marcando e desmarcando no dia da troca (o `docs/procedimento-de-corte-etapa-1.md`, "As dez rotas do aplicativo
instalado").

##### O que mudou

**Um interruptor:** a constante `AplicativoController::ROTAS_DO_APLICATIVO_INSTALADO`, que sai `abertas` — no código, e
nunca no `local.php`: é a mesma resposta em todo ambiente. Com ela aberta:

- a guarda de login das dez (`usuarioDoSistemaAutenticado`, o 401) não é perguntada; ela continua escrita, atrás do
  interruptor, e o `conferir-autorizacao-privilegiada.php` vigia as duas metades (a guarda e o interruptor) nas dez;
- **o `Access-Control-Allow-Origin: *` volta** nas nove que o mandavam no código de julho (o `eaf1fca`; todas menos a
  V3, que nunca o mandou). O D59 o tirou das que passaram a exigir sessão (`*` e credencial são mutuamente exclusivos), e
  o aplicativo é chamado de outra origem (o `capacitor://`, ver a `BarreiraDeOrigem`): se ele lê a resposta pelo
  navegador do aparelho, sem o cabeçalho ela não chega a ele. **Não medido** — o aplicativo não está neste repositório —,
  e por isso ele volta com o resto: "como estão hoje na produção". Vai na resposta do Laminas, e não no `header()` de
  julho; no fio, o mesmo cabeçalho;
- o recorte da resposta 106 sai junto nas três que marcam e desmarcam (o **D237**);
- **as duas guardas que não leem a sessão ficam** — o serviço da unidade do colaborador e a linha do bloqueio que o
  desmarcar recusa (a resposta 158) —: ficar ou sair junto era a pergunta 208, e o dono decidiu em 15/10/2026 que
  ficam (a 208 (a)). Na cópia, **0** dos 71.402 atendimentos de 2026 pelo aplicativo têm o serviço de outra unidade (o
  número está no D237), e nada volta ao dono;
- **e o colaborador ativo de uma unidade inativa**, que o V2 e o V3 recusam desde 15/10/2026 nos dois estados (a
  resposta 226 (b); o D482).

**Fechar é trocar a constante para `fechadas`**, quando o aplicativo novo — o `/api/v4`, os sucessores deste débito,
feitos desde 04/09/2026 — estiver em campo: a guarda, o 401 e o recorte da 106 voltam juntos, e o cabeçalho sai. A suíte
de contrato e o ensaio das escritas seguem a constante sozinhos; o caso `testAEtapa1SobeComAsDezAbertas` muda junto, de
propósito.

**O que fica diferente do código de julho nessas dez, e não é de quem chama** (lido ação a ação, com o serviço e as
consultas que elas chamam, contra o `eaf1fca`, na verificação de 15/10/2026; a primeira versão desta lista dava o
colaborador da unidade inativa e a data que não existe como recusados, e esquecia os cupons):

- o `status` do sorteio chega texto, e não nulo (o D56, de 28/08; o snapshot de contrato já o tem);
- os cupons (`cuponsSelecionar`, pela `CupomClienteRepository::selecionarCuponsAtivosCliente`): o do produto oferecido
  desativado não aparece (o **D274**, a resposta 96 (a): *"O aplicativo também deixa de mostrar o cupom dela"*), e o
  desconto e o produto são os que o cupom guardou no dia em que foi dado (o **D271**, a resposta 94 (a); as colunas entram
  no passo 0, e o cupom que não guardou nada segue a campanha, como em julho). As chaves da resposta são as mesmas: ela é
  menor, ou outra no valor, por decisão do dono, e o contrato, que compara a estrutura, não a vê. Provado no
  `CupomDeProdutoDesativadoTest`;
- o marcar (V2 e V3): o serviço que não existe dá "- Informe o serviço." com 200, onde julho dava 500 (o `getReference`
  estourava no `getPeriodos`); as duas guardas da pergunta 208 (ficam: a 208 (a), 15/10/2026); e, desde 15/10/2026, o
  colaborador da unidade inativa, recusado com "Informe o colaborador." (a 226 (b); o D482);
- a ordem do que empata — os próximos horários do cliente (`getClienteAgenda`) e os créditos do consumo — sai pelo id; o
  conteúdo é o mesmo.

**E o que fica igual a julho, e pode surpreender** — as regras do marcar são as de julho: o `validarAgendamento` é o
`agendaSalvarV2ValidarFormulario` do `eaf1fca`, movido ao serviço por este débito em 04/09, e a diferença é só o `$hoje`
vir de fora. Com as dez abertas, então:

- **o colaborador ativo de uma unidade inativa marca pelo V2 e pelo V3.** O `Usuario::daUnidadeInativa` do **D482** (a
  resposta 179 (b)) só é perguntado na agenda do aplicativo e na v4; o D482 deixou estas duas de fora porque elas exigiam
  a sessão e recortavam pela unidade dela, e as duas coisas saíram com a 189 (b). Recusar é escolha do dono — a 179 (b)
  contra o "nem maior nem menor" da 189 (b) — e foi ao lote em 15/10/2026; até a resposta, fica como a produção. Na cópia,
  99 colaboradores ativos em 24 unidades inativas, todos com produto ativo da própria unidade; nenhum atendimento pelo
  aplicativo com eles criado nos 90 dias antes da cópia (2 em 2026, pela data do horário);
- **a data que não existe vira outra:** o V2 e o V3 passam a data ao `DataHora::dataHoraBr_to_dateTime`, o mesmo de julho,
  que lê por posição e faz `new \DateTime` — `31/02/2031` grava em 03/03/2031 —, e o texto fora da forma derruba a rota
  com 500. O D420 e o D432 consertaram o `DataHora::dataValida`, que o marcar não chama;
- **o V3 grava o corpo do pedido em disco antes de qualquer conferência** (`logarJsonRecebido`, em
  `data/logs/agenda_json/`, um arquivo por segundo, inclusive o JSON inválido, do tamanho que o pedido mandar). Com as dez
  fechadas o 401 vinha antes; abertas, qualquer pedido anônimo escreve ali, como em julho.

Os três estão presos em `RotasAntigasDoAplicativoTest` ("Abertas: o que elas NAO recusam"), e o docblock do interruptor os
lista.

##### O provado

- **`DezRotasDoAplicativoInstaladoTest`** (novo, 34 casos, verdes nos dois bancos): abertas, sem sessão, nenhuma das dez
  dá o 401, e as nove mandam um `*` (a V3, nenhum); fechadas, sem sessão, as dez dão o 401 com a frase e sem o cabeçalho,
  e com a sessão do ERP respondem, sem o cabeçalho; o valor que não é `abertas` fecha; a constante sai `abertas`; e as dez
  são exatamente as ações que chamam `usuarioDoSistemaAutenticado` — a lista sai do código do controller, sem
  comentários, e não da memória —, todas atrás do interruptor. Desde a verificação de 15/10/2026, também a tabela da
  guarda do login (`rotas-publicas.php`): o texto de cada rota das dez — as rotas saem do roteador — diz o que a defende
  pelo interruptor; até ali dizia "sessão" com as dez abertas.
- **`RotasAntigasDoAplicativoTest`** (33 casos, 15 novos, verdes nos dois bancos): os 18 da 106 e da 158 seguem, com as
  dez fechadas; abertas, sem sessão, o desmarcar apaga o horário de outra franquia e o v2 e o v3 marcam com o colaborador
  dela, e a linha do bloqueio e o serviço de fora da unidade do colaborador continuam recusados; e, da verificação de
  15/10/2026, o que as abertas não recusam, como julho: o colaborador da unidade inativa e a data que não existe, no v2 e
  no v3, e o corpo do V3 gravado em disco.
- **`scripts/ensaiar-sessao-do-erp.php`**, que roda no CI: o caso 20 (a barreira de origem diante do desmarcar) exigia o
  401 do aplicativo sem o cookie, e com as dez abertas saía 1 (22 de 23, medido antes do conserto, na verificação de
  15/10/2026). Passou a seguir a constante — abertas, o pedido chega à ação sem sessão e ela responde o "ID inválido." do
  id 0; fechadas, o 401 —: 23 de 23 nos dois bancos de contrato, e 23 de 23 com a constante em `fechadas`.
- **A suíte de contrato** (74 casos, verdes nos dois bancos): os doze pedidos sem sessão às dez escolhem o snapshot pela
  constante. Abertas, os de antes deste débito e do D59 (`7d10399^`: `cupons-selecionar`, `agenda-desmarcar-sem-dados`…),
  repostos do git byte a byte e **verdes sem regravar** — a carga e a recusa do formulário são as de antes —, e o
  cabeçalho conferido (a suíte passou a guardar os cabeçalhos da resposta). Fechadas, os `-sem-autenticacao` de
  03/09/2026, guardados: com a constante trocada para `fechadas`, **74 de 74 verdes** — o fechar, medido.
- **`scripts/ensaiar-escritas-do-aplicativo.php`**, nos dois bancos de contrato: sem sessão e de outra origem
  (`Origin: capacitor://localhost`), como o aplicativo instalado, os seis casos dão 200 com o efeito no banco, o cabeçalho
  certo na forma do sucesso e resíduo zero; com a constante em `fechadas`, ele entra como o gerente e passa. A
  restauração da agenda do caso 1 deixou de morrer com chave duplicada quando o desmarcar recusa (achado por um mutante:
  o resto do `finally` ficava para trás).
- **Mutantes**, um de cada vez, cada um vermelho (as tabelas no roadmap de 14/10/2026: a da entrega e a da verificação).

---

### D92 — Defeito só do PostgreSQL era invisível ao CI — CORRIGIDO em 04/09/2026

O CI rodava a suíte de caracterização contra **duas versões de MySQL** e nenhuma
de PostgreSQL. E a migração é *para* o PostgreSQL.

**Custou duas vezes no mesmo dia.** `ValidacaoDeAgendamentoTest` inseria
`checkin, checkout, status` como `0, 0, 1`; no PostgreSQL as três colunas são
booleanas (**D81**). Quatro testes verdes no MySQL, quatro vermelhos no
PostgreSQL — e só apareceram porque rodei à mão. Antes disso, uma colisão de id
entre fixtures e testes só apareceu no cenário do CI.

*Ficar dependendo de eu lembrar não é uma verificação.*

#### O QUE FALTAVA NÃO ERA O JOB — era o `fixtures.sql` carregar no PostgreSQL

Essa era a razão de a ausência ter durado. Medido, eram **três coisas pequenas**:

| | |
|---|---|
| `CONCAT(CURDATE(), ' 08:00:00')` | virou `CURRENT_DATE + INTERVAL '8' HOUR` |
| 23 valores `0`/`1` em coluna booleana | viraram `FALSE`/`TRUE` |
| `SET NAMES utf8mb4` | é do MySQL; quem carrega no PostgreSQL pula |

A forma padrão do intervalo foi **conferida nos dois bancos**, e devolve o mesmo
instante. As 23 trocas foram achadas cruzando cada `INSERT` com as 77 colunas
booleanas do destino — e não por busca de texto.

**O arquivo ficou portável, e não ganhou um irmão derivado.** Um
`fixtures.postgres.sql` gerado seria exatamente a armadilha do **D90**: duas
cópias que divergem em silêncio. O que o carregador acrescenta são duas linhas
que não cabem no arquivo — `SET app.inquilino_id`, que o RLS exige, e pular o
`SET NAMES`.

**Conferido tabela a tabela:** os dois bancos carregam as mesmas 17 tabelas com
as mesmas contagens.

#### O que passou a existir

**`scripts/rodar-caracterizacao-postgres.sh`**, para rodar aqui, e um **job novo
no CI**. A suíte inteira roda no PostgreSQL: 196 testes.

*A configuração do CI é derivada de `config/autoload/local.postgres.php.dist`*,
e não escrita de novo — escrever à mão já custou uma vez nesta mesma tarefa: usei
o `driverClass` do DBAL 3 num projeto que usa DBAL 2, e o job teria falhado com
classe não encontrada.

**O job conecta como DONO do banco, e isso é deliberado.** O dono ignora RLS.
Este job pergunta *"o código roda no PostgreSQL?"*; quem pergunta *"o isolamento
entre inquilinos vale?"* é o job ao lado, com `postgres/teste-rls.sql`. São duas
perguntas, e misturá-las enfraqueceria as duas respostas.

#### O FUSO PRECISOU SER FIXADO NOS DOIS LADOS

O job nasceu vermelho por um único teste, e a causa não era o teste.

`Venda::setDataCriacao()` grava `new DateTime('now')` — hora do PHP, **sem
deslocamento** —, e a coluna no PostgreSQL é `timestamptz`. Quando o banco
interpreta esse texto num fuso e o PHP o lê noutro, a diferença é exatamente o
deslocamento entre os dois.

Medido, e reproduzido de propósito: com PHP em UTC contra PostgreSQL em
`America/Sao_Paulo`, `PagamentoTrocoDebitoTest` acusa **10.799 segundos** onde
espera menos de 600. Três horas.

*É o **D42** visto por outro ângulo.* Aquele é a FONTE do relógio — banco contra
PHP. Este é o mesmo relógio lido em dois FUSOS. O sintoma é idêntico e a causa
não é.

O job agora fixa `TZ`/`PGTZ` no serviço e `date.timezone` no PHP, ambos em
`America/Sao_Paulo` — que é o que `docker/php/php.ini` usa e o que a réplica de
produção roda. Herdar o fuso do ambiente é o que fez a diferença aparecer só num
lugar.

#### O que continua fora

A suíte de **contrato** roda só contra MySQL, e continua assim: ela exige a
aplicação de pé respondendo HTTP, e subir a aplicação apontada para o PostgreSQL
no CI é outro trabalho. O que ela cobre — formato de resposta — é o que menos
depende do banco.

---

### D93 — Duas consultas do mesmo par perguntam o mês de jeitos diferentes — CORRIGIDO em 05/09/2026

Descoberto em 05/09/2026, ao ordenar as consultas do NPS pelo **D72**.

`NpsResultadoUnidadeRepository` tem duas consultas que a consolidação usa em
sequência sobre a **mesma linha**, e elas não filtram pela mesma coisa:

| Método | Filtro |
|---|---|
| `selecionarPorUnidadeMesAno` | `MONTH(nps.dataCriacao) = :mes AND YEAR(nps.dataCriacao) = :ano` |
| `selecionarTotalizadoresPorUnidadeMesAno` | `nps.mes = :mes AND nps.ano = :ano` |

A tabela tem as colunas `mes` e `ano`, **as duas `NOT NULL`** — e uma das
consultas as ignora, deduzindo o período da data em que a linha foi criada.

#### Medido

| | |
|---|---:|
| linhas em `nps_resultados_unidades` | 2.514 |
| onde `mes`/`ano` divergem de `MONTH`/`YEAR(data_criacao)` | **0** |

**Hoje as duas concordam** — por acidente de como os dados nascem: a
consolidação roda dentro do mês que ela consolida, então a data de criação cai
no mesmo mês que as colunas dizem.

#### Quando isso quebra

1. **Uma consolidação que atravesse a virada do mês.** A rotina roda de
   madrugada; uma execução que comece em 31/03 e grave em 01/04 cria uma linha
   com `mes = 3` e `data_criacao` em abril. A partir daí uma consulta acha a
   linha e a outra não.
2. **Um reprocessamento.** Recalcular março em maio grava `mes = 3` com
   `data_criacao` de maio — e o mesmo desencontro.
3. **A migração.** `MONTH()`/`YEAR()` sobre `timestamptz` no PostgreSQL
   dependem do fuso da sessão; as colunas `mes`/`ano` não dependem de nada. É o
   **D42** aplicado a um filtro em vez de a um cálculo.

O sintoma é o pior possível: a consolidação lê os totalizadores de uma linha,
não acha a outra, **cria uma linha nova**, e o mês passa a ter duas — que é
exatamente a duplicata que o D72 encontrou ali.

#### Resolvido no mesmo dia, e eram quatro consultas

As quatro passaram a filtrar por `nps.mes` e `nps.ano`:

| Repositório | Método |
|---|---|
| `NpsResultadoUnidadeRepository` | `selecionarPorUnidadeMesAno` |
| `NpsResultadoColaboradorRepository` | `selecionarPorColaboradorMesAno` |
| | `selecionarTotalizadoresNotasPorUnidadeMesAno` |
| | `selecionarTotalizadoresPorColaboradorMesAno` |

**A troca não muda número nenhum hoje, e isso está medido:** 2.514 linhas na
tabela da unidade e 9.215 na do colaborador, **zero divergentes** nas duas. É o
raro caso em que a medição autoriza a mudança em vez de adiá-la.

#### Os irmãos foram examinados, e são o argumento

`DashboardFaturamentoRepository::selecionarPorUnidadeMesAno` e
`DashboardColaboradorRepository::selecionarPorMesAnoColaborador` — mesmo nome,
mesmo assunto — **já filtravam por `das.mes` e `das.ano`**. Não era uma
convenção ausente no sistema: era o NPS destoando dela.

`NpsRepository` e `NpsItemRepository` também usam `MONTH(nps.data)`, e ali está
**certo**: aquelas tabelas não têm colunas `mes`/`ano`, e `data` é a data da
própria pesquisa — o período *é* a data. Conferido antes de mexer.

#### O que os testes provam

`ConsolidacaoNpsTest` ganhou o cenário que a base ainda não tem: uma linha de
**março gravada em maio**. Um teste exige que ela seja achada por (3, 2026); o
outro, que **não** seja achada por (5, 2026) — sem o espelho, uma consulta que
continuasse olhando a data de criação passaria pelo primeiro.

Com o filtro antigo os dois ficam vermelhos.

---

---

### D94 — A busca do bilhete do sorteio não filtra pelo sorteio — CORRIGIDO em 05/09/2026

Descoberto em 05/09/2026, ordenando a consulta pelo **D72**.

`SorteioVendaRepository::selecionarVenda($vendaId)` procura o bilhete **só pela
venda**:

```sql
WHERE sorven.venda = :venda
```

E `SorteioService` usa o que voltar para **marcar o ganhador** do sorteio que
está sendo apurado. Se a mesma venda tiver bilhete em dois sorteios, o serviço
pode marcar como ganhador do sorteio A um bilhete que pertence ao sorteio B.

#### Medido

| | |
|---|---:|
| vendas com mais de um bilhete | **4** |
| destas, com bilhetes em sorteios **diferentes** | **0** |

**Não acontece hoje**, e por um motivo frágil: as campanhas de sorteio não se
sobrepuseram no tempo. Duas campanhas simultâneas — que é uma decisão de
marketing, não uma mudança de código — bastam para produzir o caso.

#### Corrigido em seguida, e o parâmetro é OBRIGATÓRIO

`selecionarVenda($vendaId, $sorteioId)` — sem valor padrão. Parâmetro opcional
deixaria o esquecimento possível, que é exatamente o defeito que se está
consertando, e **os dois chamadores já tinham o sorteio à mão**:

| Chamador | De onde vem o sorteio |
|---|---|
| `SorteiosController::finalizarTerceirosValidarFormulario` | `$request['sorteio']`, validado no bloco imediatamente acima |
| `SorteioService::finalizarInterno` | `$sorteio->getId()`, o argumento do método |

Eu havia escrito aqui que o primeiro "nem tem o sorteio à mão no ponto da
chamada". **Estava errado:** ele o valida quinze linhas antes, e o guarda em
`$retorno['sorteio']`. A frase saiu de uma leitura apressada, e é a razão de o
débito ter sido adiado por um dia.

#### O que o teste prova

`BilheteDoSorteioTest::testOBilheteVemDoSorteioPedido` monta o cenário que a
base não tem — a mesma venda com bilhete em duas campanhas — e põe o bilhete da
**outra** campanha com id menor, para que a consulta sem filtro devolva
justamente ele. Com o filtro anulado, fica vermelho.

#### Um vizinho, que virou o D95

`SorteioService::finalizarInterno` sorteava dentro de `while (true)`. Anotei
aqui como "merece medição antes de conserto", medi em seguida, e o conserto
saiu no mesmo dia — está no **D95**.

---

### D95 — O sorteio de um prêmio sem ganhador possível prendia a requisição para sempre — CORRIGIDO em 05/09/2026

Descoberto em 05/09/2026, lendo `SorteioService::finalizarInterno` para o D94.

```php
while (true) {
    $key = array_rand($vendas);
    // ... sai daqui SÓ quando achar uma venda de status 1
}
```

A lista de candidatas vem de `selecionarVendasPorSorteio`, que **não filtra por
status**. Se todas as vendas participantes estiverem canceladas, `array_rand`
sorteia para sempre entre linhas que nunca servem: **a requisição nunca
termina**, e o processo do PHP fica preso até o servidor derrubá-lo.

#### Não é hipótese

| Sorteio | Vendas | Com venda ativa |
|---|---:|---:|
| Fantasia dia das crianças 2023 | 2.325 | 2.321 |
| SC — Florianópolis (cancelado) | **0** | 0 |
| SC — Florianópolis | 1.385 | 1.377 |
| Dia das Crianças (aguardando sorteio) | 3.500 | 3.475 |
| *fixture* `Sorteio de contrato` | 1 | **0** |
| *fixture* `Sorteio da Sessao` | 1 | **0** |

Os dois sorteios de fixture **deste repositório** têm exatamente a forma que
trava: uma venda cancelada e mais nada. Qualquer teste que chamasse
`finalizarInterno` neles penduraria a suíte.

Em produção o caso vivo é o sorteio de id 2, sem venda nenhuma — ali
`array_rand([])` **lança** em PHP 8 em vez de travar. O travamento exige o outro
arranjo: vendas existentes, todas canceladas.

#### Provado, e sob limite de tempo

Com o laço antigo, o teste do cenário roda até ser morto:

```
docker compose exec -T app timeout 25 phpunit --filter testSorteioComTodasAsVendasCanceladasNaoTemGanhador
codigo de saida: 124        # morto pelo limite
```

Com o laço novo, ele responde em menos de um segundo.

#### O conserto

**Sorteio sem reposição.** Cada tentativa sai da lista, então o laço termina em
no máximo uma passada por venda — e a distribuição entre as elegíveis continua
uniforme, que é o que faz um sorteio ser sorteio.

**E devolver `null` é parte do conserto**, não um efeito colateral dele. "Não há
ganhador possível" é uma resposta, e quem chama precisa dela: o controlador
passou a responder *"Nenhuma venda elegivel: todas as vendas participantes estao
canceladas."* em vez de deixar o navegador girando.

#### O teste que trava se a correção sumir

`BilheteDoSorteioTest::testSorteioComTodasAsVendasCanceladasNaoTemGanhador`.
**Se ele travar em vez de falhar, a correção foi revertida** — é a forma que um
teste de terminação tem, e o tempo limite do job do CI é a rede de segurança. O
par dele (`...ComVendaAtivaTemGanhador`) impede o conserto preguiçoso de
devolver `null` sempre.

---

### D96 — O contrato do aplicativo só era conferido contra MySQL, e o login do ERP não sobrevivia ao PostgreSQL — CORRIGIDO em 05/09/2026

Descoberto em 05/09/2026, ao perguntar o que faltava depois do **D92**.

Aquele débito pôs a **caracterização** para rodar contra PostgreSQL no CI. A
**suíte de contrato** — a que congela o que os aparelhos instalados recebem, e
que ninguém pode atualizar em bloco (**D5**) — continuou só no MySQL.

A pergunta que faltava é uma só, e vale ser respondida antes do corte:
**trocar o banco muda o que o aplicativo recebe?**

#### A primeira execução respondeu, e não foi "não"

`scripts/rodar-contrato-postgres.sh`, primeira execução:

| | |
|---|---:|
| testes | 67 |
| passaram | **61** |
| falharam | **6** |

As seis, todas com a mesma causa: **o login do ERP respondia HTTP 500**.

```
SQLSTATE[22P02]: invalid input syntax for type integer: "t"
```

`AutenticacaoController` chama `selecionarPorLogin($login, true)`, e
`usuarios.status` é `integer` — na anotação e nos dois bancos. O MySQL aceita o
booleano e converte em silêncio; o PostgreSQL recusa a consulta inteira. O mesmo
vale para `selecionarPorEmail($email, true)`, que é o **"esqueci minha senha"**.

**No PostgreSQL, hoje, ninguém entraria no ERP.**

#### É o D82, no ponto cego que o próprio portão dele declara

O `conferir-booleano-em-coluna-inteira.php` procura o literal `true`/`false`
**dentro do método que monta a DQL**. Aqui o literal está no **chamador**, três
arquivos adiante, e o método recebe `$status` como argumento. O cabeçalho
daquele roteiro diz isso desde que ele nasceu:

> O QUE ELE NAO VE: valor que chega por variavel atribuida longe dali.

Uma limitação declarada continua sendo uma limitação. **O que fechou o buraco
não foi um portão melhor: foi rodar contra o banco certo.**

#### O conserto, e onde ele ficou

`$param['status'] = (int) $status;` nos dois métodos. **No repositório, e não
nos chamadores** — é o repositório que sabe o tipo da coluna, e assim o próximo
chamador não precisa saber.

#### O que passou a guardar isso

| Onde | O que garante |
|---|---|
| `SenhaEAcessoTest`, dois testes novos | o caminho **do chamador**, com o `true` que o controlador passa |
| job `Contrato e caracterização — PostgreSQL` | as 67 respostas do aplicativo, agora nos dois bancos |
| `scripts/rodar-contrato-postgres.sh` | a mesma pergunta, na máquina de quem desenvolve |

Sem a conversão, os dois testes novos ficam **vermelhos no PostgreSQL e verdes
no MySQL** — como o `SaldoDuplicadoTest` do **D72**, e pelo mesmo motivo de
fundo.

#### E a resposta à pergunta, agora que ela existe

**67 de 67.** Com o login consertado, o contrato do aplicativo é idêntico nos
dois bancos. É o resultado que se queria — mas ele só vale porque a pergunta foi
feita: até hoje de manhã, "o contrato passa" queria dizer "passa no banco que
vamos deixar de usar".

---

### D97 — Nove telas do ERP quebram no PostgreSQL, e zero no MySQL — ZERADO em 05/09/2026

Descoberto em 05/09/2026, na pergunta seguinte ao **D96**.

Aquele débito consertou o login do ERP, que respondia HTTP 500 no PostgreSQL — e
deixou a observação que importa: **nenhuma suíte deste projeto abre uma tela**.
A de contrato cobre o que o aplicativo recebe; a de caracterização chama
serviços por dentro. O ERP inteiro, que é onde 180 barbeiros trabalham todo dia,
não tinha cobertura nenhuma contra o banco para o qual estamos migrando.

#### Medido

`scripts/medir-telas-no-postgres.sh` entra no ERP, lê o menu do próprio layout e
pede cada tela. Duas execuções, mesma sementeira, bancos diferentes:

| | PostgreSQL | MySQL |
|---|---:|---:|
| telas visitadas | 143 | 139 |
| **HTTP 200** | 121 | **125** |
| HTTP 404 | 13 | 14 |
| **HTTP 500** | **9** | **0** |

**O controle é a metade que vale.** "Nove telas quebradas" sozinho não diz se o
defeito é da migração ou do cenário de fixtures. Com zero no MySQL, diz.

#### As nove, e as três famílias

| Tela | Erro |
|---|---|
| `/mod-vendas/vendas/indexVendas` | `22P02` — booleano em coluna inteira |
| `/mod-cadastros/assistentesUsuarios/index` | `22P02` |
| `/mod-cadastros/vipClassEdu/index` | `22P02` |
| `/mod-relatorios/rel-aniversariantes/index` | `22P02` |
| `/mod-marketing/notificacoes/index` | `42883` — `operator does not exist: boolean = integer` |
| `/mod-relatorios/rel-clientes/index` | `42883` |
| `/mod-relatorios/rel-descontos/index` | `42883` |
| `/mod-vendas/caixas/gerenciar` | **`42803` — erro de agrupamento** |
| `/mod-relatorios/rel-caixas/index` | **`42803`** |

As sete primeiras são o par **D81**/**D82** — o mesmo erro de tipo nos dois
sentidos — vivo em telas que nenhum portão alcançava.

#### As duas últimas são notícia ruim sobre um portão que diz zero

O **D24** foi promovido a portão bloqueante hoje de manhã, com **zero quebras**
em 105 consultas com agrupamento. E duas telas falham com erro de agrupamento no
PostgreSQL.

A causa apareceu ao ler o auditor: ele tratava `GROUP BY vp.formaPagamento`
como equivalente a `GROUP BY fp.id`, com um comentário explicando que a junção
casa aquele valor e os grupos são os mesmos. **São mesmo — e o PostgreSQL recusa
assim mesmo**, porque só reconhece dependência funcional a partir da chave
primária da própria tabela.

**Era a dedução do MySQL 8 escrita dentro do detector do defeito do MySQL.** A
regra saiu em 05/09/2026; com a consulta antiga de volta a auditoria sai com 1, e
com a corrigida volta a zero. Nenhuma outra consulta do sistema dependia dela.

Fica a lição sobre a ferramenta anterior a esta: **`ONLY_FULL_GROUP_BY` no MySQL
não substitui executar no PostgreSQL** — o modo estrito do MySQL aceita a
dependência através da junção, então não podia ter acusado este caso.

É a terceira vez nesta semana que um portão verde convive com um defeito da
mesma família: o do D82 não via o literal no chamador (**D96**), o de ordenação
não vê `ORDER BY` montado por auxiliar (**D46**), e agora o de agrupamento. A
lição não é "os portões são ruins" — é que **portão que lê código e execução que
usa banco respondem perguntas diferentes**, e a segunda não tinha quem a fizesse.

**Os três portões ficaram melhores por causa disto**, e cada melhoria nasceu de
um defeito real: o do D82 aprendeu a forma `$params['x'] = true`, o do D46
ganhou dispensa escrita para o caso em que a correção é proibida pelo D47, e o
do D24 perdeu a suposição do MySQL que carregava dentro.

#### O que este medidor não mede

Tela que só aparece depois de um POST, fluxo com vários passos, e qualquer coisa
que dependa de dado que as fixtures não têm. Uma tela que responde 200 aqui pode
quebrar com dado de verdade. **É piso, não teto** — e o piso já é nove.

Ele também **concede privilégios** ao usuário de fixture no banco descartável:
sem isso o ACL nega e a tela responde 404. A primeira execução visitou 61
endereços e 57 eram negados — uma medição que só alcança o que o ACL recusa mede
permissão, não tela.

#### Zerado no mesmo dia, com três consertos

**9 → 0.** Nove telas, três correções — e a desproporção é o achado:

| Conserto | Telas que voltaram |
|---|---:|
| `GROUP BY vp.formaPagamento` → `GROUP BY fp.id` | 2 |
| `con.status = 1` → `con.status = true` | 3 |
| `$params['status'] = true` → `= 1`, em 7 lugares | 4 |

**Uma linha de DQL derrubava três telas.** `ConvenioRepository::popularCombobox`
alimenta o filtro de clientes, e esse filtro aparece no relatório de clientes, no
de descontos e na tela de notificações. Defeito de tipo não fica onde nasce.

##### O agrupamento, e por que o MySQL aceitava

```
GROUP BY vp.formaPagamento     -- a coluna do lado de cá, igual a fp.id pelo JOIN
SELECT fp.id, fp.nome, SUM(...)  ORDER BY fp.ordenacao
```

O MySQL 8 **deduz a dependência funcional através do JOIN**: se agrupou por uma
coluna igual a `fp.id`, então `fp.nome` está determinado. O PostgreSQL só
reconhece dependência funcional a partir da **chave primária da própria tabela**.
Agrupando por `fp.id`, os dois aceitam, e o resultado é o mesmo linha a linha.

É por isso que o auditor do **D24** dizia zero e estava certo: ele confere se as
colunas projetadas estão no agrupamento *do jeito que o MySQL exige*. Esta é uma
regra a mais, e ela só aparece executando.

#### Os dois pontos cegos que os portões ganharam de volta

| Portão | O que não via | Agora vê |
|---|---|---|
| `conferir-booleano-em-coluna-inteira.php` (**D82**) | `$params['status'] = true;` — atribuição a índice | sim, e achou **7** |
| `conferir-booleanos-em-consulta.php` (**D81**) | a consulta inteira, quando a string interpola uma variável | sim, e reprova a `con.status = 1` |

A segunda é a mais fina das três. O roteiro lia só `T_CONSTANT_ENCAPSED_STRING`,
e uma string como

```php
" WHERE con.status = 1 AND (conuni.unidade IS NULL OR conuni.unidade = $unidadeId)"
```

**não é** desse tipo: o PHP a quebra em pedaços literais mais variáveis. A
consulta inteira sumia da análise — e com ela o `= 1` que estava à vista, no meio
do texto. A variável interpolada continua fora de alcance (é do **D73**, que
vigia concatenação); o que entrou foi o texto literal, que é onde mora o defeito
que este roteiro procura.

O primeiro é o que importa: a forma faltava, **e nem estava na lista de pontos
cegos declarada no cabeçalho do próprio roteiro**. Com ela, o portão encontrou os
sete sítios sozinho — quatro em `UsuarioRepository`, dois em `VipClassRepository`,
um em `AssistenteRepository` — e todos eram reais.

#### O que passou a guardar

O passo **"Telas do ERP (nenhuma pode responder 500)"**, no job de PostgreSQL do
CI, **depois** da caracterização — ele concede privilégios ao usuário de fixture,
e a suíte financeira precisa do banco como as fixtures o deixaram.

Medição final, pelo mesmo roteiro que abriu este débito:

| | PostgreSQL | MySQL |
|---|---:|---:|
| telas visitadas | 146 | 139 |
| **HTTP 500** | **0** | **0** |

O PostgreSQL visita mais telas que o MySQL agora — as que quebravam não
mostravam os próprios links.

#### A décima tela, que só o CI viu

O passo novo do CI reprovou na primeira execução, e não por defeito dele:
`/mod-configuracoes/unidades/index` respondia **500** lá e **200** aqui.

```
SQLSTATE[42P10]: for SELECT DISTINCT, ORDER BY expressions must appear in select list
```

**É o encontro de dois consertos anteriores, cada um certo sozinho.** A ordem
daquela listagem tem um `CASE WHEN uni.estado IS NULL`, que é o auxiliar do
**D46** para o nulo ordenar igual nos dois bancos. A paginação roda **sem output
walkers**, que é o modo que o **D47** descreve: o Doctrine pagina por
subconsulta, com `SELECT DISTINCT`, e o PostgreSQL exige que toda expressão do
`ORDER BY` apareça na lista do `SELECT`. Ele acrescenta sozinho as colunas
simples; um `CASE` não é coluna nenhuma.

**Tentei ligar os walkers, e foi pior:** o erro de SQL virou um HTTP 500 **sem
corpo**, que nem a página de exceção explica. Preferir o defeito que se entende
ao que não se entende não é resignação — é poder consertar.

O conserto foi **tirar o `CASE` da ordenação**, e o que se perde está medido:
`unidades.estado` tem **1 nulo em 63**, e nessa única linha a posição passa a
diferir entre os bancos. As outras 62 ficam iguais.

##### O portão do D46 ganhou uma dispensa, porque aqui não há correção possível

`conferir-ordenacao-nulos.php` passou a aceitar `@ordenacao-nulos-conferida`
seguida de um motivo com no mínimo 40 caracteres, e a listar as dispensas em
seção própria — some da lista de expostas, **não some da vista**.

Ela existe por uma razão estreita e verificada: nesta consulta a correção do D46
**não pode ser aplicada**, porque a do D47 a proíbe. Sem a dispensa, o portão
ficaria vermelho para sempre e alguém acabaria por desligá-lo.

*A primeira dispensa que escrevi ficou invisível:* o roteiro lia uma janela de
120 caracteres antes do `ORDER BY`, e um motivo que explique algo não cabe em
120 caracteres. A janela da dispensa passou a ser própria e maior; a da detecção
de correção continua apertada, porque ali uma janela larga criaria falso
"corrigida".

##### Por que aqui não reproduzia, e isso vale mais que o conserto

O ambiente local e o do CI **não são o mesmo PostgreSQL**:

| | local | CI |
|---|---|---|
| usuário do banco | `bvip_app` | `franquiabv` (dono) |
| RLS | **ativo** | ignorado pelo dono |

O dono enxerga todas as unidades; a aplicação local, sob RLS, enxerga o que o
inquilino declarado permitir. O roteiro local passou a conectar como o dono, para
medir o mesmo que o portão mede.

**Isso não explicou a diferença**, e a explicação verdadeira apareceu depois —
está logo abaixo. Fica registrado que a hipótese do usuário do banco foi
levantada, testada e **descartada**: alinhar os dois ambientes foi certo por
outras razões, e não era a causa.

#### A causa, achada em 05/09/2026: o medidor é cego onde mais se olha para ele

O `php.ini` de desenvolvimento tem `display_errors = On`. Quando a tela falha, o
PHP **imprime o aviso antes** de o framework definir o status: os cabeçalhos vão
embora com **200**, e a página de exceção chega no corpo de uma resposta
"bem-sucedida". No CI, sem `display_errors`, a mesma falha vira **500**.

O medidor olhava só o status. Ou seja:

> **O ambiente onde alguém roda a medição à mão é justamente o que esconde o
> defeito.** Toda vez que escrevi "zero telas quebradas" aqui, o número valia
> menos do que parecia; os números do CI eram os verdadeiros.

Ele passou a ler o corpo também — uma tela com exceção no corpo conta como
quebrada, seja qual for o status. Com a correção, a medição local reproduz
exatamente o que o CI viu: **uma** tela, `/mod-configuracoes/unidades/index`, com
o `SQLSTATE[42P10]` na mão.

*A primeira versão dessa leitura acusou as 146 telas*: o `strip_tags` deixa o
conteúdo do `<script>`, e qualquer página do sistema tem um `Error` ali dentro.
O script sai antes, e as agulhas ficaram estreitas — nome de classe terminado em
`Exception` ficou de fora de propósito, porque aparece em texto legítimo e o
custo do falso positivo num portão é alto.

---

### D98 — As 22 rotinas de cron rodam no PostgreSQL, e duas morriam por outro motivo — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito sem marca de estado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: fazer a trava em /tmp pertencer a quem roda o cron no servidor, e conferir o `display_errors` de produção.

> **Em 15/10/2026 (o D499):** a primeira das duas faltas saiu pelo código — a trava de outro dono abre para ler e trava igual. No lugar dela fica conferir no servidor, antes do corte, se alguma rotina está parada por uma trava de outro dono, porque o conserto a faz voltar a rodar (a pergunta foi ao lote). O `display_errors` de produção continua.

Medido em 05/09/2026, fechando a terceira frente aberta pelo **D96**.

O D96 achou o login quebrado e o **D97** achou nove telas — os dois porque
alguém finalmente **pediu** o que nenhuma suíte pedia. Sobrava o terceiro mundo:
as rotinas de `/api/cronjobs/*`, que rodam de madrugada e sustentam royalties,
dashboards, NPS e envios. Elas **escrevem muito**, e escrita é onde os defeitos
de tipo do **D81**/**D82** mordem.

#### A resposta é boa, e é a primeira notícia

| | |
|---|---:|
| rotinas chamadas | **20** desde 14/09/2026 (eram 22) |
| erros de SQL no PostgreSQL | **0** |
| fora da medição | 4 parados por decisão sua (Yungas e as três de marketing), 3 que não são rotinas, e **2 que falam com a API de produção do Asaas** |

##### As duas que saíram, e por que sair foi urgente — 14/09/2026

Até esta data a medição chamava `relatoriosRoyaltiesFaturasGerar` e
`relatoriosRoyaltiesFaturasAtualizar`, **uma a uma, a cada execução** — e o CI
também: a última corrida verde, de 13/09, pediu as duas.

As duas instanciam a classe `Asaas`, cuja **chave de produção está escrita no
construtor** (o **D3** já a registra). A primeira chega a `gerarBoleto()`, que faz
`POST`/`PUT` em `payments`: **cria cobrança**. E essa chamada está **fora** do
`if (date('d') == 16)` que envolve o resto do método — vale qualquer dia. A
segunda faz `GET` em `payments/{id}/status`, lendo a conta de produção.

**Nada aconteceu até hoje porque `royalties_faturas` nasce vazia no banco
descartável**, e porque na cópia local o anonimizador zera `asaas_id`. Isso é
acidente do dado, não guarda: no dia 16 a própria rotina cria as faturas e entra
no laço que as cobra no mesmo fôlego — é o que o **D44** registra, sob o rótulo
"para decisão sua".

Medição não pode ter efeito irreversível no mundo. As duas entraram na lista de
exclusões com o motivo escrito, e essa lista ganhou uma **terceira natureza** ao
lado das duas que já tinha. `relatoriosRoyaltiesAtualizar` **fica**: ela calcula
royalties e não alcança o Asaas, conferido linha a linha.

Falsificado nos dois sentidos, executando a enumeração real e parando antes de
qualquer pedido: sem as duas linhas, o roteiro monta **22** rotinas e as duas
aparecem entre as que seriam chamadas; com elas, **20**, e as duas só aparecem em
"fora".

##### A lista veio do CONTROLADOR, e não de um prefixo — e isso custou uma correção

A primeira versão lia `/api/cronjobs/*`, e **perdia quatro das 25 entradas do
crontab**: `cupons/cuponsValidarVencimento` e as três de `marketing/` moram
noutro caminho e são rotinas do mesmo jeito — mesma classe, mesmo token, mesmo
agendador.

Ler pelo `CronJobController` corrige isso e faz a lista acompanhar o sistema. Ela
trouxe junto três endereços que **não** são rotinas: `/api/unidade/vendas`, `V2`
e `V3` — a integração fiscal, que mora no mesmo controlador mas autentica por
`hash` da unidade. Ficam de fora com o motivo escrito, e já têm contrato
congelado.

*A primeira tentativa de ler por controlador usou uma expressão com `.*?` entre
a rota e o controlador, e ela **atravessava blocos**: casava o `'route'` de uma
entrada com o `CronJobController` de outra, e a lista saiu com 29 nomes. Partir
o arquivo em blocos pelo próprio `'route' =>` não tem esse risco.*

Nenhum `SQLSTATE`. Depois de nove telas e um login, era razoável esperar o
contrário — e vale registrar quando a medição absolve.

#### `200` não prova nada aqui, e por isso o corpo é lido

27 das 30 ações respondem `{"sucesso": true}` mesmo com o token errado
(**D4**). Duas consequências para quem mede:

1. **sem o token certo, a medição não mede nada** — receberia 200 de rotinas que
   nem chegaram a rodar;
2. **200 não é sucesso**: o medidor procura `SQLSTATE`, `Fatal error` e nome de
   exceção **no corpo**, e conta como quebra mesmo com status 200.

Foi assim que as duas de baixo apareceram.

#### A pasta que não existe mata a rotina — CORRIGIDO

Três lugares gravavam o cache de relatório assim:

```php
$fp = fopen($arquivo, 'w+');   // false quando a pasta nao existe
fwrite($fp, json_encode($lista));
```

Com a pasta ausente o `fopen` devolve `false`, e o `fwrite` **lança** em PHP 8.
A rotina morre no meio do laço — e responde `sucesso: true`.

**Não é hipótese de laboratório:** `data/cache/` está no `.gitignore`, então a
pasta não existe num checkout novo — nem no CI, nem numa máquina nova, **nem no
servidor para onde a migração vai**. Em produção ela existe porque alguém a
criou uma vez, há anos.

O conserto é um `gravarCache()` que cria a pasta e confere as duas operações.
Duas rotinas quebravam só por isto: `relatoriosEvasaoAtualizar` e
`relatoriosFidelizacaoAtualizar`.

#### A trava envenenada por quem roda pela linha de comando

`TravaDeExecucao` guarda um arquivo em `/tmp`, e **quem o cria vira dono**. Uma
execução pela linha de comando (root) deixa `/tmp/npsContabilizar.lock` com dono
`root`; a chamada seguinte pela **web** (`www-data`) não consegue abri-lo:

```
Warning: fopen(/tmp/npsContabilizar.lock): Permission denied
RuntimeException  .../TravaDeExecucao.php:65
```

E o cron **é chamado pela web** — o crontab usa `curl`. Uma única execução
manual pela linha de comando basta para desligar aquela rotina para sempre, e o
sintoma é o mesmo de sempre: nada.

**Não consertei no código, e a razão é que o conserto certo não é de código.**
Um nome de arquivo por usuário faria dois usuários terem duas travas — ou seja,
exclusão mútua nenhuma, que é pior que o defeito. O que resolve é o arquivo
pertencer a quem roda o cron. Fica registrado, medido, com a reprodução escrita.

*O medidor apaga `/tmp/*.lock` antes de rodar*, porque a pergunta dele é sobre o
banco e trava velha seria ruído.

**Consertado no código em 15/10/2026 (o D499), sem nome por usuário.** O mesmo veneno
derrubou três vezes o ensaio de permissão no melhor da rede do Profissional Parceiro.
O `flock` não pede escrita: `TravaDeExecucao::obter` abre para ler o arquivo que não
abre para escrever, e trava igual — a mesma trava para os dois donos, que era o que
a objeção acima pedia. Para a produção, a falta muda de figura: o arquivo não precisa
mais pertencer a quem roda o cron, mas uma rotina que esteja parada no servidor por
uma trava de outro dono **volta a rodar** quando o código subir. Conferir as travas
do servidor antes do corte é do dono (a pergunta foi ao lote em 15/10/2026).

#### Uma observação que fica pela metade, de propósito

Nas duas falhas acima a resposta foi **HTTP 200 com a página de exceção no
corpo**. A causa provável é o `display_errors` ligado no ambiente de
desenvolvimento: o `Warning` sai antes de o framework definir o status, e os
cabeçalhos vão embora com 200.

**Não conferi a configuração de produção**, e por isso não afirmo que lá
acontece o mesmo. Escrevo assim em vez de escrever a versão alarmante — este
documento já tem um débito que nasceu com o diagnóstico errado (**D55**), e a
diferença entre os dois casos foi exatamente esta linha de cuidado.

##### E a metade que faltava estava escrita desde a F2

`docs/f2-fatia-pdv.md` mede exatamente isto, com outro nome: *"a aplicação
imprimia avisos ANTES da resposta, e isso anula o código HTTP"* — corpo dizendo
"sem permissão", resposta dizendo 200, tipo dizendo HTML. Lá foi
`SessaoAcl::getUnidade()` indexando nulo sem sessão, corrigido com `?? null`.

**Dois irmãos daquele ficaram para trás**, e continuavam imprimindo em toda
requisição sem sessão até 05/09/2026:

| Onde | O quê |
|---|---|
| `SessaoAcl::getAcl()` | três `return $acl['funcionalidades'][...]` indexando o nulo |
| `layout.phtml`, duas linhas | `$usuario['token']` e `$usuario['id']` |

Corrigidos com `?? null` e `?? ''`. **O valor devolvido não muda** — indexar
nulo já devolvia nulo; o que sai é o **aviso**, e com ele a chance de um 500
chegar ao navegador como 200. Medido depois: duas requisições sem sessão que
antes produziam três avisos agora produzem **zero**.

Isso não fecha a pergunta sobre produção — `display_errors` lá continua não
conferido —, mas tira do caminho as fontes de saída precoce que este projeto
conhece.

#### O que passou a guardar

O passo **"Rotinas de cron (nenhuma pode quebrar)"** no job de PostgreSQL do CI,
e `scripts/medir-cron-no-postgres.php` na máquina de quem desenvolve — o mesmo
roteiro que monta o banco para as telas mede as rotinas em seguida, sem repetir
dois minutos de montagem.

---

### D99 — Rota nova sob `/api` não aparecia como vermelho: aparecia como nada — e o portão que o fechou também não a via — CORRIGIDO em 13/09/2026

Medido em 05/09/2026, ao perguntar quanto da superfície congelada a suíte de
contrato realmente pede.

O **D5** diz o que está em jogo: `/api*` é servido a aparelhos instalados, que
ninguém atualiza em conjunto. A suíte congela essas respostas — **mas só as que
alguém lembrou de escrever**. Uma rota nova nasce fora do congelamento, e a
migração pode mudá-la sem que um único teste reclame.

#### Medido

| | |
|---|---:|
| rotas `/api`, fora as de cron | **37** |
| pedidas pela suíte | **29** |
| dispensadas com motivo | **8** |
| **sem contrato e sem decisão** | **0** |

As oito dispensadas são as páginas de `/api/docs`: elas desenham a documentação
da própria API, são para gente ler, e congelar o HTML delas seria congelar a
documentação. **Conferidas por execução, e não por argumento** — as cinco que
não pedem parâmetro respondem 200 sem exceção no corpo, contra o PostgreSQL.

#### A primeira medição disse 2, e estava errada

Um protótipo mais frouxo aceitava a rota como coberta quando o **último segmento
do caminho** aparecia em qualquer lugar do código dos testes. Com essa regra,
`/api/docs/aplicativo` passava porque a palavra `aplicativo` está em toda parte.

A versão que ficou tolera **duas** coisas, e só duas: parâmetros de rota
(`/:id`) e o sufixo de versão (`V2`, `V3`), que a suíte monta com `sprintf` e
por isso nunca aparece inteiro no arquivo. **Não há terceira tolerância, de
propósito** — cada uma é uma chance de o roteiro dizer "coberta" sobre algo que
ninguém pede.

#### O que ele guarda

`scripts/conferir-cobertura-contrato.php`, no `conferir-tudo.sh`. Posto à prova
declarando uma rota nova sem teste: sai com 1 e a nomeia; retirada, volta a
zero.

O caminho de saída é explícito na mensagem, e são dois: escrever o teste, ou
acrescentar a rota à lista de dispensadas **com o motivo**. Nunca só apagar o
vermelho.

#### O PORTÃO TINHA O MESMO DEFEITO QUE VIGIAVA — 13/09/2026

Ao revisar este débito para fechá-lo, o portão foi **falsificado** — e passou no
teste que não devia passar.

**Ele lia o arquivo de rotas como TEXTO.** Fatiava por `'route' =>` e só aceitava
o que **começasse** com `/api/`. Um `child_routes` do Laminas não começa: ele
guarda o pedaço (`'/:id/cupons'`) e herda o resto do pai. Medido:

| | |
|---|---:|
| caminhos que o portão via | 37 |
| caminhos que a árvore tem | **43** |
| **invisíveis** | **6** |

As seis: `/api/v4/sessoes/confirmacao` e a subárvore `/api/v4/clientes/:id/*`
inteira — `indicacoes`, `agenda`, `cupons`, `consumo`, `sorteios`.

**É o D99 ao pé da letra, dentro do portão que o registro dizia tê-lo fechado.**
Uma rota nova naquela subárvore não aparecia como vermelho: aparecia como nada.

#### E HAVIA UM SEGUNDO BURACO, no outro extremo

A cobertura era `strpos($testes, $caminho)` — substring puro —, e isso errava de
duas maneiras opostas:

- **Parâmetro no meio.** O caminho tinha `/:id` **apagado** antes da busca, então
  `/api/v4/clientes/:id/cupons` virava `/api/v4/clientes/cupons`, que teste nenhum
  pede — o teste pede `/api/v4/clientes/90001150/cupons`. Assim que a árvore
  passou a enxergar as seis, **as cinco com parâmetro apareceram como
  descobertas**, e eram falso positivo do próprio casamento.
- **Colisão por prefixo.** `strpos` acha `/api/agendas/getUnidade` **dentro** de
  `/api/agendas/getUnidades`, e as duas rotas existem. Apagando o teste da
  primeira, o portão continuava verde.

Agora `:param` vira curinga de **um segmento** e o fim exige
`(?![A-Za-z0-9_-])`. O contraste foi medido nos dois: com o teste de
`getUnidade/:id` apagado, a lógica antiga diz **verde** e a nova diz **vermelho**.

#### O CONTROLLER É HERDADO NA DESCIDA, e isso é carregante

Sem herdar `defaults.controller` do pai, as quatro rotas de cron que são filhas
(`/api/cupons/cuponsValidarVencimento` e as três de `/api/marketing`) perderiam a
marca de `CronJobController` e virariam **falso vermelho**. O `require` do config
funciona sem autoloader: `::class` resolve em tempo de compilação.

#### E ELE SÓ RODAVA À MÃO

O portão estava no `conferir-tudo.sh` e **não no CI** — que roda onze portões
nomeados, um a um. Quer dizer que ele só guardava quando alguém lembrava de
rodá-lo, que é a mesma classe de defeito que ele existe para vigiar. Entrou no
CI em 13/09/2026.

**Ressalva honesta:** o CI está parado desde 21:01 de 13/09 por cobrança da conta
(D187), então este passo **ainda não executou lá**. Foi rodado à mão, e a
falsificação também. *Cobertura escrita no CI não é cobertura executada.*

#### FALSIFICADO EM TRÊS SENTIDOS

| o que se estraga | o que ele faz |
|---|---|
| rota nova dentro de um `child_routes` | sai **1** nomeando `/api/v4/clientes/:id/faturas` |
| apaga-se o teste, a rota fica | sai **1** nomeando `/api/v4/clientes/:id/cupons` |
| colisão por prefixo (`getUnidade` vs `getUnidades`) | sai **1** — e o portão antigo saía **0** |

Depois do conserto: **43 rotas, 35 pedidas, 8 dispensadas, 0 novas.**

---

### D100 — Sete lugares imprimiam antes da resposta, e cada um podia transformar um 500 em 200 — ZERADO em 05/09/2026

Medido em 05/09/2026, depois de o mecanismo explicar três coisas de uma vez.

**O mecanismo.** Qualquer `Warning`, `Notice` ou `Deprecated` que o PHP emita vai
para a **saída**. Saída antes da resposta significa cabeçalho já enviado — e o
status que o framework definir depois não vale mais nada. A `f2-fatia-pdv.md`
mediu isso e o chamou pelo nome: *"a mentira que a Fase 2 existe para acabar"*,
com corpo dizendo "sem permissão" e resposta dizendo 200.

**O que ele explicou.** Três registros deste mesmo dia, que eu tinha escrito como
independentes:

| Registro | Sintoma |
|---|---|
| **D97** | o medidor de telas era cego na máquina de quem desenvolve |
| **D98** | duas rotinas de cron responderam 200 com página de exceção |
| — | o login do ERP, no D96, respondia 200 antes do conserto |

#### Medido, varrendo as 153 telas e as 22 rotinas

| Sítio | Avisos numa passagem |
|---|---:|
| `EstadoRepository:20` — `$dados` indefinida | **35** |
| `Cliente:434` — `explode(null)` | 8 |
| `ArquivoCategoriaRepository:21` — `$saida` indefinida | 6 |
| `Xls\Sorteio:45` — `ob_clean()` sem buffer | 4 |
| `Cliente:444` — `str_replace(null)` | 4 |
| `Cliente:423` — `explode(null)` | 4 |
| `Usuario:816` — `openssl_decrypt(null)` | 3 |
| **total** | **64** |

Depois dos consertos: **zero**, na mesma varredura.

#### Dois deles não eram só ruído

**`EstadoRepository::popularCombobox` devolvia `null`.** As duas primeiras não são
"variável não declarada" por estilo: sem nenhuma linha no resultado, o `foreach`
nunca roda, `$dados` nunca existe, e o `return` devolve **nulo em vez de lista**.
Quem recebe e itera emite mais avisos — o aviso vira cascata. Agora começa `[]`.

**`ob_clean()` no exportador de XLSX corrompia o arquivo.** Ali o aviso não sai
numa tela: sai **antes dos bytes do XLSX**, no meio de um download. O usuário
abre uma planilha corrompida e não tem como saber por quê. Agora só limpa buffer
que existe.

#### O que isto não resolve

`display_errors` em **produção** continua não conferido — se estiver desligado
lá, nada disso chegava ao usuário final, e o ganho é de diagnóstico. Se estiver
ligado, era defeito visível. **Não afirmo qual**, e a diferença entre as duas
frases é o que separa este registro de um diagnóstico inventado.

O que vale nos dois casos: as sete fontes de saída precoce que este projeto
conhecia deixaram de existir.

##### E não é conferível daqui — mas é conferível em dez segundos lá

Procurei em 05/09/2026: o único `php.ini` versionado é o
`docker/php/php.ini`, e a primeira linha dele diz *"Configuração de
DESENVOLVIMENTO. Não usar em produção"*. O valor de produção mora no servidor,
e nenhum arquivo deste repositório o conhece.

A pergunta se responde com um comando **somente de leitura**, no servidor:

```bash
php -i | grep -E '^(display_errors|display_startup_errors|error_reporting)'
```

E a resposta que se quer é `display_errors => Off => Off`. O segundo campo é o
valor local; os dois precisam estar desligados, porque um `ini_set` no código
mudaria só o primeiro.

**A exposição hoje é limitada, e vale dizer por quê.** O aviso só chega ao
usuário se existir aviso: são zero, e o zero é portão em duas frentes — o
medidor de telas reprova qualquer `Warning`/`Notice`/`Deprecated` no corpo
(D100), e o de bordas reprova qualquer byte antes do começo ou depois do fim de
um arquivo (D107/D108). O que `display_errors` decide é o que acontece com o
**próximo** aviso, o que vier de um dado que as fixtures não têm.

#### E o zero virou portão

`medir-telas-no-postgres.php` passou a contar **aviso impresso na resposta** como
falha, e não como sujeira. Posto à prova retirando o `$dados = []` do
`EstadoRepository`: a medição sai com 1 e nomeia as **20 telas** afetadas, com o
arquivo e a linha.

**O job do CI subiu a aplicação com `display_errors` LIGADO**, e isso parece
contraintuitivo num portão — é justamente o ponto. Com ele desligado o aviso
some, e com ele some a única pista de saída precoce; ligado, o aviso sai no corpo
e o medidor o pega. A detecção de erro não se perde, porque o medidor lê o corpo
desde hoje. De quebra, os dois ambientes passaram a medir a mesma coisa: é o
mesmo `php.ini` da máquina de quem desenvolve.

---

### D101 — A API da Fase 2 foi provada por `curl` uma vez, e nunca mais — CORRIGIDO em 05/09/2026

Medido em 05/09/2026, aplicando ao código **novo** a mesma pergunta que o D96 fez
ao antigo.

`docs/f2-fatia-pdv.md` registra a prova da fatia do PDV: *"Provado por `curl`
contra a aplicação de pé, com sessão real, e por teste nos dois bancos"*. As duas
provas valem — e **nenhuma se repete**:

| Prova | Por que não se repete |
|---|---|
| o `curl` | foi uma vez, por uma pessoa, num dia |
| `ComandaApiTest` | é caracterização: exercita **serviços** e formatos, e não faz requisição |

`grep -rn "adm/api" tests/` devolve **uma linha**, e é um comentário.

É a mesma forma de silêncio do D96 e do D99, com um agravante: **é o código que
a Fase 2 está construindo em cima.** Defeito aqui não é dívida herdada — é dívida
nascendo.

#### Medido: 11 endpoints, todos vivos

| | |
|---|---:|
| endpoints em `/adm/api` (verbo + caminho) | **11** |
| que quebram no PostgreSQL | **0** |

As respostas são as do negócio — 200, 404, 422, 409 —, que é o que se espera de
chamadas sem contexto. O que a medição procura é **quebra**: 5xx ou exceção no
corpo, pelo mesmo critério do medidor de telas.

#### Ele escreve, e é por isso que só roda no banco descartável

Nove dos onze mudam estado. Medi-los sem escrever seria medir outra coisa. A
objeção que o **D61** levanta contra a suíte de contrato — *"deixa de ser
repetível"* — não alcança aqui: nada é comparado com instantâneo, e o banco nasce
das fixtures a cada execução.

#### A lista veio da configuração como ARRAY, não por expressão regular

`module.config.php` é um array PHP: `require` devolve a estrutura pronta, e as
rotas aninhadas (`Literal` → `Segment` → `Method`) se percorrem sem ambiguidade.

Cheguei nisso depois de duas tentativas piores: caminhar pelo roteador do Laminas
por reflexão (que não alcançou as rotas-filhas) e ler por expressão regular — que
no medidor de cron já tinha casado o `'route'` de um bloco com o controlador de
outro.

#### O portão oscilou antes de valer, e isso quase passou

A primeira versão usava `CURLOPT_COOKIEJAR` para carregar a sessão, e o resultado
foi **instável**: a mesma sequência devolvia 200 numa execução e **403 nas onze**
na seguinte, com o login respondendo `sucesso: true` nas duas.

**E o relatório dizia "nenhum endpoint quebrou" nas duas.** Um 403 não é 5xx: a
medição passava, verde, medindo recusa em vez de endpoint — exatamente a
armadilha que o D97 documentou para as telas negadas pelo ACL.

O cookie de sessão do PHP não tem validade, e o que o cURL faz com esses num
arquivo compartilhado entre alças depende de detalhe de versão. Ler o
`Set-Cookie` da resposta do login e reenviá-lo em `CURLOPT_COOKIE` tira a
dependência: o que vai no cabeçalho é exatamente o que voltou. Duas execuções
seguidas passaram a dar o mesmo resultado.

---

### D102 — Uma barra invertida virou byte de controle, e a expressão parou de casar — CORRIGIDO em 05/09/2026

Achado em 05/09/2026, ao reler o medidor de telas por outro motivo.

O medidor tinha esta linha, que **a olho nu está certa**:

```php
$limpo = preg_replace('~<(script|style)\b[^>]*>.*?</\1>~is', ' ', $corpo);
```

No arquivo, porém, não havia `\b` nem `\1`: havia os bytes **0x08** e **0x01**.
São o rastro de quem gravou a linha por uma ferramenta que interpretou as barras
invertidas antes de escrever — o editor desenha o padrão como se estivesse
certo, e o byte não tem forma.

#### Nada acusa, e é isso que custa

| quem poderia acusar | por que não acusa |
|---|---|
| `php -l` | byte de controle dentro de literal é PHP válido |
| o editor | não tem glifo para 0x08 |
| o próprio portão | **expressão que não casa nada não dá erro** |

A última linha é a que importa, e é a mesma forma de falha do D4 e do D100: o
sistema respondendo bem por não ter feito o trabalho.

#### O que estava desligado

1. **A limpeza de `<script>` em `excecaoNoCorpo` nunca rodou.** O comentário ao
   lado dizia que ela era a correção dos 146 falsos positivos da primeira
   medição. Não era: o que os tirou foi o **estreitamento das agulhas**. O
   comentário explicava errado uma proteção inexistente — e o próximo a
   acrescentar uma agulha larga confiaria nela.

2. **Uma agulha de `motivoDoErro` estava morta.** `(?:Exception|Error)\b` exigia
   um backspace literal depois de `Error`. É justamente a agulha de última
   instância: sem ela, a explicação impressa para uma tela quebrada cai no
   trecho bruto da página — `"App | Barbearia VIP (function() { try { ..."`, que
   foi a reclamação que originou o comentário.

3. **A limpeza faltava inteira em `motivoDoErro`**, que é quem *imprime* a
   explicação. A correção tinha ido para as funções vizinhas.

Três funções faziam a mesma limpeza de três jeitos; hoje fazem uma só,
`textoDaPagina()`.

#### Um terceiro caso, na documentação

A varredura achou o mesmo estrago em `docs/ambiente-homologacao.md:228`, num
comando que o leitor é instruído a rodar:

```bash
ss -tlnp | grep -E ':(8080|8081|8025|3307)\b'
```

O `\b` era um 0x08. Quem copiasse a linha procuraria porta seguida de backspace,
e não acharia nada — sem erro nenhum.

#### O portão

`scripts/conferir-bytes-de-controle.php` varre os arquivos de texto versionados
— 1.043 deles — e reprova qualquer byte da faixa de controle fora de tabulação,
quebra de linha e retorno de carro. Conferido reintroduzindo o byte: o portão
acusa arquivo e linha.

As bibliotecas empacotadas do painel (`jszip`, `pdfmake`) ficam de fora com
motivo escrito: elas guardam dado comprimido dentro de literal, e um 0x1B ali é
conteúdo. A lista de extensões é **positiva** — dizer quais são texto é uma
decisão que se lê; perguntar "é binário?" é adivinhar.

#### E a contagem saiu do cabeçalho do `conferir-tudo.sh`

O comentário dizia "VINTE roteiros" com 24 linhas abaixo. Já tinha dito "treze"
com dezenove, foi corrigido, e errou de novo na semana seguinte. O número saiu;
ficou a receita que o responde:

```bash
grep -cE '^rodar |^rodar_conhecido ' scripts/conferir-tudo.sh
```

#### O portão reprovou este próprio registro, dez minutos depois

Ao gravar o texto acima, **as sete ocorrências de barra invertida viraram bytes
de controle outra vez** — nas duas linhas de código citadas, na menção a
`(?:Exception|Error)` e no comando de porta. O portão acusou arquivo e linha, e
o documento foi reescrito.

Isso encerra a dúvida sobre a origem: não foi um editor de uma vez só nem um
descuido isolado. **É a ferramenta de escrita, e ela repete o estrago sempre
que uma barra invertida atravessa.** Um portão que só olhasse os `scripts/`
teria deixado passar — a varredura vale sobre todo texto versionado por isso.

---

### D103 — "Nenhuma tela quebrou" eram dois quintos das telas, e o número não estava em lugar nenhum — CORRIGIDO em 14/09/2026: o denominador e o piso saem impressos, e o piso estava 35 telas atrás do alcance

O medidor de telas (D97) dizia, a cada execução:

```
  telas visitadas: 153
  Nenhuma tela respondeu erro do servidor, e nenhuma imprimiu aviso.
```

**153 de quantas?** O cabeçalho do próprio roteiro respondia em palavras — *"é
piso, não teto"* —, e palavra não se confere. O denominador nunca foi impresso.

#### Medido: 117 de 252 — remedido em 14/09/2026

| | |
|---|---:|
| ações de tela no inventário, fora rotina, API do aplicativo, mortas **e os 43 nomes que a varredura não pede de propósito** | **252** |
| alcançadas pela varredura | **117** |
| | **46%** |

**O rótulo do denominador omitia uma quarta exclusão**, e ela não é pequena. Além
de rotina, API do aplicativo e mortas, o código descarta tudo que casa com
`BLOQUEADAS` — `excluir`, `remover`, `cancelar`, `enviar`, `migrar-senhas` e
afins —, porque são ações que a varredura **não pode pedir**. Medido lendo o
padrão do próprio roteiro: as ações de tela vivas são **295**, dessas **43** são
bloqueadas, e sobram os 252. Então o alcance contra tudo que é tela é **39,7%**,
e não 46%. Os dois números são verdadeiros; o que estava errado era publicar só
o maior sem dizer sobre o quê.

> Os primeiros números medidos foram **82 de 251, 33%**, e estavam errados: o
> cruzamento usava o nome da classe onde a URL usa um alias, e as 23 telas de
> relatório que a varredura alcança contavam como não alcançadas. Corrigido no
> D104, junto com a linha do "não reconhece", que caiu de 24 para 3 — era ela
> o alarme, e foi ela que denunciou.

Os **203** caminhos visitados viram 120 pares controlador/ação distintos: 117 do
inventário e 3 que ele não reconhece — ação que o denominador exclui de
propósito, ou URL com id no fim, que é a mesma ação outra vez. O resto são
endereços repetidos com parâmetro diferente. As sementes lidas do menu são 58.

O que não é alcançado se concentra:

| controlador | ações fora |
|---|---:|
| `AgendasController` | 16 |
| `ClientesController` | 16 |
| `UsuariosController` | 11 |
| `PesquisasController` | 7 |
| `ProdutosController` | 6 |
| `VipClassAdminController` | 5 |
| `SaidasController` | 5 |
| `NotificacoesController` | 5 |
| ... e mais 29 controladores | |

São telas que dependem de um passo antes — abrir um cliente, escolher uma data
—, e a varredura começa no menu e segue um nível de links.

#### O número vale mais como piso do que como meta

`COBERTURA_MINIMA = 117` desde 14/09/2026. A varredura já falhou por baixo uma
vez, e de um jeito que não dava alarme: **a primeira versão visitou UMA tela e
disse "nenhuma quebrou"**, porque as sementes vinham de um lugar sem link
nenhum. Um medidor que deixa de alcançar dá a resposta mais tranquilizadora
possível — e a contagem de telas é a única coisa que denuncia isso.

#### O piso ficou nove dias parado, e o comentário ao lado dizia que isso não acontecia

Era **82**, e o alcance era **117**. A varredura podia perder **35 telas — 30% da
superfície que ela alcança** — e a corrida sairia verde, ainda imprimindo
"nenhuma tela respondeu erro do servidor". Era exatamente a falha silenciosa que
o parágrafo acima diz que o piso existe para denunciar.

**E o 82 nem era o número certo:** saiu do cruzamento por nome de classe, que o
D104 mostrou estar errado. O comentário ao lado da constante dizia, com todas as
letras, *"subi-lo acontece sozinho quando a varredura alcança mais"* — e **não
acontece**: `git log -S COBERTURA_MINIMA` devolve **um único commit**, o de
05/09/2026. Nove dias, o alcance subindo de 82 para 117, e a constante parada.
Comentário que promete uma proteção inexistente custa o mesmo que nome que mente
— é o D46 desta mesma noite, outra vez.

**117 é medido três vezes, e o alcance é determinístico:** a última corrida verde
do CI, em 13/09, e duas corridas locais em 14/09 devolveram **117 de 252, com 203
telas visitadas**, as três.

**Por que o piso não carrega folga.** A única variação já observada foi a corrida
de 06/09/2026 em que o alcance caiu para 76 porque a rede derrubou um quinto dos
pedidos. Essa classe de corrida hoje **sai com 2 antes de chegar ao piso**, pelo
guarda de HTTP 0 — ela se recusa a concluir em vez de reprovar por cobertura.
Então o piso não precisa mais absorver ruído de rede.

**E o piso passou a ser impresso na linha da cobertura:**

```
cobertura: 117 de 252 acoes do inventario (46%)   [piso: 117]
```

Uma palavra a mais, e a defasagem aparece em **toda** corrida verde — não só no
dia em que alguém for ler a constante. É o que teria denunciado este débito
sozinho.

**Falsificado de ponta a ponta, com a cadeia real:** piso em 118, código de saída
**1** e a mensagem `A COBERTURA CAIU: 117, e o piso e 118`; piso de volta em 117,
código **0**. Não é arnês nem simulação: é o roteiro inteiro, contra o PostgreSQL
montado do zero.

Conferido nas duas direções, e refeito em 14/09/2026 contra o piso novo: com o
piso em 118 o roteiro sai com 1 e diz o que caiu; de volta em 117, sai com 0.

#### E o alarme de mapeamento

A linha *"visitadas que o inventário não reconhece: 3"* fica impressa de
propósito. Se o formato dos caminhos mudar, os 103 caem para perto de zero e os
3 sobem — e sem essa segunda linha, a queda pareceria defeito do sistema em vez
de defeito da medição.

**Foi exatamente assim que o erro do D104 apareceu.** Ela marcava 24, alto
demais para "URL com id no fim", e puxar esse fio mostrou que 23 telas de
relatório estavam sendo cruzadas pelo nome errado.

#### Não é meta de cobertura

Subir de 41% pede encadear passos — abrir um registro, seguir para a tela que só
existe depois dele —, e isso é teste, não varredura. O valor deste registro é
outro: **o "nenhuma quebrou" agora vem com o tamanho do que ele não olha.**

---

### D104 — A consulta pesada do sistema nunca tinha sido pedida — MEDIDO em 05/09/2026 — FECHADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como medido, e o corpo diz que ele está fechado, sem nada a fazer. O corpo: "Medido: 31 relatórios, nenhum quebra".

O medidor de telas alcança a tela de **filtro** do relatório: a página com os
campos de data e unidade. A consulta roda quando alguém aperta *gerar* — numa
ação que devolve PDF, XLS ou TXT, e que nenhuma medição chamava.

É onde os dois bancos mais diferem. Relatório é agregação com data: `GROUP BY`,
`SUM`, função de data, ordenação por coluna anulável. O D24 achou `GROUP BY` que
o PostgreSQL recusa, o D46 ordenação que muda de significado, o D81/D82 tipo
trocado — **nenhum deles num relatório, porque nenhum relatório era pedido.**

#### Medido: 31 relatórios, nenhum quebra

| | |
|---|---:|
| ações do módulo, por GET | **31** |
| que quebram no PostgreSQL | **0** |

Uma ação ficou de fora e o medidor imprime qual: `ConsultorVisagista::gerarPdf`
faz `find(fromRoute('id'))`, e sem id o Doctrine recebe nulo e a requisição
morre com 500. Essa URL não existe na tela — só a medição a inventou.

#### O alcance da medição, e o limite dela

O cenário de fixtures é pequeno — **5 vendas, 7 clientes, 3 agendamentos** —, e
isso levanta a pergunta certa: relatório que roda sobre quase nada prova alguma
coisa?

Prova a classe que mais importa aqui, e dá para demonstrar. As duas
incompatibilidades que a migração vem encontrando são recusadas pelo PostgreSQL
**no planejamento, antes de olhar linha nenhuma** — conferido contra tabelas
vazias:

```
CREATE TEMP TABLE vazia (a int, b int);
SELECT a, b FROM vazia GROUP BY a;
ERROR:  column "vazia.b" must appear in the GROUP BY clause ...   (D24)

CREATE TEMP TABLE vazia2 (ativo boolean);
SELECT * FROM vazia2 WHERE ativo = 1;
ERROR:  operator does not exist: boolean = integer               (D81/D82)
```

O que ela **não** alcança é o que depende de dado: ordenação por coluna anulável
(D46), divisão por zero, agrupamento por mês que só aparece com meses
diferentes, e a linha sorteada do D72 — que só muda de resposta quando há mais
de uma candidata.

Para essa segunda classe o instrumento é outro, e já existe:
`conferir-migracao.php`, que compara os **dois bancos carregados da mesma
origem**. Está fora do `conferir-tudo.sh` de propósito, porque leva dez minutos
e é portão de corte.

#### Duas vezes o medidor quase inventou um defeito

Este registro vale mais pelos dois erros do que pelo resultado. Os dois davam
vermelho convincente, e nenhum era do sistema.

**1. O pedaço de URL não é o nome da classe.** Os cinco módulos usam a rota
curinga `/[:controller[/:action]]`, e o `:controller` é a chave do mapa
`aliases` do `Module.php`. Nos cadastros ela se parece com o nome
(`clientes` → `ClientesController`); nos relatórios, não:

| na URL | classe |
|---|---|
| `rel-aniversariantes` | `AniversariantesController` |
| `rel-consultor-visagista` | `ConsultorVisagistaController` |
| `vip-class-relatorio` | `VipClassRelatorioController` |

Deduzir pelo nome falhou de duas maneiras, e a segunda é a perigosa:

- `/mod-relatorios/aniversariantes/index` → **404**, idêntico a uma tela negada
  pelo ACL;
- `/mod-relatorios/vendas/index` → **500**, e eu quase o registrei como defeito
  de relatório. Não era relatório nenhum: o alias `vendas` existe no
  `ISVenda/src/Module.php`, e a URL servia o controlador de vendas de outro
  módulo.

O mapa agora sai do próprio `Module.php`, em `scripts/segmentos-de-controlador.php`.

**2. A data ia no formato que a tela nunca manda.** `DataHora::stringDate()` não
interpreta data: recorta por **posição** — `$d[6..9]` é o ano, `$d[3..4]` o mês,
`$d[0..1]` o dia. Mandando `2023-01-01` ela produz a string `1-01-3--20`, e o
PostgreSQL responde *invalid input syntax for type date*.

**O relatório saiu com DOZE quebrados, todos com essa mensagem.** Escrito sem
conferir, o registro teria dito "doze relatórios quebram no PostgreSQL" — e a
frase teria sobrevivido a revisão, porque parece exatamente com o D81. Com
`dd/mm/aaaa`, os doze passam.

`fluxo-movimento` é a exceção: os campos dele são mês/ano, e o controlador parte
a string por `-`. O formato dele vem à parte, e não por adivinhação.

**E OS DOIS BANCOS FALHAM DE FORMAS DIFERENTES, medido em 12/09/2026.** O parágrafo
acima conta o que o PostgreSQL faz — *invalid input syntax for type date*, alto e na
hora. O MySQL, com a **mesma** string inválida, não reclama: ele devolve **zero
linhas**. Medido ao chamar `selecionarGorjetasPorColaboradorPeriodo` com data ISO
para um colaborador que tem 383 gorjetas na base — veio `quantidade => 0,
total => NULL`, sem erro nenhum.

A consequência é contraintuitiva e vale dita: **a migração torna esta classe de
defeito mais visível, não mais perigosa.** Hoje um relatório com data no formato
errado mostra uma tela vazia que parece "não houve movimento"; depois do corte ele
estoura e alguém descobre. São 64 chamadas de `stringDate` no sistema.

#### Isso corrigiu o número do D103

O mesmo mapa de segmentos era usado para cruzar o inventário com o que a
varredura visita. Como os relatórios entravam com o nome da classe, as **23
telas de relatório que a varredura alcança** contavam como não alcançadas:

| | antes | depois |
|---|---:|---:|
| cobertura | 82 de 251 | **103 de 253** |
| visitadas que o inventário não reconhece | 24 | **3** |

A linha do "não reconhece" era o alarme, e funcionou: 24 é grande demais para
"URL com id no fim".

#### E um `require` que apagou uma variável de quem o chamou

O mapa é incluído por `require`, que **compartilha o escopo de quem chama**. A
primeira versão percorria os arquivos com `foreach (... as $arquivo)`, e o
medidor de telas tinha um `$arquivo` seu — o cursor do CSV que estava lendo.
Depois do `require` ele valia o caminho do último `Module.php`, e o `fgetcsv`
seguinte morreu com *"must be of type resource, string given"*. O corpo mora
dentro de uma clausura por causa disso: sai o mapa, e só ele.

---

### D105 — Um item de menu levava a lugar nenhum, e o portão que nasceu disso lia 15% dos links — PARCIAL em 14/09/2026: cinco mortos vivos consertados, seis esperando decisão — PARCIAL em 05/10/2026 — DECIDIDO em 15/10/2026 pela resposta 120, e PARCIAL: os seis mortos esperam as fatias que lhes dão destino — PARCIAL em 20/10/2026: o ativar dos banners — PARCIAL em 21/10/2026: o editar dos sorteios — PARCIAL em 25/10/2026: a fidelização saiu — PARCIAL em 06/11/2026: o exportar dos pacotes — PARCIAL em 06/11/2026: a validade e o total de sessões na planilha dos pacotes (316 b)

> **05/10/2026:** os cinco consertos foram medidos por HTTP no banco de contrato, nos dois bancos, e o da evasão estava pela metade — a tela certa, sem a mensagem; consertado (a seção "Os cinco destinos por HTTP", no fim deste débito). Falta o que já faltava: os seis de `MORTOS_CONHECIDOS`, que esperam decisão.

O menu monta cada item com
`url('<modulo>-admin/default', ['controller' => ..., 'action' => ...])`, e o
`controller` ali é a chave do mapa `aliases` do `Module.php`. **Nada confere se
a chave existe.**

Sem alias, o item responde 404 — e o 404 de rota é *idêntico* ao 404 que o ACL
devolve para tela negada. Foi por isso que passou despercebido: a medição de
telas lista os 404 como informação, sob o rótulo *"ACL negando, ou link para
lugar nenhum"*, e com os dois casos misturados ninguém separa um do outro.

#### O portão que nasceu deste débito guardava 15% do que o nome prometia

Ele se chamava `conferir-menu-morto.php` e lia **um arquivo**: o `layout.phtml`.

| | |
|---|---:|
| chamadas `url('<mod>-admin/default', ...)` no ERP | **413** |
| dentro do `layout.phtml` — as únicas que o portão via | **62** |
| **sem portão nenhum** | **351** |

E o diagnóstico deste débito nunca foi sobre *menu*: é sobre link que aponta
para o nada, e isso vale para todo link do ERP. O nome estreitou o portão até
ele caber no exemplo que o originou.

Alargado para as 413 chamadas mais os 33 caminhos escritos à mão
(`'/mod-<prefixo>/<alias>/<ação>'`, que `url()` não acha), ele achou **cinco
links mortos visíveis** que ninguém via. Nenhum estava escondido:

| onde | apontava para | é |
|---|---|---|
| `saidas/parcelas.phtml:14` — botão "Identificação" | alias `cadastrar` | não existe em módulo nenhum; o do ISFinanceiro é `saidas` |
| `pesquisas-nps/index.phtml:246` — botão "Gerar TXT" | alias `pesquisasnps` | o alias é `rel-pesquisas-nps` |
| `AtendimentosController.php:78` | `/mod-cadastros/atendimentos/index` | módulo errado **e** alias inexistente |
| `EvasaoController.php:75` | `/mod-relatorios/evasao/index` | o alias é `rel-evasao` |
| `AgendasController.php:75` — **a tela inicial** | `/mod-relatorios/vendas/index` | o alias é `rel-vendas` |

Os cinco foram corrigidos. Três deles merecem nota.

**A tela inicial mandava um grupo inteiro para o 404.**
`AgendasController::indexAction` é o que responde `/`. Quem tem `grupo == 9`
— "Relatório vendas", 1 usuário na cópia — era redirecionado ao entrar no
sistema para uma rota que não existe. O grupo existe *para* essa tela.

**Os dois redirecionamentos de cache vazio caíam no caminho comum.** Quando o
cache do cron não existe, `Atendimentos` e `Evasão` escrevem "Dados
indisponíveis." e redirecionam. O usuário **não lê a mensagem** — ele vê 404. E
o registro já conta, no D98, que "os demais caches do cron continuam saindo
`[]`": o caminho de cache vazio não é o excepcional.

**Um sexto caso foi corrigido e vale menos do que parece.**
`VendasPagamentosController:105` devolvia `/mod-vendas/vendas/comandasAbertas`
em `$result['href']`, e `VendasController` tem `indexComandasAction`. Mas
`mod-vendas.js:710` lê `response.tipo` e monta o caminho por conta própria, já
correto — **`response.href` nunca é lido**. O conserto impede que um futuro
leitor siga um caminho morto; ele não curou um 404 vivo, e dizer o contrário
seria inflar o resultado.

#### O mapa de que o portão depende era cego, e a cegueira produzia defeito inventado

`scripts/segmentos-de-controlador.php` aceitava só `'alias' => 'Destino'` com
aspas dos **dois** lados, e afirmava em comentário que `::class` só aparecia em
par de serviço. Era falso: `ISCadastro/src/Module.php:72` declara
`'blacklistAgenda' => Controller\BlacklistAgendaController::class`.

Era **exatamente 1** controlador, e por isso nunca incomodou: nenhum item do
menu aponta para lá, e o palpite `lcfirst()` do medidor de telas reproduz
`blacklistAgenda` por acaso. Mas as três chamadas a `blacklistAgenda` em
`clientes/index.phtml` e `blacklist-agenda/index.phtml` viravam **três links
mortos falsos** no primeiro portão a varrer o ERP inteiro — medido, não suposto
(falsificação F6 abaixo).

**Mapa cego não devolve silêncio: devolve defeito inventado.** É o mesmo erro
que a seção "Como isto apareceu" deste débito já confessava — ler só o primeiro
bloco `aliases` —, agora na outra dimensão.

#### Duas naturezas, e dizê-las com palavras diferentes é o ponto

O portão separa **SEM ALIAS** (ninguém registrou a chave) de **SEM AÇÃO** (o
alias existe, o método não). As duas dão o mesmo 404 na tela, e misturá-las é o
defeito que este débito registra. A segunda natureza era hipótese até o portão
alargar; hoje existem quatro casos dela.

#### O que fica, e por que cada um espera uma decisão

Seis entram em `MORTOS_CONHECIDOS`, cada um com o motivo escrito. **Três são a
mesma história — alguém escondeu em vez de remover:**

- `fidelizacao|index` — o item de menu, `<li class="display-none">`;
- `pacotes|exportar` — botão "Exportar", dentro de `<div class="btn-group display-none">`;
- `usuarios|contrato` — botão "Gerar Contrato", `<a style="display: none;">`.

Enquanto estiverem escondidos ninguém clica, e por isso podem esperar. **O
portão vigia o esconderijo**, e não apenas o anota: se a marca sumir, ele fica
vermelho no mesmo instante, porque a justificativa ("ninguém clica") caducou.

**Dois estão visíveis, e nenhum tem conserto óbvio:**

- `appbanners|ativarRemover` — a tela tem **dois** botões apontando para lá, um
  de desativar e outro de **ativar**. O vizinho `PacotesController` tem
  `ativarRemoverAction`, que alterna o status; o `AppBannersController` só tem
  `removerAction`, e ela faz `delete()`. **Apontar o botão de ativar para o de
  apagar trocaria um 404 por perda de dado.** Falta escrever a ação.
- `sorteios|editar` — não existe ação de editar sorteio, e `novoAction` é só
  inserção: não lê `id`. Ou a ação é escrita, ou a data deixa de ser link.

E o sexto, `fidelizacao|pdf`, é o mesmo alias morto por outra porta: o
`ISRelatorio/src/Form/Fidelizacao.php`, órfão — nenhum `new Fidelizacao` em
lugar nenhum. Ele anda junto com a decisão sobre o item de menu.

#### A falsificação: sete defeitos reintroduzidos, sete vermelhos

Cada arquivo foi restaurado byte a byte da cópia gravada antes (sha1 + tamanho
conferidos, `git status --porcelain` vazio depois de cada um).

| | o que voltou | esperado | obtido |
|---|---|---|---|
| F1 | `pesquisasnps` de volta no `index.phtml` | 1, **SEM ALIAS** | 1, `ISRelatorio\|pesquisasnps\|txt` |
| F2 | ação `indexx` num item vivo do menu | 1, **SEM AÇÃO** e *não* "sem alias" | 1, e as duas condições valem |
| F3 | ordem `action`/`controller` invertida | **2** (guarda de leitura) | 2, *"LI 413 E ENTENDI 412"* |
| F4 | alias `fidelizacao` passando a existir | 1, "CONSERTADOS" | 1, os **dois** conhecidos apontados |
| F5a | `class="display-none"` fora do menu | 1, esconderijo | 1, com o arquivo nomeado |
| F5b | `style="display: none;"` fora de `editar.phtml` | 1, esconderijo | 1 — a vigia não está grudada num caso só |
| F6 | mapa do HEAD (cego a `::class`) | defeito inventado | 1, **3 mortos falsos** |
| F7 | piso em 500 | 2 (varredura encolheu) | 2 |

O F3 é o que o portão anterior não tinha: ele só reclamava se lesse **zero**
itens. Ler 30 dos 62 era "ok" — verde por ausência de dado.

#### O que esta prova NÃO afirma

**Não observei os 404 por HTTP.** Sem sessão, o ERP serve a mesma página de
login (6.076 bytes, HTTP 200) para rota válida, rota inválida e alias
inventado — o roteamento nem chega a rodar, medido nos quatro casos. E
`scripts/definir-senha-contrato.php`, o mecanismo da casa para dar senha ao
usuário de fixture, **recusa rodar** contra o banco de desenvolvimento por
desenho (ele conta `unidades` e aborta acima de 10). A prova aqui é estática —
leitura direta dos `Module.php` e dos controladores — mais as sete
falsificações. Quem tiver o ambiente de contrato de pé pode fechar essa lacuna
com cinco requisições.

**E o portão não cobre tudo**, o que está escrito no cabeçalho dele: 2 chamadas
`url($rota, ...)` com a rota em variável; 136 chamadas de rotas **nomeadas**
(essas o `conferir-rotas-orfas.php` cobre, e pelo outro lado — ele parte da
rota declarada e procura a ação); e url montada em JavaScript por concatenação,
que o `mod-vendas.js` faz em três lugares.

#### Como isto apareceu

Não foi procurando. Ao montar o mapa de segmentos do D104, a pergunta natural
era se todo item do menu resolvia — e a primeira resposta foi **22 de 59 sem
alias**, o que estava errado: o mapa lia só o *primeiro* bloco `aliases` de cada
`Module.php`, e o `ISConfiguracao` tem dois — o de serviços em cima, o de
controladores embaixo. Dezoito controladores ficavam fora do mapa sem nenhum
aviso.

**Um número absurdo é mais fácil de investigar que um número plausível.** Se o
mapa tivesse errado só um ou dois, a lista de "mortos" teria parecido crível e
entrado no registro. Foi o que quase aconteceu de novo em 14/09 com o
`blacklistAgenda`: três, e não dezoito.

#### O portão

`scripts/conferir-links-mortos.php` — renomeado de `conferir-menu-morto.php`,
porque o nome antigo passaria a mentir. Ligado em `scripts/conferir-tudo.sh`
(linha "links mortos") e em `.github/workflows/ci.yml`. Sai com **1** para morto
novo, conhecido que sara, ou esconderijo que some; com **2** para leitura
divergente ou varredura abaixo do piso de 380.

Em 14/09/2026: 413 chamadas lidas, 413 entendidas, 6 conhecidos, 0 novos. Os 37
portões de `conferir-tudo.sh` passaram (o único vermelho é o conhecido do D50,
que espera produção), e os quatro que só existem no CI foram rodados à mão,
porque o CI está parado por cobrança desde 13/09 (D187).

#### Os cinco destinos por HTTP — 05/10/2026, e o da evasão estava pela metade

A lacuna de "O que esta prova NÃO afirma" — *"quem tiver o ambiente de contrato de pé
pode fechar essa lacuna com cinco requisições"* — virou o ensaio
`scripts/ensaiar-links-consertados-do-erp.php`. O impedimento era o 404 do ACL, idêntico
ao da rota; o ensaio o tira do caminho **dando o privilégio**: põe no gerente do
contrato as cinco funcionalidades das telas de destino (e o grupo 9 no caso 5), entra, e
em cada caso segue o link que o **servidor** emite — o `href` da página ou o `Location`
do redirecionamento, sem destino escrito no ensaio — e pede o link **morto** de antes do
conserto na mesma sessão. O 200 com a marca da tela de um lado, e o 404 do outro, com o
privilégio dado, só podem ser a rota. Escreve só no banco de fixtures (recusa acima de
10 unidades), devolve tudo num `finally` — também quando para no meio, ver abaixo — e
confere o resíduo; as sessões saem pelo "Sair".

| caso | o link que o servidor emite | o destino | o morto, na mesma sessão |
|---|---|---|---|
| 1 | os **dois** "Identificação" das parcelas (o caminho no topo e a aba) | 200, a tela da despesa | `/mod-financeiro/cadastrar/...` 404 |
| 2 | o "Gerar TXT" do NPS, depois de a tela carregar os clientes de uma pesquisa | 200, `clientes.txt` | `/mod-relatorios/pesquisasnps/txt` 404 |
| 3 | o `Location` do cache vazio dos atendimentos | 200, o relatório **e** "Dados indisponíveis." | `/mod-cadastros/atendimentos/index` 404 |
| 4 | o `Location` do cache vazio da evasão | 200, o relatório **e** "Dados indisponíveis." | `/mod-relatorios/evasao/index` 404 |
| 5 | o `Location` de `/` para o grupo 9 | 200, o relatório de vendas | `/mod-relatorios/vendas/index` **500** |

**O caso 4 ficou vermelho na primeira rodada:** o redirecionamento chegava à tela certa,
e a tela não dizia nada. `evasao/index.phtml` nunca desenhou o parcial das mensagens
(`partials/notificacoes`) — desde a importação do ERP —, e o `indexAction` já as
entregava. O que este débito dizia do caminho do cache vazio, *"o usuário não lê a
mensagem — ele vê 404"*, ficou meio verdade depois de 14/09: o 404 saiu, e a mensagem
continuava sem aparecer. O parcial entrou na tela, no lugar em que a de atendimentos o
tem. Só esses dois relatórios guardam mensagem (`setMensagem` no `ISRelatorio` inteiro:
duas chamadas, uma em cada), então não há irmão.

**O caso 5 corrige a natureza escrita na tabela de cima.** O alias `vendas` **existe**
— é o do PDV, no `ISVenda`, e os aliases do gerenciador de controllers valem em todos os
prefixos (medido: o roteador casa `/mod-relatorios/vendas/index` com `vendas::index`, e o
gerenciador cria `ISVenda\Controller\VendasController`). Ele não tem `indexAction`; o
padrão do Laminas responde com um molde que não existe, e o pedido morre em 500 (no
PostgreSQL de contrato a página mostra a `RuntimeException` do `PhpRenderer`). Era "sem
ação", e um 500, e não "sem alias" e um 404 — o grupo 9 via "An error occurred". O mesmo
500 vale para `/mod-vendas/vendas/index`, e é de uma classe: o D424. O portão continua
certo em chamar o link de morto — ele exige o alias no módulo do prefixo, que é mais
estrito que o Laminas.

**Provado, nos dois bancos:** o ensaio com 5 de 5 no MySQL de contrato (montado por
`montar-banco-contrato.sh`) e no PostgreSQL de contrato, resíduo zero nos dois. E em
processo, para o CI, que não roda o ensaio: `D105CacheVazioDosRelatoriosTest`, 2 casos
(a exportação com o cache ausente, o destino do redirecionamento e a tela renderizada
com a mensagem), verdes nos dois. **Seis mutantes, um de cada vez, restaurados pelo
sha1, todos vermelhos:** cada caminho de volta ao de antes — o "Identificação" do topo
(o caso 1, com um dos dois links levando ao 404), o "Gerar TXT" (o caso 2), o destino
dos atendimentos e o da evasão (os casos 3 e 4 do ensaio e o caso em processo de cada
um), a tela inicial do grupo 9 (o caso 5: o destino dá 500) — e o parcial arrancado da
tela da evasão (o caso em processo; o ensaio já o tinha mostrado vermelho antes do
conserto).

**O `finally` só valia no caminho feliz (revisão de 05/10/2026).** A primeira versão do
ensaio saía por `exit(2)` dentro de `pedir()` (HTTP 0) e de `entrar()` (login recusado),
as duas chamadas **depois** das primeiras escritas — e em PHP o `exit` não roda o
`finally`, a regra que o repositório já tinha escrito nos ensaios da API desde 09/09.
Medido no MySQL de contrato, contra um endereço que não responde: saída 2, e ficaram no
banco os cinco privilégios dados ao gerente (de 10 para 15 linhas) e a pesquisa
90008200 — a rodada seguinte recusava começar, e os outros ensaios passariam a medir o
gerente com o privilégio de saídas. E o próprio `finally` abria com o "Sair", que é HTTP:
com o servidor caído, a falha dele pulava a devolução inteira. Agora as duas **lançam**,
o `catch` guarda o motivo e o fim sai com 2; o banco volta **antes** do "Sair", e cada
"Sair" (as duas sessões, no caso 5) tem `try` próprio e só avisa. **Dois mutantes, um de
cada vez, restaurados pelo sha1, vermelhos:** o `exit(2)` de volta em `pedir()`, com o
endereço que não responde, e de volta em `entrar()`, com o servidor de pé e a senha
errada de propósito — nos dois, 15 privilégios e a pesquisa depois da rodada; com o
conserto, saída 2, 10 privilégios e nenhuma pesquisa. A ordem do "Sair" depois do banco
ficou provada só por leitura: ninguém derrubou o servidor entre o login e o fim de uma
rodada. Depois do conserto, o ensaio inteiro de novo: 5 de 5 e resíduo zero no MySQL e
no PostgreSQL de contrato (30 s).

**O ensaio não entrou na lista do CI nem do `conferir-tudo.sh`:** ele precisa do banco
de contrato de pé e do servidor, como os outros ensaios por HTTP. A recomendação é pô-lo
no job `caracterizacao-postgres`, ao lado do `ensaiar-sessao-do-erp.php` — levou 33 s
no PostgreSQL de contrato. Sem ele, o CI cobre o caso em processo.

**O que resta, e por isso o título continua PARCIAL:** os seis de `MORTOS_CONHECIDOS`,
como em 14/09 — os três escondidos, o `fidelizacao|pdf` que anda com o do menu, o
"ativar" dos banners e a data dos sorteios —, que esperam decisão.

#### A decisão (11/10/2026, registrada em 15/10/2026 pela fatia PONTE-PARA-A-ANTIGA)

O dono respondeu a pergunta 120 da página do lote — "Três botões escondidos do ERP levam a
telas que nunca existiram: saem?" — sem marcar letra, com a observação *"Criar telas que
ainda não existem, caso não leve a lugar nenhum, cire-as para que exista um destino"* (sic). A
leitura da fila das telas montada em 14/10/2026 (a correção 3 dela): nenhum morto sai
calado, e cada um ganha destino.

| o morto | o destino | a fatia da fila das telas |
|---|---|---|
| `fidelizacao\|index` (o item escondido) e `fidelizacao\|pdf` | a resposta 138 (a) já o deu: a fidelização ao colaborador fica no Profissional Parceiro, e o item sai | 41, FIDELIZACAO-SAI -- **feito em 25/10/2026**: o item escondido, o `Form/Fidelizacao.php`, a view sem controlador e a linha do `menuAtivo` saíram; as duas entradas saíram de `MORTOS_CONHECIDOS` (o PARCIAL de 25/10/2026, abaixo) |
| `pacotes\|exportar` (o "Exportar" escondido) | ganha a ação e o botão | 90, EXPORTAR-PACOTES -- **feito em 06/11/2026**: a ação baixa a planilha da lista, o botão aparece nas duas telas, e a entrada saiu de `MORTOS_CONHECIDOS` (o PARCIAL de 06/11/2026, abaixo) |
| `usuarios\|contrato` (o "Gerar Contrato" escondido) | ganha tela, com o modelo do dono | 91, GERAR-CONTRATO |
| `appbanners\|ativarRemover` (visível) | o "ativar" ganha a ação | 21, BANNERS-SERVIDOR -- **feito em 20/10/2026**: a ação existe, só por POST e com o estado pedido; saiu de `MORTOS_CONHECIDOS` (o PARCIAL de 20/10/2026, abaixo) |
| `sorteios\|editar` (visível) | o editar do sorteio | 26, SORTEIOS-CADASTRO -- **feito em 21/10/2026**: a ação existe, pela `RegraDoSorteio`, só ao administrador e conferindo o que leu (o D565); a lista a oferece em todo sorteio aberto, e não só no que se cancela; saiu de `MORTOS_CONHECIDOS` |

**Por que o título diz DECIDIDO e PARCIAL ao mesmo tempo.** A decisão que o débito esperava
existe; o defeito não acabou: os seis continuam em `MORTOS_CONHECIDOS`, e cada fatia acima
tira a sua entrada do `conferir-links-mortos.php` no mesmo commit que dá o destino. O
`contar-debitos.php` lê "DECIDIDO" sozinho como fechado, e o lê "PARCIAL" antes; o título
diz as duas palavras para a conta dizer o que o corpo diz. Fecha quando a última das cinco
fatias entrar.

#### PARCIAL em 20/10/2026 -- o ativar dos banners ganhou a ação (a fatia 21, BANNERS-SERVIDOR)

A lista dos banners tinha dois botões para `appbanners/ativarRemover`, e a ação nunca existiu (`eaf1fca`). Agora existe: só
por POST, com o estado pedido -- o X abre a caixa "Tirar este banner do aplicativo?" e manda `estado=inativo` pelo
`modalRemoverPorPost`, e o "pôr no aplicativo" da lista dos inativos é um formulário com `estado=ativo`. O segundo envio igual
não desfaz o primeiro, e nenhum dos dois aponta para o `removerAction`, que apaga. A entrada `ISConfiguracao|appbanners|ativarRemover`
saiu de `MORTOS_CONHECIDOS` (`scripts/conferir-links-mortos.php`) no mesmo commit, e o portão segue 0 com cinco conhecidos.

**Provado** pela linha dos banners no `D199AtivarRemoverPorPostTest`, nos dois bancos: o GET não muda nada, o POST sem estado
não muda nada, o POST grava o pedido e o segundo não desfaz, e a lista renderiza os dois formulários. Mutantes: a ação que
alterna e a ação que aceita GET -- vermelhos.

**Restam** os outros cinco de `MORTOS_CONHECIDOS`, cada um com a sua fatia (a tabela da decisão, acima).

#### PARCIAL em 25/10/2026 -- a fidelização saiu (a fatia 41, FIDELIZACAO-SAI)

Os dois mortos da fidelização tinham destino desde a resposta 138 (a) do dono -- *"A Fidelização sai, junto com a rotina; a
fidelização ao colaborador já está no Profissional Parceiro"* -- e agora saíram do código: o item escondido "Fidelização de
Clientes" do menu Relatórios (`layout.phtml`, o `<li class="display-none">` para o controlador `fidelizacao`, que nunca
existiu), o `Form/Fidelizacao.php` órfão que apontava para `/mod-relatorios/fidelizacao/pdf`, a view
`is-relatorio/fidelizacao/index` do ISRelatorio, que desenhava esse formulário e que nenhum controlador
usava (achada na verificação da fatia), e a linha do `Rel-fidelizacao` no `SessaoAcl::menuAtivo`. As entradas `ISRelatorio|fidelizacao|index` e `ISRelatorio|fidelizacao|pdf`
saíram de `MORTOS_CONHECIDOS` no mesmo commit: o portão segue 0, com dois conhecidos, e o item de volta ao layout é morto
novo (falsificado: vermelho). O privilégio `relatorios/fidelizacao` fica no cadastro dos grupos, como os do Dash.
Colaboradores e do Radar: nenhuma linha apagada. A rotina do relatório saiu junto -- o D306.

**Provado** pelo `FidelizacaoDeClientesSaiuTest`, nos dois bancos: o layout desenhado com a sessão do gerente e o
privilégio da fidelização posto nela não leva o item nem o link (o controle: o Dash. Faturamento continua), o menu não
acende pelo `Rel-fidelizacao`, e o resolvedor de templates da aplicação não acha mais a view (o controle: a do Dash.
Faturamento). Mutantes: o item de volta ao layout (o caso e o `conferir-links-mortos.php` vermelhos), a linha de volta ao
`menuAtivo` e a view de volta (o caso vermelho em cada um). O privilégio que fica sem tela é o quarto do D485.

**Restam** dois de `MORTOS_CONHECIDOS`, ambos escondidos: o `pacotes|exportar` (a fatia 90) e o `usuarios|contrato` (a 91).

#### PARCIAL em 06/11/2026 -- o exportar dos pacotes ganhou a ação e o botão (a fatia 90, EXPORTAR-PACOTES)

O "Exportar" da lista antiga de pacotes estava dentro de um `<div class="btn-group display-none">` e apontava para
`pacotes/exportar`, uma ação que nunca existiu. A resposta 120 pede o destino, e ele é o que o nome diz: a lista em
planilha. Agora:

- **A ação existe nas duas portas, com a mesma planilha** (`ISConfiguracao\Exportacao\PlanilhaDosPacotes`): a antiga,
  `PacotesController::exportarAction` (`/mod-configuracoes/pacotes/exportar`), e a nova, `GET /adm/api/pacotes/exportacao`.
  XLSX, com as colunas da lista (Nome, Valor, Status), o valor como número com duas casas, o texto sempre como texto (um
  nome que comece com "=" não vira fórmula), e o nome `pacotes-<ativos|inativos>-<dia>-<unidade-N|rede>.xlsx`.
- **O mesmo recorte e os mesmos filtros da lista**: a unidade da sessão (só o administrador escolhe outra, o D142), o
  estado e o nome, sem a página. A guarda é a da lista, o `ler` de `configuracoes/pacotes` -- a planilha não leva nada
  que a lista não mostre. Na API, a lista e a planilha leem a busca do mesmo ajudante (`filtrosDaLista`, vigiado pelo
  `conferir-recorte-por-unidade.php`); na antiga, as duas leem o `dadosRequest`. Na antiga, o estado e a unidade são
  listas fechadas, e o resto é 404.
- **O botão aparece**: na lista antiga, sem o `display-none`, e com a busca montada por `http_build_query` (a variável
  `exportacao` do `indexAction`) -- e não pela `filtros` da paginação, que levava o nome cru e perdia a unidade vazia
  do administrador. Na tela nova, o "baixar a planilha (XLSX)" no pé da lista, o `BotoesDeExportacao` dos relatórios,
  para quem lê, com a busca que produziu a lista e desligado enquanto ela relê.
- **A entrada `ISConfiguracao|pacotes|exportar` saiu de `MORTOS_CONHECIDOS`** no mesmo trabalho: o portão segue 0, com
  um conhecido. O piso de chamadas não mudou (343, piso 320): o link é o mesmo `url(...)`.

**Provado** pelo `PlanilhaDosPacotesTest`, nos dois bancos: as recusas da API (401, 403, o 422 do estado) em JSON; o
arquivo (o tipo, o `attachment` com o nome do dia e da unidade, `no-store`, `nosniff`, o tamanho; o cabeçalho em
negrito; o valor numérico com `#,##0.00`; o acento; o "=" como texto); as linhas iguais às da lista da API, na mesma
ordem, com quatro buscas; o recorte (quem não é administrador fica na própria unidade mesmo pedindo outra; o inativo só
nos inativos; o serviço nunca; o administrador escolhe a unidade, e o zero é a rede); a ação antiga dando a mesma
planilha, e 404 sem o `ler`, com o estado fora da lista e com a unidade que não é número; e o botão da lista antiga
visível com a busca codificada. Os ensaios HTTP de recusas (235 a 237) e de permissão com a rota nova. Na tela nova,
seis casos do `PacotesLigado.teste.tsx`. Mutantes: na `roadmap.md` de 06/11/2026.

**Na verificação da fatia (06/11/2026)**, quatro buracos de prova fechados, cada um com o mutante que passava verde e
agora cai: (1) **o link da rede** -- o administrador que lista "Todas" (a unidade vazia) e clica em Exportar: o caso novo
`testOLinkDaRedeLevaAPlanilhaDaRede` SEGUE o `href` que a lista desenha e confere a planilha da rede (as duas unidades,
o nome `-rede`, a mesma da API com `unidade=0`), e que quem não é administrador fica na própria; caem a unidade vazia
recusada (o 404 de volta) e a unidade vazia lida como a do administrador. (2) **A ordem 401 e 403 antes do 422**: com
`estado=todos`, sem sessão dá 401 e sem o `ler` dá 403, em processo e por HTTP (a linha
`/adm/api/pacotes/exportacao?estado=todos` no `ensaiar-permissao-adm-api.php`); cai a busca lida antes da guarda. (3)
**Os cabeçalhos da porta antiga** (`no-store`, `nosniff`, o tamanho), que monta os dela: caem o `Cache-Control` e o
`nosniff` arrancados dela. (4) **O arquivo por HTTP**: o controle 236 só conferia "não é JSON", e um 200 com a página do
fatal do PHP passava; agora o `pedir()` do ensaio de recusas devolve também o corpo cru e os cabeçalhos, e o
`$aPlanilhaDosPacotes` confere o tipo, o anexo com o nome, o `nosniff`, o `no-store`, a assinatura "PK" e a planilha
aberta pelo PhpSpreadsheet (o cabeçalho da lista e um pacote com o valor em número); o caso novo 238 pede a porta antiga
(`/mod-configuracoes/pacotes/exportar`), que nenhum ensaio pedia. Caem por HTTP: o `nosniff` arrancado da porta antiga (o
238) e a planilha trocada por um HTML de fatal com 200 (o 236 e o 238).

**Resta** um de `MORTOS_CONHECIDOS`, escondido: o `usuarios|contrato` (a fatia 91, que espera o modelo do dono). O
débito fecha com ela.

**06/11/2026, a fatia 97 (PACOTES-PLANILHA-COLUNAS; a resposta 316 (b) do dono, gravada na página do lote em
07/11/2026 01:15 UTC, sem observação: "Acrescentar a validade e o total de sessões: duas colunas a mais, uma linha por
pacote").** A planilha das duas portas ganhou, depois de Status, **"Validade (dias)"** e **"Total de sessões"**, em número
(a soma e o filtro da planilha funcionam). A validade é a do cadastro (`produtos.pacote_validade_dias`): o zero sai zero, e
a vazia (a coluna aceita NULL; 1 dos 239 pacotes do dev) fica em branco -- nem zero, que diria "vence no dia", nem "sem
prazo": o que o vazio quer dizer não está decidido (a tela nova do cadastro diz "sem prazo", e a venda o lê como 31/12/1969 --
o D731, com a pergunta ao dono). O total é a soma das sessões (`pacotes_itens.quantidade`) dos itens com `status` ligado: os que o cadastro mostra
(`PacoteItemRepository::selecionarItensPacote`) e a venda entrega (`VendaPagamentoService::salvarPacotes`). O item tirado
não conta; o do serviço inativo conta, porque o cadastro o mostra e a venda cria as sessões dele (5 dos 446 itens do dev
apontam para serviço inativo). A soma sai na MESMA consulta da lista, por subconsulta correlata, só sem a página
(`ProdutoRepository::listagemIndexPacotes(..., false)`, que só a planilha pede): nada de uma consulta por pacote, e a lista
paginada não mudou. Uma linha por pacote, o mesmo recorte, os mesmos filtros, as mesmas guardas e o mesmo nome do arquivo.
**Provado** pelo `PlanilhaDosPacotesTest`, nos dois bancos: as células, o tipo numérico e o cabeçalho das duas colunas; a
vazia vazia e o zero zero; as duas portas; e, com quatro pacotes na rede, uma consulta só lendo `pacotes_itens` -- a da
lista (o `DebugStack`). Por HTTP, o `$aPlanilhaDosPacotes` do ensaio de recusas confere o total em número e a validade
esperada pelo caso: o 236 grava 30 dias no pacote do contrato (e devolve a foto depois) e espera o 30 em número; o 238, com o
pacote sem validade, espera a célula em branco. Os mutantes, na `roadmap.md` de 06/11/2026. Desta parte, nada fica de pé:
resta só o contrato, acima.

**A verificação da 97 (06/11/2026)** confirmou quatro achados baixos, tratados: (1) o texto dizia que a validade em branco
era "como o cadastro a mostra", e o comentário do código, que o vazio não era o zero -- a tela nova mostra "sem prazo" e a
venda grava 31/12/1969; o texto foi corrigido e o defeito da venda virou o D731, com a pergunta ao lote; (2) a justificativa
da guarda ("a planilha não leva nada que a lista não mostre") ficou falsa com as colunas novas: agora diz que ela não leva
nada que o `ler` já não abra (o `GET /adm/api/pacotes/:id` entrega a validade e os itens), no `exportacaoAction`, em
`docs/f2-fatia-pacotes.md` e em `docs/convencao-api.md`; (3) os comentários do código datavam a fatia de 07/11 (o dia UTC
da resposta), e passaram a 06/11; (4) o controle HTTP da validade passava sempre pelo ramo vazio, porque o pacote do
contrato nasce sem ela -- agora cada porta exerce um ramo (acima), e o mutante da validade em texto cai por HTTP.

---

### D106 — O portão de corte estava vermelho por 61 provas, e nenhuma era da carga — CORRIGIDO em 05/09/2026

`conferir-migracao.php` é o portão do **passo 5**: ele compara os dois bancos
carregados da mesma origem, e é o que autoriza virar a chave. Fica fora do
`conferir-tudo.sh` porque leva dez minutos.

Rodado em 05/09/2026, pela primeira vez em semanas:

```
61 PROVAS FALHARAM. A carga NÃO está boa para corte.
```

**Nenhuma das 61 vinha da carga.** Vinham de os dois lados terem sido *usados*
depois dela.

#### As 25 tabelas divergentes, uma a uma

Não parei em "nenhuma era da carga": conferi as 25 até a última.

| quantas | causa |
|---:|---|
| **18** | linhas de fixture da suíte de caracterização — 111 no MySQL, 14 no PostgreSQL |
| **4** | dicionário de CEP, **esvaziado no MySQL** em 31/08/2026 por decisão, com a cópia do PostgreSQL anterior a isso |
| **2** | `pesquisas` e `pesquisas_perguntas`: o PostgreSQL guarda a pesquisa `id=1` (*Cadastro Fidelidade*, status 0, pergunta "pergunta 1?") que foi **apagada do MySQL** depois da carga |
| **1** | `usuarios_acessos`: o PostgreSQL para no id 1.880.083, de 16/08 às 21h16 — a hora da carga —, e o MySQL tem mais **seis registros de login** feitos em 03/09 |

4 + 2 + 1 + 18 = 25. Nenhuma sobra, e é isso que autoriza a frase: **a carga está
boa; as cópias é que não são mais o que foram carregadas.**

As duas primeiras causas explicam também as somas e as datas. As somas de dinheiro diferiam exatamente pelos valores
das vendas de fixture — `vendas.valor_total` por R$ 309,90, o total das cinco —,
e os extremos de data terminavam em `2030-12-31` e `2026-01-01`, que são valores
escritos à mão no `fixtures.sql`. As quatro tabelas de endereço mostravam
`MySQL 3 / PostgreSQL 709.440`, porque o `limpar-dicionario-cep.sql` rodou de um
lado só.

#### Vermelho que se explica é pior que vermelho nenhum

Cada uma das 61 linhas tinha resposta pronta — *"ah, são as fixtures"*, *"ah, é
o CEP"*. E é assim que um portão de corte vira enfeite: não porque alguém decide
ignorá-lo, mas porque ele passa a exigir uma interpretação toda vez, e a
interpretação sempre termina em "pode seguir".

O pior é o momento. Isso não aparece num dia qualquer: aparece no dia da
virada, com todo mundo olhando e pressa para decidir.

#### A correção: recusar, e não falhar

O roteiro agora confere **antes de qualquer prova** se há linhas de cenário de
teste nos dois lados. Se houver, ele para em segundos com **código 2 — "a prova
não vale"** — e não com 1 — "a prova falhou". São coisas diferentes, e
confundi-las é o caminho mais curto para o enfeite.

A lista de tabelas sai do próprio `fixtures.sql`, então cenário novo entra
sozinho.

**Ele é detector, e não inventário.** As fixtures inserem em `vendas_produtos` e
`vendas_pagamentos` sem dizer o `id`, então essas linhas nascem no topo da
sequência real e não caem na faixa procurada. Não faz diferença para o que ele
serve: as vendas-mãe estão na faixa, a recusa dispara, e a comparação para. Mas
o número que ele imprime é o que ele viu, não o total do estrago.

#### E uma marca para o resto

Carga não inventa linha. Se o alvo tem **mais** linhas que a origem, ou a carga
rodou duas vezes, ou as duas cópias são de datas diferentes — e são coisas
opostas. A contagem agora marca esses casos com `(alvo maior)`; continua
contando como falha, porque decidir entre as duas é trabalho de quem opera.

Com a marca, as **6** tabelas nessa situação se separam sozinhas das outras 19:
as quatro de endereço, e `pesquisas`/`pesquisas_perguntas` — estas com uma linha
a mais no PostgreSQL e `data_criacao` **anterior** à do MySQL, o que não é
fixture nem dicionário. Fica anotado; investigar é para quem for cortar.

#### O que fica para quem for cortar

- as cópias locais **não servem** para a prova como estão;
- na virada, o PostgreSQL precisa ser carregado de um MySQL que **já** esteja com
  o dicionário de CEP vazio, senão as quatro tabelas de endereço divergem de
  novo — e dessa vez com aparência de perda de 756 mil linhas.

As 111 linhas de fixture no `franquiabv_dev` do MySQL continuam lá: apagá-las é
mexer no banco de desenvolvimento de quem manda, e isso não se faz sem pedir.

#### A linha a mais das pesquisas, explicada (08/10/2026)

O "fica anotado" acima foi medido nos dois bancos, só leitura (no PostgreSQL, com
`bvip.` na frente):

```sql
SELECT id, titulo, MD5(hash), programa_fidelidade, status, data_criacao, data_alteracao FROM pesquisas ORDER BY id;
SELECT id, pesquisa, conteudo, ordem, status, data_criacao FROM pesquisas_perguntas ORDER BY id;
SELECT pergunta, COUNT(*), SUM(total) FROM pesquisas_respostas GROUP BY pergunta;
```

| | MySQL (`franquiabv_dev`) | PostgreSQL (`bvip`) |
|---|---|---|
| `pesquisas` | só a id 2 | a id 1 (inativa, fora do programa, criada em 12/01/2018 às 15:14:52) e a id 2 |
| `pesquisas_perguntas` | só a id 2 | a id 1 ("pergunta 1?", da pesquisa 1) e a id 2 |
| `pesquisas_respostas` | 3 linhas, 25.471 votos | as mesmas 3, os mesmos 25.471 |

A id 2 é igual nos dois em tudo -- título, `hash` (comparado pelo MD5), status e datas. A
linha a mais é a **pesquisa de teste de 2018** e a pergunta dela, que
`scripts/remover-pesquisa-teste-2018.sql` apagou do MySQL local em 29/08/2026 (commit
`f6524d63`), treze dias depois da carga do PostgreSQL (16/08). A `data_criacao` "anterior"
é a dela: 15:14:52, quarenta minutos antes da pesquisa de verdade.

**Não é defeito da carga, nem de nada:** é a remoção decidida, feita de um lado só. A
produção ainda tem as duas (`docs/funcionalidades-sem-uso.md`: a remoção lá "espera a
janela"), então a virada leva a id 1 aos dois lados, ou a nenhum se o script rodar antes da
carga -- e as duas tabelas batem nos dois casos. O que fica para quem for cortar é a regra
do dicionário de CEP, outra vez: **o que se apaga na janela se apaga ANTES da carga.**

---

### D107 — Setenta e sete por cento de cada PDF baixado era a página do ERP colada no fim — CORRIGIDO em 05/09/2026

Achado em 05/09/2026, comparando os relatórios nos dois bancos. O PDF de evasão
saía com **61.372 bytes no PostgreSQL e 59.601 no MySQL**, e o JSON que o
alimenta era byte a byte idêntico nos dois. A diferença não estava no dado.

| | evasão | clientes |
|---|---:|---:|
| o PDF | 14.327 | 14.163 |
| HTML colado depois do `%%EOF` | **47.045** | **49.574** |
| o que o usuário baixa | 61.372 | 63.737 |

#### Por que acontece

O FPDF escreve o PDF direto na saída e devolve o controle. As ações que o chamam
terminam assim:

```php
(new \ISRelatorio\Pdf\PdfClientes())->gerarPDF($clientes);
...
return $this->notFoundAction();
```

O Laminas então renderiza a página de 404 **inteira** — layout, menu, scripts —
e ela sai colada no fim do arquivo que já foi enviado.

O `txtAction` do mesmo controlador já fazia certo: termina com `exit`. As nove
classes de PDF, não.

#### Ninguém percebeu porque o PDF abre

O leitor de PDF procura o `startxref` de trás para frente e ignora o que vier
depois do fim. O arquivo abre, imprime, parece normal — e pesa quatro vezes
mais.

**O que vai colado é a página do ERP com a sessão de quem gerou.** O arquivo sai
da empresa — para um franqueado, um contador, um cliente — levando isso junto.

#### A correção, num lugar só

As nove classes estendem `ISBase\Util\FPDFTable`. O `Output()` foi sobrescrito
ali: chama o pai e, quando o destino é o navegador (`I` ou `D`), encerra a
requisição. O modo `F` — gravar em arquivo, usado pelo visagismo — não encerra
nada, porque quem grava continua trabalhando depois.

Devolver um `Response` vazio não serviria: o cabeçalho já foi enviado pelo FPDF,
e o Laminas tentaria enviá-lo de novo — que é o D100 pela porta oposta.

Depois: **14.164 e 14.328 bytes**, com o único byte de sobra sendo a quebra de
linha que o próprio FPDF escreve.

#### E virou medição

O medidor de relatórios agora reprova qualquer binário com conteúdo depois do
fim declarado — `%%EOF` no PDF, a assinatura do diretório central no ZIP. É a
mesma família do D100: **saída que ninguém pediu, num lugar onde o formato não
reclama.**

#### Varri as outras dezesseis, e o defeito era só dos PDF

O inventário tem **17 ações que devolvem arquivo**, e o medidor de relatórios
alcança 8. Conferi as outras nove uma a uma, para não deixar a mesma frase do
D106 pela metade:

| como termina | quantas | resultado |
|---|---:|---|
| `Xls\*::gerarXls()`, que termina com `exit` | 6 | limpas |
| devolvem um `Response` do Laminas, com `setContent` | 3 | limpas |
| `Pdf\*`, que só escreviam e voltavam | 8 | **eram estas** |

As classes de XLS já faziam certo — é por isso que os XLSX saíam sem sobra
nenhuma enquanto os PDF saíam com 47 KB colados. O defeito nunca foi do
formato: era de quem não encerrava a requisição.

#### O portão foi visto vermelho antes de valer

A checagem mora em `scripts/bordas-do-binario.php` — o nome era
`fim-do-binario.php` até o D108 achar o outro lado — porque **dois** medidores
precisam dela: o de relatórios pede os 31, e o de telas esbarra em arquivo ao
percorrer o ERP — são **3 respostas binárias** em 153, e o número fica impresso
para que ninguém precise supor que a checagem roda.

Conferido desligando o `exit` do `FPDFTable` e rodando de novo: o medidor **de
telas** reprovou `/mod-relatorios/rel-vendas/exportar` com *"PDF de 14.238 bytes
com 2.735 bytes colados depois do %%EOF"*, e o roteiro saiu com 1. Religado, sai
com 0.

#### Como apareceu

Não foi procurando defeito de PDF. A pergunta era outra — *"os dois bancos dizem
o mesmo?"* —, e a resposta foi **30 de 31 relatórios idênticos**, com o de
evasão sobrando. Perseguir aquele único descompasso levou a um defeito que não
tem nada a ver com banco de dados e que estava em produção há tempo indefinido.

A comparação também precisou de duas correções para valer, e as duas são a mesma
lição do D106 um andar abaixo:

- o `--mysql` **não montava** o banco de controle, então comparava o PostgreSQL
  de hoje com um MySQL de ontem — e as fixtures usam `CURRENT_DATE`, então o
  caixa do cenário nascia com a data da montagem. A única diferença de texto
  entre os dois bancos era `04/09/2026` contra `05/09/2026`;
- o despejo guardava `[tipo, N bytes]` para binário, e tamanho não decide nada:
  os XLSX variam de um ou dois bytes entre duas corridas do **mesmo** banco.
  Agora guarda os bytes.

---

### D108 — Cento e sessenta e oito bytes de aviso na frente de cada planilha — CORRIGIDO em 05/09/2026

Achado em 05/09/2026 pela comparação entre os dois bancos, e o modo como
apareceu vale tanto quanto o defeito.

A comparação acusou dois XLSX diferentes. Fui olhar por dentro, e o `file` disse:

```
Zip archive, with extra data prepended
```

Antes do `PK` havia 168 bytes:

```html
<br /><b>Notice</b>: ob_clean(): Failed to delete buffer. No buffer to delete
in <b>.../module/ISRelatorio/src/Xls/Clientes.php</b> on line <b>66</b><br />
```

`ob_clean()` sem buffer aberto emite um `Notice`, e com `display_errors` ligado
ele sai **antes** dos bytes da planilha. Toda exportação saía com HTML na frente.

#### O D100 previu isto por escrito, e deixou quatro irmãos vivos

O D100 corrigiu o mesmo `ob_clean()` em `ISMarketing\Xls\Sorteio`, e o
comentário que ficou lá dizia:

> *"aqui o aviso é pior que em qualquer tela: ele sai ANTES dos bytes do XLSX,
> no meio de um download, e o arquivo que o usuário abre chega corrompido."*

Era hipótese, escrita com confiança. Hoje virou medida — e os **quatro** outros
`ob_clean()` sem guarda continuavam lá:

| arquivo | |
|---|---|
| `ISRelatorio\Xls\Atendimentos` | corrigido |
| `ISRelatorio\Xls\Clientes` | corrigido |
| `ISRelatorio\Xls\VendasVersao1` | corrigido |
| `ISRelatorio\Xls\VendasVersao2` | corrigido |

É o mesmo padrão do D93 e do D94: **conserta-se o sítio que a medição alcança, e
os irmãos ficam.** A diferença é que desta vez o comentário já apontava para
eles, e ninguém foi ver.

#### Severidade depende de uma pergunta ainda sem resposta

Com `display_errors` desligado o `Notice` não é impresso, e a planilha sai
limpa. **Ninguém verificou como isso está em produção** — é a pergunta aberta
desde o D100. O `ob_clean()` errado é defeito de qualquer forma; o que a
configuração decide é se ele chega ao usuário.

#### E virou portão, porque comentário não segurou

O D100 corrigiu uma destas chamadas e deixou escrito, ao lado dela, que o
arquivo chegaria corrompido ao usuário. **A previsão estava certa e não segurou
nada:** os quatro irmãos continuaram fazendo exatamente aquilo até a comparação
entre os bancos tropeçar neles.

`scripts/conferir-buffer-de-saida.php` exige que toda chamada de `ob_clean()`,
`ob_end_clean()`, `ob_flush()` ou `ob_end_flush()` tenha um `ob_get_level()` nas
cinco linhas acima. A regra é literal de propósito: ela não tenta entender o
código, só exige que a pergunta tenha sido feita por perto.

**A primeira versão acusou quatro sítios que não existem** — comentários de
bloco que citam `ob_clean()` para explicar o defeito, incluindo os do próprio
roteiro. Medidor que acusa a si mesmo não acusa nada; a leitura passou a usar o
tokenizador do PHP, que distingue código de comentário e de string sem
adivinhação. São 5 chamadas reais em 666 arquivos, todas com guarda.

Conferido nos dois sentidos: removida a guarda de `Xls\Clientes`, o portão
aponta o arquivo e a linha e sai com 1; devolvida, sai com 0.

#### A checagem passou a olhar os dois lados

`scripts/bordas-do-binario.php` (antes `fim-do-binario.php`) agora reprova nas
duas pontas, e cada ponta tem uma história:

| | |
|---|---|
| **depois do fim** | D107 — 47 KB da página de 404 colados num PDF de 14 KB |
| **antes do começo** | D108 — 168 bytes de `Notice` na frente do `PK` |

A tolerância no fim é de oito bytes, porque o FPDF escreve uma quebra de linha.
No começo é **zero**: assinatura fora do byte zero é sempre alguém escrevendo
antes da hora.

Conferido contra os arquivos capturados antes do conserto: a checagem devolve
*"ZIP comeca no byte 168: Notice: ob_clean()..."*.

#### E a comparação virou um comando

`sh scripts/comparar-relatorios-nos-dois-bancos.sh` monta os dois bancos do
zero, roda os 31 relatórios em cada um e compara resposta por resposta. Leva uns
dez minutos, e por isso fica fora do CI — é portão de corte, como o
`conferir-migracao.php`.

**Resultado de hoje: 31 de 31 idênticos.**

O que ela ignora, e só isso: `/CreationDate` e `/ID` do PDF, e o
`docProps/core.xml` do XLSX. A última exclusão foi medida, não suposta — era o
único arquivo com CRC diferente dentro do ZIP, e o `sheet1.xml`, que carrega o
relatório de verdade, batia byte a byte.

Ela já foi vista vermelha duas vezes no mesmo dia, e as duas viraram registro:
o PDF do D107 e a planilha deste.

---

### D109 — `DEFAULT NULL` virou `DEFAULT false`, e a listagem de usuários ficou vazia num banco só — CORRIGIDO em 05/09/2026

Achado em 05/09/2026, quando a comparação entre os dois bancos passou a incluir
as **telas**, e não só os relatórios.

De 181 respostas, 165 batiam. Uma delas não:

| | PostgreSQL | MySQL |
|---|---|---|
| `/mod-configuracoes/usuarios/index` | 2 usuários | **"Nenhum usuário localizado"** |

E mais 14 telas existiam só do lado do PostgreSQL — as de `usuarios/<ação>/<id>`,
que a varredura só alcança seguindo os links de uma listagem que no MySQL estava
vazia.

#### A cadeia inteira, do gerador à tela

1. O `mysql.sql` declara `` `administrador` tinyint(1) DEFAULT NULL ``.
2. O `gerar-ddl-postgres.php` converte tipo e padrão. No ramo booleano ele fazia:

   ```php
   $padrao = in_array(trim($padrao, "'"), ['1', 'true'], true) ? 'true' : 'false';
   ```

   `NULL` não está na lista de valores verdadeiros, então **virava `false`**.
3. O `fixtures.sql` inseria `grupos` sem citar `administrador`, deixando o valor
   por conta do padrão da coluna: `NULL` no MySQL, `false` no PostgreSQL.
4. A listagem filtra por `gru.administrador = :administrador` com `false`.
5. `NULL = false` **não é verdadeiro**. Zero linhas no MySQL, duas no PostgreSQL.

Cinco elos, e cada um sozinho parece inofensivo.

#### Quatro colunas, e nulo não é falso

| coluna | no `mysql.sql` | no DDL gerado |
|---|---|---|
| `grupos.administrador` | `DEFAULT NULL` | `DEFAULT false` |
| `add_comanda` (duas tabelas) | `DEFAULT NULL` | `DEFAULT false` |
| `arquivos_categorias.status` | `DEFAULT NULL` | `DEFAULT false` |

**Nulo é "não se sabe"; falso é "não".** A carga copia os valores existentes, e
por isso o dado migrado não muda — o que muda é toda linha criada *depois* da
virada, e a comparação com `= false`, que passa a encontrar linhas que antes não
encontrava.

O gerador agora trata `DEFAULT NULL` antes do ramo booleano. O
`postgres/schema.sql` regenerado muda em exatamente quatro linhas, e o passo do
CI que confere se o DDL versionado está atualizado cobre isso.

#### E o cenário passou a dizer o que quer dizer

As fixtures agora escrevem `administrador` explicitamente. Cenário que depende
do padrão de uma coluna não diz o que quer dizer — e este dependia de um padrão
que era diferente nos dois bancos.

Depois das duas correções: **180 de 181 idênticos**, e as 14 telas que só
existiam de um lado passaram a existir dos dois.

#### O que a comparação estava deixando de fora

Duas coisas eram despejadas e **nunca comparadas** — o formato mais silencioso
de buraco, porque o arquivo está lá:

| | |
|---|---|
| `cache/*.json` | os seis arquivos que as **rotinas de cron** escrevem, e que os PDF de evasão e fidelização apenas leem. A consulta pesada roda ali, minutos antes do pedido. Eu já os tinha comparado à mão uma vez, e batiam — que é exatamente a prova que o D101 diz não se repetir. |
| as 3 respostas binárias das telas | justamente as que o D107 e o D108 mostraram serem capazes de carregar lixo. |

Fechados os dois, a comparação passou de 181 para **190 arquivos**: 189
idênticos e a divergência conhecida da ordenação.

#### A que sobrou fica na lista, com o que falta para sair

`/mod-configuracoes/unidades/index` continua diferente: as mesmas linhas, em
ordem diferente. É o D46 — `unidades.estado` tem 1 nulo em 63, e os dois bancos
discordam de onde o nulo entra. O `CASE` que igualava a ordem saiu porque o
paginador roda sem output walkers (D97).

**Testei o caminho óbvio antes de aceitar:** pôr o `CASE` também no `SELECT`,
para satisfazer a regra do `SELECT DISTINCT`. Não resolve — a tela volta a
responder 500. O comentário que dizia isso estava certo, e agora está conferido
em vez de acreditado.

Ela fica em `DIFERENCAS_CONHECIDAS`, impressa a cada execução com o motivo e com
a condição de saída. A comparação falha se aparecer diferença nova **e também**
se uma conhecida sarar sem ser tirada da lista.

---

### D110 — 87 pares de royalties com duas linhas, e a busca escolhia uma sem dizer qual — CORRIGIDO em 05/09/2026

Achado em 05/09/2026, perguntando à cópia de produção o que o D72 vinha
supondo: **as chaves que o código trata como únicas são únicas nos dados?**

Das treze buscas que pedem uma linha sem ordenar, oito assumem uma chave de
negócio. Conferi as oito contra a cópia carregada, e sete estão limpas:

| chave assumida | pares repetidos |
|---|---:|
| `usuarios.login`, `usuarios.email` | 0 — **e este zero não vale**, ver abaixo |
| caixa aberto por usuário | 0 |
| venda temporária por usuário | 0 |
| `dashboard_faturamento` (unidade, mês, ano) | 0 |
| `dashboard_colaboradores` (colaborador, mês, ano) | 0 |
| `royalties_faturas` (unidade, vencimento) | 0 |
| `usuarios_comissoes_extras` (usuário, produto) | 0 |
| **`royalties` (unidade, dia)** | **87** |

#### O que são as 87

Todas entre **25/11/2025 e 14/12/2025**, em **29 unidades** — um episódio de três
semanas, não uma condição permanente. Um par típico:

| id | unidade | data | fat_total | ret_total | criada | alterada |
|---:|---:|---|---:|---:|---|---|
| 24034 | 10 | 2025-11-25 | 2.229,40 | 111,47 | 05:00:37 | 26/11 05:00 |
| 24035 | 10 | 2025-11-25 | **0** | **0** | 05:00:37 | — |

Nascem de um *upsert* sem chave única: o `FranquiaService` busca a linha do dia,
atualiza se achou e **insere se não achou**. Nada no banco impede a segunda
linha — o D29 mediu zero restrições de unicidade no sistema inteiro.

#### O dinheiro NÃO foi afetado, e isso precisa ser dito primeiro

A hipótese óbvia era dupla contagem: `relatorioFranquias` faz `SUM(roy.retTotal)`
sobre o período, e a fatura do Asaas sai daí. Se as duas linhas tivessem valor, a
cobrança sairia dobrada.

**Medi, e não é o caso.** Em 46 dos pares uma linha está zerada — e somar zero
não muda soma. Nos 43 em que as duas linhas são iguais, `ret_total` é **zero nas
duas**. O excedente cobrado é **R$ 0,00**.

Registro o resultado negativo com o mesmo cuidado que registraria o positivo: a
hipótese era boa e estava errada.

#### O que era real, então

`selecionarPorDataUindade` devolvia **uma das duas sem dizer qual**, e é essa
linha que decide entre atualizar e inserir. Em 46 pares uma delas está zerada.

A ordem agora é `roy.id ASC`, e a escolha do `ASC` foi conferida: **nos 46 pares
mistos, quem tem os valores é o menor id** — nos 46, sem exceção. É também o que
o MySQL vinha devolvendo, então a migração não muda a resposta. Não supus: contei.

#### E a varredura dos outros nove "lê-e-cria"

O `royalties` não foi achado por sorte: fui atrás do **mecanismo**. O padrão que
cria duplicata é o *upsert* sem chave única — busca, atualiza se achou, insere se
não achou — e ele aparece em **dez lugares** do sistema.

Conferi os dez contra a cópia de produção:

| tabela | pares repetidos | a busca ordenava? |
|---|---:|---|
| `royalties` | **87** | não → **corrigida (D110)** |
| `clientes_creditos` | 3 | já ordenava — R$ 408 contra R$ 0 no par que diverge |
| `nps_resultados_colaboradores` | 5 | já ordenava |
| `nps_resultados_unidades` | 3 | já ordenava |
| `pontuacoes_historicos` | 2 | dispensada: o chamador só pergunta `empty()` |
| `vendas` (temporária por usuário) | 0 | **não → corrigida agora** |
| `dashboard_faturamento` | 0 | **não → corrigida agora** |
| `dashboard_colaboradores` | 0 | **não → corrigida agora** |
| `royalties_faturas` | 0 | **não → corrigida agora** |
| `clientes` (amigo da fidelidade) | — | não é upsert de chave: cria um cliente novo |

**As quatro com zero duplicatas foram corrigidas mesmo assim**, e o motivo está
escrito em cada uma: em `royalties` também não havia, até um episódio de três
semanas criar 87. "Hoje não há" não é garantia quando nada no banco impede.

A regra é a mesma nas quatro — `id ASC`, a linha mais antiga, que é a que o
*lê-e-cria* teria achado antes de a segunda existir, e é o que o MySQL já
devolvia. Com zero duplicatas hoje, a ordem não muda resposta nenhuma: ela muda o
que acontece **quando** a segunda linha aparecer.

A linha de base do D72 caiu de 13 para **9**.

#### E onde a ordem seria o conserto ERRADO

Das nove que restavam, três saíram agora — as duas irmãs do painel de
colaboradores, pela mesma chave da que o cron atualiza (se um par repetido
aparecer, sem ordem cada uma lê uma linha diferente e o painel junta números de
linhas distintas), e o envio de push, que **devolve lista, não linha**: são até
100 e o chamador percorre todas, então nenhuma é descartada.

Sobram **seis**, e elas têm em comum o motivo de eu não as tocar:

| busca | chave | duplicatas hoje |
|---|---|---|
| `UsuarioRepository::autenticacao` | `login` | **não medível aqui** |
| `UsuarioRepository::selecionarPorLogin` | `login` | **não medível aqui** |
| `UsuarioRepository::selecionarPorEmail` | `email` | **não medível aqui** |
| `CaixaRepository::selecionarPorUsuario` | caixa aberto do usuário | 0 |
| `UsuarioComissaoExtraRepository::selecionarPorUsuarioProduto` | (usuário, produto) | 0 |
| `PesquisaRepository::selecionarPorFidelidade` | pesquisa ativa do programa | 0 |

As três de credencial **não são medíveis na cópia local** — a anonimização
deriva `login` e `email` do `id`. As outras três, sim, e estão zeradas. E em todas, `ORDER BY` seria
o conserto errado: ele faria o código **tolerar em silêncio** um estado que não
deveria existir.

No login isso é pior que um empate qualquer — duas contas com a mesma
credencial, e o sistema escolhendo em qual delas você entra. O conserto certo
aqui não é escolher melhor: é a **restrição de unicidade** que o D29 mede como
inexistente. Enquanto ela não vier, o número fica visível no contador.

#### Duas das medidas acima NÃO VALEM, e o próprio D29 já dizia

Voltando ao D29 depois de escrever isto, achei a frase que eu deveria ter lido
antes:

> *"A base anonimizada **não serve** para essa conferência: ela deriva login,
> e-mail, CPF e hash do `id`, então são únicos por construção."*

Conferido no `anonimizar.sql.modelo`, e é literal:

```sql
`login` = CONCAT('user', `id`),
`email` = CONCAT('colaborador', `id`, '@exemplo.invalid'),
`cpf`   = LPAD(MOD(CONV(SUBSTRING(MD5(CONCAT('ucpf', `id`)), 1, 10), 16, 10), ...
`hash`  = MD5(CONCAT('hash-unidade-', `id`))
```

Então o "**0 duplicatas**" que registrei para `usuarios.login` e
`usuarios.email` **não mede o sistema: mede a anonimização.** Ali eu contei
linhas que são únicas porque foram construídas assim.

**As outras medidas continuam de pé**, e dá para dizer por quê sem depender de
confiança: a anonimização toca nove tabelas — `agendas`, `agendas_exclusoes`,
`clientes`, `notificacoes`, `push_app`, `unidades`, `unidades_socios`,
`usuarios`, `visagismo` — e **nenhuma** das outras chaves que medi mora nelas.
Em `push_app` ela mexe em título, conteúdo e destinatário, e não no `cronjob`,
que foi o que contei.

O que fica sem resposta é justamente o caso mais sensível do D29: **login,
e-mail, CPF e o `hash` que autentica as rotas `/api/unidade/vendas*`**. Esses só
a réplica de produção responde, e ela está congelada pela mesma decisão de
23/08 que congelou o resto.

A decisão sobre o veredito não muda — `ORDER BY` continua sendo o conserto
errado para credencial —, mas o argumento perdeu uma perna, e prefiro registrar
isso a deixar um número que parece medida e não é.

#### O que isto não conserta

A duplicidade em si. Enquanto não houver restrição de unicidade em
`royalties (unidade, data)`, duas linhas podem voltar a aparecer — o que mudou é
que a escolha entre elas deixou de ser sorteio.

A restrição é trabalho de migração, não de código: exige decidir o que fazer com
os 87 pares que já existem, e isso é dado de produção. Fica como o primeiro caso
concreto do **D29** com nome, número e data.

---

### D111 — O fechamento da agenda era validado no navegador, e o servidor aceitava o que viesse — CORRIGIDO em 13/09/2026; a frase e a `RegraDeFechamento` em 05/10/2026 — PARCIAL, lido em 05/10/2026 — PARCIAL em 12/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decidir o teto de dias da tela antiga; limpar ou corrigir em produção, na janela do corte, as 1.664 linhas sem motivo ou invertidas.

Achado em 05/09/2026, ao escrever o endpoint equivalente da Fase 2.

`AgendasController::fecharAction()` faz isto:

```php
$retorno['mensagem'] = $this->validarFormularioFechar($request, $colaboradores);
...
$retorno['sucesso'] = $this->service->fechar($request, $colaboradores);
```

**Ele calcula a validação, guarda numa variável e fecha assim mesmo.** Quem
decide é o JavaScript: `mod-cadastros.js` chama `fecharValidar` primeiro e só
chama `fechar` se a resposta permitir — ou se o operador confirmar o aviso de
"existem N agendamentos no período".

Quer dizer: **a regra existe, roda, e não é obedecida pelo servidor.**

#### O que um pedido direto consegue

`POST /mod-cadastros/agendas/fechar` sem passar pelo primeiro passo:

| campo | o que acontece |
|---|---|
| `motivo` vazio | o fechamento é criado sem motivo |
| `horario_inicio` inválido | `stringToDate` devolve **`false`**, e o serviço chama `format()` nele — erro fatal |
| período de anos | uma linha em `agendas` por horário livre, por colaborador, por dia |

Não é buraco para quem está de fora: exige sessão do ERP e permissão de escrita
na agenda. É integridade — um operador com o formulário aberto em duas abas, um
clique repetido, um script de alguém da casa.

#### O REGISTRO ERRAVA EM DOIS PONTOS, e o segundo é pior

Eu tinha escrito aqui que *"não é buraco para quem está de fora: exige sessão do
ERP e permissão de escrita na agenda. É integridade — um operador com o formulário
aberto em duas abas, um clique repetido, um script de alguém da casa."*

**Não é preciso nada disso. A tela faz sozinha, por um caminho de dois cliques.**

E faltava um defeito inteiro, que nenhum dos dois achados registrados mencionava.

#### O CAMINHO PELA TELA, e ele é curto

`validarFormularioFechar` devolve `horarios` — quantos agendamentos há no período —
junto da mensagem. Quando falta **só o motivo**, as datas são válidas, então a
contagem roda assim mesmo e volta `mensagem = "Informe os campos obrigatórios"`
**com `horarios = 1`**.

E o navegador decide o que mostrar por `response.horarios === 0`:

```js
if (response.horarios === 0) {
    notificacao("warning", response.mensagem);     // um aviso
} else {
    $("#modal_agenda_fechar_confimar .scroller h4").html(response.mensagem);
    $("#modal_agenda_fechar_confimar").modal("show");   // "deseja continuar?"
}
```

Então o operador que esquece o motivo num período que tem agendamento **não vê
"falta o motivo"**: vê uma caixa **ATENÇÃO** com os botões **Não** e **Sim**, e o
texto dentro dela é *"Informe os campos obrigatórios: - Motivo"*. Ele clica em Sim,
`agendaFechar()` dispara, e a agenda fecha **com motivo vazio**, por cima do
agendamento de um cliente.

**Medido no navegador, com o defeito devolvido**, e não deduzido do código: o modal
de confirmação abre, e o `h4` dele traz a frase do campo que falta.

E não há nada antes disso que segure: `motivo` é um `Text` puro no formulário — o
asterisco do rótulo é enfeite —, **nenhum campo tem `required`**, e o botão Salvar
é `type="button"`, então a validação do HTML nunca roda. Conferido no DOM vivo.

#### O SEGUNDO DEFEITO, que já estava registrado: a data com traço

O mesmo serviço lê data de dois jeitos:

| método | helper | aceita |
|---|---|---|
| `agendar()` | `dataHoraBr_to_dateTime` — recorta por posição | `24-12-2026` **e** `24/12/2026` |
| `fechar()` | `stringToDate` — `createFromFormat("d/m/Y")` | **só** `24/12/2026` |

E `createFromFormat` não lança: devolve `false`. Passar traço para o `fechar()`
produz `Call to a member function format() on bool` três quadros adentro do
serviço, longe de quem escolheu o separador. A tela nunca viu porque o
formulário manda barra nos dois casos; eu vi na primeira execução do teste novo.

**Ele era pior do que "recusa feia".** Medido em 13/09: o pedido responde **HTTP
500** — e responde depois de **já ter gravado** o fechamento, porque o `flush` vem
antes do cálculo das datas. Erro de servidor para quem pediu, e uma linha órfã no
banco.

#### E O TERCEIRO, que não estava: o fim antes do início

**Não há, em lugar nenhum desta tela, uma conferência de que o fim venha depois do
início.** E o serviço **grava o fechamento ANTES** de calcular as datas do período:

```php
$this->em->persist($fechamento);
$this->em->flush();                                    // <- já gravou

$datas = DataHora::selecionarDatasNoPeriodo(...);      // <- e só agora calcula
```

Com o período invertido, `selecionarDatasNoPeriodo` devolve lista vazia, o
fechamento fica gravado **sem bloquear um único horário**, e a tela responde
*"Agenda fechada com sucesso"*. A agenda continua aberta e ninguém é avisado.

#### QUANTO ISSO CUSTOU, medido na cópia de produção

`agendas_fechamentos` tem **134.334 linhas**, de 2020 a 2026 — e a tabela está
inteira nesta cópia, conferida pelo portão de completude do D181.

| | |
|---|---:|
| fechamentos com **motivo vazio** | **99** |
| fechamentos com **o fim antes do início** | **1.565** |
| fechamentos que não bloquearam **nenhum** horário | 2.762 |
| destes, os que têm o fim antes do início | **1.565 — todos** |

**Os 1.565 são exatamente os 1.565.** Todo fechamento de período invertido não
bloqueou nada; 1,17% dos fechamentos da rede foram gravados e a agenda seguiu
aberta. E não é resíduo antigo: são **117, 268, 270, 204, 273, 287 e 146** por ano,
de 2020 a 2026 — ritmo estável, porque a causa é digitação (`14:30` para `14:25`).

**Os 99 sem motivo aceleraram:** 1, 1, 1, 1, 4, e então **55 em 2025 e 36 em 2026**.
Noventa e um dos noventa e nove são dos últimos dois anos. A coluna é
`varchar(100) NOT NULL`, então quem chegou ali passou pelo controlador.

E os extremos dizem que ninguém conferia nada:

| id | período | dias | motivo |
|---|---|---:|---|
| 122435 | **1924-11-27** → 1924-12-27 | 30 | `entrevista Santiago` |
| 169503 | 2021-05-22 → 2026-05-22 | **1.826** | `EMERGENCIA PEGOU FOGO NA CAS…` |
| 111562 | 2024-07-27 → **2032-02-03** | **2.747** | `treinamento` |

Um `2024` digitado como `1924`. Cinco anos de agenda fechados numa chamada — e
fechar grava **uma linha por horário livre, por colaborador, por dia**: 1,16 milhão
das 4 milhões de linhas de `agendas` são bloqueio, com **máximo de 10.401 numa
chamada só**. A API nova põe teto de 92 dias; a tela não tem teto nenhum, e **29
fechamentos passam desse teto**.

#### A objeção que me travou — e a resposta estava escrita dentro dela

Pôr a validação no `fechar()` mudaria o comportamento da tela: hoje ela conta
com o servidor **não** recusar, porque a confirmação de "existem N agendamentos"
já aconteceu no navegador. Um servidor que passasse a recusar quebraria
justamente o caminho feliz de quem confirmou.

O conserto certo é o fluxo de dois passos virar um: o pedido carrega a
confirmação, e o servidor decide. **Foi o que a API nova fez** —
`POST /adm/api/agendas/fechamentos` valida no servidor e responde **409** com a
contagem quando há agendamentos no período; o segundo pedido vem com
`"confirmar": true`.

**E era só dizer o mesmo à tela antiga.** A objeção estava certa e eu parei um passo
antes da saída: a tela **já pergunta** "deseja continuar?" ao operador, e
`agendaFechar()` só é chamada em dois lugares — quando não houve nada a ressalvar,
e quando ele clicou em Sim. Nos dois, a confirmação já aconteceu. Ela só **não era
contada ao servidor**. Foi a mesma forma do D180, fechado no dia anterior pelo
desenho da tela irmã: o desenho pronto já estava na casa.

#### COMO FICOU

**Três arquivos, nenhum deles em produção, e nenhuma mudança de schema.**

`AgendasController::fecharAction()` passou a **obedecer**. Onde havia

```php
$retorno['mensagem'] = $this->validarFormularioFechar($request, $colaboradores);
$retorno['sucesso'] = $this->service->fechar($request, $colaboradores);
```

há agora uma recusa antes do serviço, e **só uma** das recusas é confirmável:

```php
if ($validacao['sucesso'] !== true && (!$validacao['confirmavel'] || empty($request['confirmar']))) {
    return new JsonModel([...]);   // e o serviço não é chamado
}
```

`validarFormularioFechar()` ganhou três coisas: **formato de data** (`dataValida`,
que já existia na casa e ninguém chamava aqui), a conferência de **fim antes do
início**, e `horarios` saindo **zero** quando a recusa é de campo — que é o que tira
o erro de campo do modal de confirmação. E `confirmavel` só é verdadeiro na recusa
de "há N agendamentos".

`mod-cadastros.js` passou a mandar `confirmar: 1`, que é a confirmação que ele já
tinha na mão. **É a única peça que encosta na tela**, e é honesta nos dois
chamadores.

De quebra, `$retorno['mensagem']` deixou de receber o **array inteiro** da
validação. Ninguém tinha visto porque o servidor nunca recusava — o
`notificacao("warning", response.mensagem)` do JS mostraria `[object Object]` no
primeiro dia em que recusasse.

#### O PORTÃO: `scripts/ensaiar-fechamento-da-agenda.php`

**30 asserções em 10 casos**, e ele é o único ensaio da casa que faz POST em
`/mod-cadastros` — os outros cinco medem `/adm/api` ou as rotas públicas.

Ele escreve, então só roda no banco descartável, e tem guarda própria: mais de dez
unidades e ele se recusa a rodar, dizendo por quê. A limpeza está num `finally` e
apaga **tudo o que tem fechamento** — não só o que o caminho feliz criou. Foi a
lição que o ensaio do D184 cobrou: enquanto o defeito estiver de pé, os casos de
**recusa** também gravam, e uma limpeza que só soubesse desfazer o caminho certo
deixaria seis linhas para trás na primeira falsificação. Ele confere o resíduo antes
de sair.

**Duas asserções não olham para um caso, e sim para o banco inteiro:** nenhum
fechamento pode ficar sem motivo, e nenhum com o fim antes do início. São as duas
assinaturas dos 99 e dos 1.565. Se uma reaparecer, reapareceu.

#### FALSIFICADO EM CINCO SENTIDOS, e mais uma vez na tela

| o que se estraga | o que o ensaio faz |
|---|---|
| o servidor volta a não obedecer | vermelho nos casos **2, 2b, 3, 4, 5, 6** e nas duas assinaturas |
| some a conferência de período invertido | vermelho no **5**, e na assinatura dos 1.565 |
| some a conferência de formato de data | vermelho no **4** |
| `horarios` volta a vazar junto do erro de campo | vermelho no **1** |
| tudo vira confirmável | vermelho no **2b** |

**O caso 2b existe por causa do conserto, e não do defeito.** Como o JavaScript
passou a mandar `confirmar: 1` em todo pedido, se `confirmavel` deixasse de
distinguir a tela voltaria a passar por cima de tudo — e nenhum outro caso
perceberia. Ele manda `confirmar` junto de um motivo vazio e exige a recusa.

**E a tela foi aberta no navegador**, porque *curl verde não prova a tela*. Com o
defeito devolvido, o modal **ATENÇÃO** abre com os botões Não e **Sim**, e o texto
dentro dele é *"Informe os campos obrigatórios: - Motivo"*. Com o conserto, o mesmo
gesto não abre modal nenhum, e a confirmação legítima — período com agendamento e
motivo preenchido — continua abrindo, com `confirmavel: true` e a frase certa.

#### UMA LINHA NAS FIXTURES, e o motivo dela

`gerente.teste` ganhou **`Agendas gerenciar`**. Sem ela, os trinta casos batiam num
404 do `notFoundAction` e o roteiro dizia que nada fora gravado — **verdade, e pelo
motivo errado**: eu estaria medindo a guarda, não o fechamento. As fixtures dizem,
no próprio comentário, que a lista é "só os que o ensaio usa"; ela cresce quando um
ensaio novo precisa, e não além disso. O colaborador continua sem privilégio nenhum,
que é o que faz o ensaio de permissão medir o que diz medir.

**E a casa cobrou a conta na hora.** Antes de mexer eu varri o repositório atrás de
quem dependesse da ausência dessa linha, procurando por `90002001` e por
`Agendas gerenciar` — e não achei nada. **Achei porque procurei o nome errado:**
`PrivilegioApiTest::testOCenarioTemSetePrivilegiosETodosSemMolde` não cita o
privilégio, **conta as linhas** — e afirmava sete. A suíte ficou vermelha com
*"Failed asserting that actual size 8 matches expected size 7"*.

O teste está certo e fez o que existe para fazer: congelar o cenário e falar
quando ele muda. O número foi atualizado — e o nome do método junto, porque
`testOCenarioTemSete…` seria nome que mente — **porque a mudança foi deliberada, e
não porque o vermelho incomodava**. A diferença entre as duas coisas é o registro
que está escrito agora no próprio teste.

#### O QUE NÃO FOI FEITO, e por quê

- **O teto de dias.** A API nova recusa períodos acima de 92 dias; a tela não tem
  teto, e 29 fechamentos passam disso — inclusive os que parecem legítimos
  (`EMERGENCIA PEGOU FOGO NA CASA`, cinco anos). Pôr teto aqui **mudaria o que
  operadores fazem hoje**, e isso é decisão de produto, não conserto de defeito.
- **A mensagem `"- Horário fim início"`**, que é claramente um erro de digitação de
  quem escreveu a validação. Fica como está: mexer em texto que o operador lê não é
  o que este débito pediu.
- **Extrair uma `RegraDeFechamento`**, como a casa fez com `RegraDeAgendamento`. É o
  movimento certo um dia, e é refatoração — o conserto cabe onde o defeito está.

#### O QUE CONTINUA ABERTO

**As 1.664 linhas que já estão lá.** Os 99 fechamentos sem motivo e os 1.565 de
período invertido são passado: a porta fechou, o que passou por ela continua
gravado. Limpar ou corrigir exige tocar produção, e por decisão de 11/09/2026 isso
acontece dentro da janela do corte.

E vale dizer o que a correção **não** devolve: os 1.565 não bloquearam horário
nenhum, então não há agenda presa a destravar. O estrago deles foi de outra
natureza — **alguém pediu para fechar a agenda, leu "fechada com sucesso", e ela
não fechou**.

**O ensaio está no CI e ainda não rodou lá**, porque o CI está parado desde as 21:01
de hoje por cobrança da conta (D187). Ele foi rodado à mão, 30 de 30, e a
falsificação também. *Cobertura escrita no CI não é cobertura executada* — e esta,
por enquanto, não foi.

#### 05/10/2026 — DOIS DOS TRÊS RESTOS, e o terceiro continua com o dono

Dos três itens de "O que não foi feito", dois não pediam decisão e saíram agora; o
teto de dias continua sendo decisão de produto, e não mudou.

**A frase.** O horário de início inválido dizia *"- Horário fim início"*: agora diz
*"- Horário início inválido"*. Nenhum teste nem ensaio contava com o texto (procurado
em `tests`, `scripts`, `module`, no JS e na tela nova).

**A `RegraDeFechamento`.** A validação saiu de `AgendasController` para
`ISCadastro\Regra\RegraDeFechamento::conferir`, como a do agendamento virou
`RegraDeAgendamento`; `validarFormularioFechar` ficou com uma linha, para os dois
chamadores (`fecharValidar` e `fechar`) não mudarem. O motivo de sair não é estética:
privada no controller, só o HTTP a alcançava — e o único ensaio dela,
`scripts/ensaiar-fechamento-da-agenda.php`, **fecha a agenda de verdade** no banco em
que roda.

**Sem mudar o que ela diz, e isso foi medido antes de mover.**
`tests/Caracterizacao/FechamentoDaTelaAntigaTest.php` despacha `fecharValidar` e `fechar`
em processo (`ControllerEmProcesso`), dentro da transação da suíte. Os nove casos de
caracterização — os cinco campos vazios em ordem, a data com traço, o horário de fim
inválido, o período invertido sem contar, o D111 do motivo faltando com a contagem
zerada, um e dois agendamentos (a frase, o número e `confirmavel`), o período livre e
a confirmação que só vence a recusa confirmável — **rodaram verdes contra a função
privada antes da mudança**, nos dois bancos, e continuaram verdes depois. Arrancados
na regra já movida, o período invertido e o `horarios` zerado na recusa de campo deixam
cada um o seu caso vermelho.

O que mudou junto, de propósito, é a metade da tela antiga do D215 (ver lá).
---

#### 12/10/2026 — O TETO DE 92 DIAS NA TELA ANTIGA — pela resposta 109 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 109** (a opção "a"): *"O mesmo teto de 92 dias da tela nova no fechar a agenda da tela antiga; o
fechamento longo de verdade se faz em partes, ou desativando o colaborador; o erro de digitação para no ato."*

**O conserto.** `RegraDeFechamento::DIAS_NO_MAXIMO` (92) passou a ser O teto -- a API o lê de lá
(`Adm\AgendasController::DIAS_NO_MAXIMO`), um número só para as duas portas do mesmo fechamento --, e a regra da
tela antiga o confere com a conta da API: `diff()->days` entre o início e o fim, com as horas. Passando dele, a
validação responde *"O período passa do máximo de 92 dias por fechamento: feche em partes, ou desative o
colaborador."*, com `horarios` zero (o aviso, e não o "deseja continuar?") e `confirmavel` falso: nenhum "sim"
passa por cima, e o `fechar` com a confirmação recusa do mesmo jeito. A contagem de agendamentos não roda sobre o
período longo. O JS não mudou: a recusa de campo já ia para o aviso.

**O que resta, e por isso PARCIAL:** as 1.664 linhas que já estão lá (os 99 sem motivo e os 1.565 de período
invertido), e agora também os 29 fechamentos acima do teto, são passado -- limpar ou corrigir é na produção, na
janela do corte, como antes.

**Provado:** `FechamentoDaTelaAntigaTest` ganhou 3 casos, nos dois bancos -- 93 dias recusam sem contar e sem ser
confirmáveis; o `fechar` com a confirmação, além do teto, não grava; 92 dias inteiros cabem. Os 15 de antes seguem
verdes. Mutantes, um de cada vez, restaurados pelo sha256: sem o teto, os dois primeiros ficam vermelhos (M15); com
o 92º dia contado (`>=`), o terceiro (M16). **Não rodei o `ensaiar-fechamento-da-agenda.php`**: ele fecha a agenda de
verdade no banco em que roda.

### D112 — Quinze dos dezenove endpoints novos paravam na guarda, e a medição dizia "nenhum quebrou" — CORRIGIDO em 05/09/2026

Achado em 05/09/2026, relendo o medidor de `/adm/api` depois de a fatia da
agenda fechar.

O medidor chama os dezenove endpoints da Fase 2, um a um, com sessão real, no
PostgreSQL, a cada push. No fim ele imprimia uma linha:

```
  Nenhum endpoint quebrou.
```

A frase é verdadeira e **se lê maior do que é**. Esta era a contagem real:

| o que a resposta era | quantos | o que aquilo prova |
|---|---|---|
| 2xx | **4** | a rota, o verbo, a sessão, a ACL **e o trabalho** |
| 4xx (422, 404, 409) | **15** | a rota, o verbo, a sessão, a ACL e a primeira guarda |

E dos quatro 2xx, **dois vinham vazios** — o que reduz o que a medição
efetivamente exercitava a **dois** dos dezenove.

Quinze dos dezenove paravam na primeira linha do controlador: `POST` com corpo
vazio dá 422, `:id` de recurso que outro endpoint já apagou dá 404, comanda
fechada dá 409. **Nenhuma delas é defeito** — são as respostas certas, e não há
o que consertar na lista. O problema é aritmético: quem lesse "nenhum endpoint
quebrou" concluiria "os dezenove funcionam", e a medição sustentava um quinto
disso.

É a mesma correção que o D103 fez no medidor de telas: o número sozinho parecia
cobertura, e não era. **O medidor não mudou de veredito — ele passou a publicar
o denominador**, e a nomear os quinze.

#### E o denominador, publicado, achou uma cegueira de verdade

Duas leituras respondiam 200 com **lista vazia**, e um 200 vazio é pior que um
4xx: ele entra na conta dos que "chegaram ao trabalho" sem que o formatador do
recurso tenha rodado uma vez.

| leitura | por quê |
|---|---|
| `GET /adm/api/agendamentos` | sem `data`, o padrão é **hoje**, e o cenário tem agendamento em 24/12/2026 |
| `GET /adm/api/comandas` | lista comandas **abertas** (`status = 3`), e a venda do cenário tem `status = 0` |

A primeira já tinha custado caro: foi exatamente assim que a primeira versão do
recurso de agenda **passou na medição sem nunca ter rodado**, trazendo dentro
dois defeitos que só apareceram quando um teste lhe deu uma linha de verdade
(`checkin`/`checkout` tratados como instante, e um mapa de quatro estados para
uma coluna booleana).

A segunda ninguém tinha visto: `GET /adm/api/comandas` — da **primeira** fatia,
medida desde que o medidor existe — nunca formatou uma comanda. O trabalho dela
está coberto por `ComandaApiTest`, que cria a própria comanda aberta; a medição
HTTP é que era cega.

#### O que mudou

1. **Leitura antes de escrita.** A varredura era alfabética, e
   `DELETE /adm/api/agendamentos/:id` rodava antes de `GET /adm/api/agendamentos`
   levando embora a única linha que a leitura teria. Os `GET` passaram a vir
   primeiro. As escritas continuam interferindo umas nas outras — isso é o preço
   de medir escrita num banco só, e segue escrito no cabeçalho do medidor.
2. **Query de leitura**, só para `GET`: a data do cenário, e o colaborador dos
   horários livres. Mandar corpo válido nos `POST` seria inventar cenário e
   transformar o medidor num teste que escreve; um `GET` não inventa nada —
   pergunta pelo dia em que o cenário tem dado, em vez de perguntar por hoje.
3. **Uma comanda aberta nas fixtures** (`vendas` 90001001, `status = 3`), sem
   item e sem pagamento. A venda 90001000 continua fechada, porque é cenário de
   instantâneo de contrato congelado e de relatório: mexer no status dela mudaria
   o que dezenas de medidas veem, para consertar uma.
4. **A data da comanda passou a ser a de hoje** (`CURRENT_DATE + INTERVAL '9'
   HOUR`, como já é a do caixa). Com data fixa em janeiro a linha nascia
   vencida: a medição roda as rotinas de cron **antes** dos endpoints,
   `comandasCancelar()` cancela toda comanda aberta há mais de três dias, e a
   listagem voltava a sair vazia — agora não por cenário errado, mas porque o
   sistema fez o que devia. Comanda aberta só existe enquanto é recente.
5. **`PRECISA_TRAZER`**, a guarda que fecha o buraco de vez: as três leituras
   **têm** de trazer linha, e coleção vazia reprova com "A MEDIÇÃO FICOU CEGA".
   Sem ela, bastaria alguém mover a data do cenário para tudo voltar ao 200 vazio
   sem aviso. **Foi essa guarda que achou a comanda**, na primeira vez que rodou.

#### Como ficou

```
  Chegaram ao trabalho (2xx): 5 de 19. Os outros 14 pararam na guarda.
```

Cinco — e nenhum deles vazio, que é a metade que importa da correção. Cinco, e
não dezenove: é este o ponto. O medidor cobre a **borda HTTP** dos
dezenove nos dois bancos, a cada push; quem cobre o trabalho dos treze é a suíte
de caracterização. Não adianta "melhorar" o número mandando corpo válido em cada
`POST`: o número é para ser lido, não para ser subido.

---

### D113 — Trinta e um relatórios verdes, e nenhum deles precisava mostrar nada — CORRIGIDO em 06/09/2026 (23 de 31 com marca) — PARCIAL, lido em 05/10/2026 — PARCIAL em 06/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: marca de dado nos três restantes: o XLS legado de atendimentos (OLE2), o TXT do NPS e o Consultor Visagista, este adiado.

Achado em 06/09/2026, fazendo ao medidor de relatórios a mesma pergunta que o
D112 fez ao de `/adm/api`: *o que este número prova?*

O medidor chama os 31 relatórios a cada push, nos dois bancos, e termina com
"Nenhum relatório quebrou". Ele confere status, tipo de conteúdo, exceção no
corpo e as bordas do binário (D107/D108). **Não confere que o relatório trouxe
uma linha.**

Sete artefatos inspecionados à mão, e os sete sem dado nenhum:

| artefato | o que tinha dentro |
|---|---|
| `rel-vendas/index` | o formulário de filtro |
| `rel-aniversariantes/index` | o formulário de filtro |
| `rel-clientes/index` | "Nenhum registro localizado" |
| `rel-clientes/xls` | uma linha: o cabeçalho |
| `rel-clientes/txt` | **zero bytes** |
| `rel-radar-temperatura/selecionarFaturamento` | `{"faturamento1":0,"faturamento2":0,...}` |
| `rel-fluxo-movimento/gerarGrafico` | sete dias, todos zero |

O medidor existe (D104) porque *"é justamente ali que os dois bancos mais
diferem"* — agregação com data, `GROUP BY`, ordenação por coluna anulável. Uma
consulta que não devolve linha não exercita nada disso. E a comparação entre os
dois bancos, que compara 190 artefatos e acusou uma única diferença, estava em
boa parte comparando formulários e zeros: **dois vazios são sempre idênticos.**

#### Por que o de clientes vinha vazio, e são duas causas

A primeira é do sistema, e é legítima: `filtrosListagemIndexRelatorio()` filtra
por `uni.id = :unidade` onde `uni` é `LEFT JOIN cli.ultimaVisitaUnidade` — a
unidade da **última visita**, e não a de cadastro. Cliente que nunca visitou não
aparece em relatório nenhum, e nenhum cliente do cenário tinha visita.

A segunda é do próprio medidor, e é a mais instrutiva. Ele manda um saco único de
parâmetros para os 31, e nele vão `ultimo_acesso_app_inicio` e `_fim`. O filtro
correspondente é:

```php
$dql .= " AND DATE(cli.ultimoAcessoApp) >= :acessoInicio";
```

Com a coluna **nula**, a comparação dá nulo e a linha sai do resultado. O medidor
se excluía sozinho — sem erro, sem aviso, e sem nada que distinguisse "não há
cliente" de "eu mesmo apaguei o resultado".

#### E, no caminho, um defeito de produção

Assim que o relatório teve a primeira linha para imprimir, apareceu isto:

```
Deprecated: trim(): Passing null to parameter #1 ($string) is deprecated
  in ISRelatorio/src/Controller/ClientesController.php
```

`trim($cliente['telefone'])` com telefone nulo, no PHP 8.1. É a família do D100:
o aviso vai **impresso no meio do arquivo baixado**, com o cabeçalho já mandado
em 200. Ninguém tinha visto porque este relatório nunca havia chegado a imprimir
uma linha. Corrigido com um molde para `string`.

#### O que mudou

1. **Um cliente de fixture com histórico** (`clientes` 90001010, "Cliente
   Histórico Teste"): última visita, unidade e colaborador da visita, telefone,
   e-mail e último acesso ao app. Cliente **novo**, e não histórico no
   `Cliente Teste`: aquele é o cliente *sem passado* do cenário, e três testes
   dependem disso — com `ultima_visita_colaborador` preenchido nele,
   `FidelizacaoTest::testPrimeiraVisitaNaoFidelizaOColaborador` fica vermelho,
   porque a "primeira" visita passa a ser a segunda com o mesmo colaborador.
2. **`PRECISA_MOSTRAR`**, no medidor: um mapa de relatório → marca, e a marca é
   um valor do cenário que **só aparece se uma linha foi impressa**. Aqui é o
   telefone: está na coluna Telefone da tabela e no arquivo do TXT. O nome não
   serviria — "Cliente Teste" aparece também nas listas de seleção do
   formulário, e uma marca dessas ficaria verde com a tabela vazia.
3. **O denominador publicado**: `Com marca de dado conferida: N de 31`, seguido
   da frase que impede a leitura larga — nos outros, o que o medidor prova é que
   não quebram, não que mostrem alguma coisa.

#### A segunda rodada: de dois para nove

O denominador publicado é para ser reduzido, e a primeira redução veio no mesmo
dia. Sete relatórios entraram, e o que cada um custou diz bastante sobre por que
eles estavam vazios:

| relatório | por que estava vazio | o que resolveu |
|---|---|---|
| `rel-produtos/index` | filtra por `usu.unidade` — as duas vendas concluídas do cenário são da unidade 90001100 | uma venda concluída na 90001000 |
| `rel-descontos/index` | a listagem é enraizada no **item** (`pro.valorDesconto > 0`) e ainda exige `ven.descontoTotal > 0` | desconto nos **dois** lugares, na mesma venda |
| `rel-caixas/index` | o caixa existia e não tinha venda nenhuma | a mesma venda |
| `rel-vendas-canceladas/index` | **já tinha dado** — faltava a marca | a venda de `status = 0` que sempre esteve lá |
| `rel-indicacoes/index` | **já tinha dado** — faltava a marca | a indicação com comissão que sempre esteve lá |
| `rel-produtos/retiradas` | `produtos_estoque` estava vazia no cenário | uma retirada (`operacao = 'sai'`) |
| `vip-class-relatorio/index` | `vipclass` estava vazia no cenário | um curso, `status = 1` |

Dois dos sete **já mostravam dado** e ninguém sabia: a diferença entre "não
mostra" e "não perguntamos" só apareceu quando a pergunta passou a existir.

Uma venda concluída na unidade certa acendeu três relatórios de uma vez — o que
também explica o tamanho do buraco: **o cenário não tinha uma única venda
concluída na unidade que o medidor pede.**

E uma armadilha de tipo, no caminho: `vipclass.status` é **inteiro** nos dois
bancos, e não booleano como quase todo `status` deste schema. `TRUE` ali derruba
a carga no PostgreSQL — o D81 pelo avesso.

#### A terceira rodada: de nove para treze, e a planilha aberta por dentro

Mais quatro, e dois deles por um caminho diferente do texto.

**Os dois de franquia** passaram a imprimir o **nome da unidade** — a coluna
"Unidade" saía em branco porque `unidades.nome` era nulo no cenário. As listas de
seleção do ERP não usam essa coluna (`popularCombobox()` concatena país, estado,
cidade e bairro), então o nome só aparece na resposta se houve linha de dado: é
marca estável. A alternativa que existia era "horários disponíveis", um número
que muda todo ano porque o período pedido é relativo ao ano corrente.

**As duas planilhas** não aceitam marca de texto — o conteúdo vai comprimido
dentro do zip. Mas dá para **abrir o zip e contar as linhas** da primeira folha,
que é pergunta exata e não palpite sobre bytes. Duas linhas é o mínimo:
cabeçalho mais uma de dado. Isso vale para toda resposta XLSX, e não para uma
lista de escolhidos — planilha de relatório sem nenhuma linha de dado não é
resposta certa em caso nenhum.

É a mesma armadilha do TXT de zero bytes, e pior: **o XLSX de clientes saía com
6.614 bytes e uma única linha, a dos títulos.** Tamanho não distingue planilha
vazia de planilha cheia; contar linhas distingue.

#### A quarta rodada: o NPS, e um cache que nunca era escrito

**`rel-pesquisas-nps/index`** ganhou marca: uma linha em `nps_resultados_unidades`
com `total_pesquisas_realizadas = 4321`, um número que não aparece em nenhum
outro canto da página. O rótulo da unidade não serviria — ele é impresso mesmo
com os doze meses vazios, que era exatamente o estado anterior. O ano vem de
`EXTRACT(YEAR FROM CURRENT_DATE)`, que os dois bancos entendem: o medidor
pergunta pelo ano corrente, e um `2026` fixo envelheceria em janeiro.

E, no caminho, uma coisa que parecia defeito e não era. O relatório de
atendimentos respondia **"Dados indisponíveis."** porque o cache do cron nunca
tinha sido escrito para a unidade do cenário:

```php
// selecionarUnidadesRelatorioFranquias($id, $data)
$dql .= " AND DATE(u.relatorioAtendimentos) < '" . $dataRelaAten . "'";
```

Com a coluna **nula**, a comparação é nula e a unidade nunca entra — então o
cache nunca é escrito, e a rotina que o escreveria é a única que preenche a
coluna. Parece um impasse de bootstrap, e não é: **o construtor de `Unidade` já
grava `now` nessa coluna**, então unidade criada pelo ERP nasce com data.
Conferido na cópia de produção em 06/09/2026 — das 35 unidades ativas, as três
sem data eram as fixtures deste repositório, inseridas por SQL direto.

Corrigida a fixture, o cache passou a sair com conteúdo real (um cliente
atendido em março, R$ 79,90) em vez de não existir. Isso importa além do
relatório: **o cache entra na comparação entre os dois bancos**, e comparar um
arquivo que não existe dos dois lados não prova nada.

Os demais caches do cron continuam saindo `[]` — dois bytes. O de evasão, por
exemplo, filtra colaboradores por `usu.grupo = 1`, um id fixo no código que os
usuários de fixture (grupos 90001000/90001001) não têm.

#### A quinta rodada: as tabelas de dashboard, e três respostas que eram zeros

Três dos que faltavam não liam vendas: liam **tabelas de dashboard**, escritas
por rotinas de cron e vazias no cenário. Uma linha em cada acendeu três
artefatos:

| artefato | tabela | o que respondia antes |
|---|---|---|
| `rel-fluxo-movimento/gerarGrafico` | `dashboard_movimentos` | JSON com **zero nos sete dias** |
| `rel-radar-temperatura/selecionarFaturamento` | `dashboard_colaboradores` | `{"faturamento1":0,"faturamento2":0,"faturamento_per":0}` |
| `rel-radar-temperatura/index` | `dashboard_colaboradores` | a página inteira com zeros e `0%` |

Duas coisas que a marca ensinou, e que valem além destes três:

**O período é relativo ao ano corrente**, nos três. `dashboard_movimentos` é
consultado por `dataInicio >= :inicio AND dataFim <= :fim`, e
`dashboard_colaboradores` por `ano * 100 + mes` — datas fixas sairiam da janela
com o tempo, então as fixtures usam `CURRENT_DATE` e `EXTRACT(... FROM
CURRENT_DATE)`, que os dois bancos entendem.

**A marca acompanha o que a API diz, não o que a fixture guarda.** O
faturamento gravado é `2222.22` e a resposta traz `2222`: o valor é arredondado
no caminho. Marcar `2222.22` teria dado uma reprovação eterna sobre um endpoint
correto.

#### A sexta rodada: o PDF abre, e o 404 era falta de um id

Dois que estavam listados como impossíveis não eram.

**O PDF aceita marca.** Três rodadas atrás escrevi que "formato comprimido não
aceita marca de texto". Aceita: o FPDF grava o conteúdo em fluxos `FlateDecode`,
e inflá-los é a mesma ideia de abrir o zip da planilha para contar linhas. O PDF
de clientes traz o telefone da mesma linha que o TXT e a tela — e agora a marca
é procurada no texto inflado quando o tipo é `application/pdf`.

**E um 404 que não era do sistema.** `vip-class-relatorio/relatorio` pede `:id`
na rota; o medidor chamava sem id, o `find()` não achava nada e a ação
respondia 404 — resposta legítima, aceita pela medição. Ficava na lista como se
tivesse sido medida, e o que se media era a guarda, não o relatório. Com o id do
curso de fixture na rota, responde 200 e imprime a ficha.

Isso vale além deste caso: **um 404 numa lista de relatórios pode ser a ação
recusando o pedido, e não o sistema recusando o dado.** As duas coisas se
parecem no relatório de medição, e só uma delas é notícia.

#### A sétima rodada: o aniversário não é do cliente

Mais dois, e o primeiro custou uma rodada inteira por uma suposição minha.

**`rel-aniversariantes` não lista clientes.** Dei aniversário ao cliente de
fixture, medi, e a página voltou idêntica. O relatório lista **unidades, sócios
e colaboradores** — aniversário e tempo de casa. Com `data_nascimento` no
colaborador, a linha aparece.

E o parâmetro chama-se `mes`, não `mes_aniversario` — esse é o campo do
relatório de *clientes*. Passei o nome errado antes de ler o controlador, e a
página voltou igual outra vez: **parâmetro que ninguém lê não reclama.** É a
mesma classe de silêncio que este débito inteiro persegue, só que do lado de
quem pergunta.

O `mes` fica numa lista de parâmetros **por relatório**, e não no saco comum,
justamente porque o relatório de clientes também leria `mes_aniversario` — e ali
ele viraria um filtro que esvazia a listagem, trocando uma marca por outra.

**`rel-dashboard-faturamentos`** entrou com uma linha em `dashboard_faturamento`.
A marca é `gasto_por_cliente`, e não o ticket médio da mesma linha: a tela
recalcula o ticket e imprime `0,00` — mais uma vez, a marca acompanha o que a
tela diz.

#### A oitava rodada: o dashboard, e o preço de um id inventado

`rel-dashboard-colaboradores` entrou, e custou três execuções por dois motivos
que valem mais que o relatório.

O primeiro: a consulta exige `are.area = 3` — **um id de produção** (barbeiro).
O cenário inventava a própria área, então a tela respondia 200 com a página
montada e nenhuma linha. Ver o **D116**, que saiu deste mesmo achado.

O segundo: a marca. Procurei `2222`, e a página imprime `2.222,22`. Terceira vez
na mesma pedra — a marca acompanha o que a tela escreve, não o que a fixture
guarda.

#### E a lista deixou de prometer trabalho que não existe

Quatro dos que faltavam **não têm tabela própria**: são formulários de filtro e
cascas que o JavaScript preenche. `rel-vendas/index` manda para
`rel-vendas/exportar`; `rel-atendimentos/index` para `rel-atendimentos/gerar`;
`rel-fluxo-movimento/index` busca o gráfico por JS; `rel-dashboard-unidades`
recebe só a unidade e monta o resto no navegador. Elas nunca vão ter marca, e
contá-las junto das que faltam transformava quatro provas existentes numa fila
de trabalho imaginária.

O relatório passou a dizer as três coisas separadas: **23 com marca**, **5 sem
tabela própria** (com o motivo escrito ao lado de cada uma) e **3 que são a
dívida de verdade** — o XLS legado do relatório de atendimentos, que é OLE2 e
não aceita marca de texto; o TXT do NPS, que é segundo passo de um fluxo de
sessão; e o Consultor Visagista, adiado por decisão.

#### O que isto não resolve

**Três relatórios seguem sem essa prova**, e agora sabe-se o que cada um
pediria: aniversariante no mês perguntado, cache de cron preenchido, respostas de
NPS, visagismo (adiado por decisão). Um dos dois PDF entrou na sexta rodada; o de evasão continua fora, porque o
relatório de evasão não tem dado (ver abaixo).

O número está publicado justamente para não ser esquecido. **Três** é uma
dívida escrita; "nenhum relatório quebrou" era uma dívida invisível.

#### PARCIAL em 06/10/2026 -- o XLS legado e o TXT do NPS ganharam marca; resta o Consultor Visagista, adiado

**O XLS legado de atendimentos é lido célula a célula.** `rel-atendimentos/gerar` entrega um XLS do
Excel 97-2003 -- OLE2, com o livro em registros BIFF e as strings em UTF-16 numa tabela à parte --, e
procurar a marca nos bytes não acha nada (medido: zero ocorrências do nome do colaborador no arquivo
cru). O medidor passou a abrir o arquivo com o leitor do PhpSpreadsheet (`textoDaPlanilhaXls`), que só
devolve célula se o arquivo for um XLS de verdade, e procura a marca no texto das células; sem `vendor/`
ou com o leitor recusando, a resposta é "a medição ficou cega", nunca verde. A marca é o NOME do
colaborador no bloco dele ("Colaborador Teste"): o bloco do TOTAL sai mesmo sem colaborador nenhum. O
ano pedido é 2026, e não o corrente: o dado da fixture é a venda de 10/03/2026, escrita como data fixa,
e a rotina escreve o cache de todo ano desde a criação da unidade -- com o ano corrente, a marca falharia
por calendário em 2027. A planilha sai do cache da rotina de atendimentos: o CI e o
`medir-telas-no-postgres.sh` medem as rotinas antes dos relatórios.

**O TXT do NPS faz os dois passos.** O "Gerar TXT" entrega a lista que o detalhe da unidade
(`unidadeDetalhes`, o POST que a tela faz ao clicar no mês) guardou na sessão; pedido sozinho, responde
404 -- e 404 é resposta que este medidor aceita. Agora o medidor faz o primeiro passo na mesma sessão
(`PRIMEIRO_PASSO`), exige `sucesso: true`, e só então pede o TXT; se o primeiro falha, o relatório vai
para a lista dos cegos com o motivo do primeiro passo. E o cenário não tinha pesquisa respondida
nenhuma: entrou uma em `nps` (90001000, cliente 90001010, março de **2023**, `contabilizado` TRUE) -- a
data fixa e o `contabilizado` para não cruzar com os casos que contam o NPS do mês corrente da unidade
nem com a rotina, e os anos de 2020 e 2025 já têm dono nos testes. A marca é o telefone do cliente.

**Medido no PostgreSQL de contrato,** em duas montagens do zero. Na primeira, na ordem do CI (a concessão
de privilégios do passo das telas, as rotinas, as recusas, o fluxo e os relatórios), o medidor de antes
deu **23 de 30** com marca e 3 sem prova; o de agora, ainda com a marca do XLS por escolher, deu 24 (o TXT
do NPS) -- e foi o despejo dessa rodada que mostrou as células da planilha e a marca. Na segunda (a
concessão, as rotinas e o medidor), com a marca no lugar: **25 de 30**, 4 sem tabela própria, e **1** sem
prova e sem motivo estrutural -- o Consultor Visagista, adiado. Saída 0. Sem a concessão de privilégios
do CI, as fixtures dão o gerente sem poder nenhum e os 30 respondem 404: foi o que as duas primeiras
tentativas mediram, e o motivo de a montagem local precisar do mesmo `INSERT` do `ci.yml`.

**Os mutantes,** cada um numa cópia do medidor em `/tmp` do contêiner, o repositório intocado, sobre um
banco montado do zero:

| mutante | resultado |
|---|---|
| o primeiro passo do NPS arrancado (`if (false && ...)`) | saída 1, 24 de 30: `rel-pesquisas-nps/txt` marca ausente |
| o XLS procurado nos bytes, sem o leitor | saída 1, 24 de 30: `rel-atendimentos/gerar` marca ausente |
| a pesquisa da fixture apagada do banco | saída 1, 24 de 30: o primeiro passo ainda responde sucesso (a lista vazia), e o TXT volta ao 404 |

**O que ficou:** o Consultor Visagista, adiado por decisão do dono -- `rel-consultor-visagista/index`
responde e não tem marca, e o `gerarPdf` pede id na rota e fica fora da medição.

---

### D114 — O JSON não era lido, e "o servidor não respondeu" aparecia como se fosse um código HTTP — CORRIGIDO em 06/09/2026

Dois buracos no **instrumento**, achados relendo os medidores depois do D113.
Nenhum dos dois tem um defeito do sistema atrás; os dois faziam a medição
afirmar mais do que ela media.

#### 1. `application/json` caía no ramo do arquivo binário

Os dois medidores decidiam o que fazer com o corpo assim:

```php
$texto = stripos($tipo, 'text/') === 0 || $tipo === '';
```

`application/json` não começa com `text/`. Logo, **toda resposta JSON era
tratada como arquivo binário** — e o único exame que um binário sofre é o das
*bordas* (`%PDF` fora do lugar, `PK\x03\x04` depois do fim), que num JSON nunca
acha nada.

O que isso desligava é justamente a família D100: um `Deprecated:` impresso no
meio da resposta, ou a página de exceção do Laminas devolvida com 200, passava
calado. É a razão de estes medidores lerem o **corpo** e não só o status, e ela
estava desativada em toda resposta sem HTML.

| medidor | respostas JSON afetadas |
|---|---|
| relatórios | 2 de 31 — `fluxo-movimento/gerarGrafico`, `radar-temperatura/selecionarFaturamento` |
| telas | qualquer ação AJAX que a varredura alcance por `GET` |

Agora JSON é lido com as mesmas agulhas do HTML. **Não há achado atrás desta
correção** — o que havia era um exame que nunca rodava. No medidor de telas o
JSON também saiu da conta de "respostas que são arquivo": resposta de AJAX não é
arquivo que alguém baixa, e esse número é publicado.

#### 2. `HTTP 0` não é um status

Numa execução deste mesmo dia, o resumo saiu assim:

```
  telas visitadas: 114
    HTTP 0    24
    HTTP 200    84
    HTTP 404     6
  cobertura: 76 de 253 acoes do inventario (30%)
  A COBERTURA CAIU: 76, e o piso e 82.
```

Zero é o que o cURL devolve quando **não houve resposta**: o servidor não
atendeu. Um quinto dos pedidos não chegou, e o relatório mostrava isso como mais
uma faixa de código, no meio da tabela. O que reprovou a corrida foi o piso de
cobertura — e a mensagem dele fala de menu que mudou de forma e de telas que
deixaram de ser alcançáveis. **Nenhuma das duas coisas tinha acontecido.**

O piso funcionou como rede de segurança, e é para isso que ele existe. Mas um
diagnóstico errado custa o tempo de quem for atrás dele — e, pior, ensina a
desconfiar do piso na próxima vez que ele acusar de verdade.

Agora a varredura para com `exit 2` e diz, antes de qualquer outra conclusão:

```
  O SERVIDOR NAO RESPONDEU a 24 dos 114 pedidos (HTTP 0).
```

Com o ambiente estável a mesma corrida deu 173 telas e cobertura 117 — os
números de sempre.

---

### D115 — O servidor do CI morreu no meio da varredura, uma vez, depois do XLS de clientes — instrumentado em 06/09/2026 — PARCIAL em 07/11/2026: o CI desliga o JIT que o `setup-php` liga (a queda do `php -S` reproduzida com ele num contêiner ARM, e nunca sem ele); falta o runner x86 verde com o conserto, a rodada dos 401 com JIT e a configuração da produção — PARCIAL em 08/11/2026: as cinco planilhas antigas saem com o tamanho; falta o runner x86 verde sem o JIT (o CI parado) e a produção

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como parcial, e o corpo diz que ele está aberto: o defeito segue sem conserto no código. Falta: achar a causa da planilha cortada no transporte e decidir se as ações de planilha passam a mandar Content-Length, com o custo de memória.

> **A causa, em 07/11/2026 (o seguimento da fatia 108):** o trabalhador do `php -S` do runner morre com
> **SIGSEGV** dentro do JIT do opcache, e o JIT só existe no CI. O `shivammathur/setup-php` escreve, em todo
> PHP 8, `opcache.jit=1235` e `opcache.jit_buffer_size=256M` no `99-pecl.ini` (o `src/configs/ini/jit.ini`
> dele), e o `php -S` -- a SAPI `cli-server`, que o `opcache.enable_cli` não cobre -- roda com ele. O PHP 8.1
> nasce sem JIT (o buffer padrão é 0), e é assim no `docker/php/php.ini`.
>
> | o que | evidência |
> |---|---|
> | o runner | execução 42374193312 (o commit 8d1457bf), as DUAS tentativas: o caso 236 do `ensaiar-recusas-adm-api.php` (a planilha dos pacotes, `GET /adm/api/pacotes/exportacao`) recebe HTTP 0 entre 1,3 e 1,8 s depois do 235; o trabalhador que aceitou o pedido não registra `Closing` nem erro, e o servidor segue respondendo pelos outros. O diff da 108 não toca o caminho da planilha; o commit seguinte (04adbf58, execução 42380435126) passou o mesmo caso |
> | a repetição | o job do PostgreSQL na ordem do runner (contrato; a caracterização nas três primeiras; as telas; os ensaios até o de recusas; fora o cron e o fechamento da agenda, as regras da casa) contra um `php -S` de 4 trabalhadores com as opções do `ci.yml` (e o `memory_limit` de 128M do `php.ini-production`), num banco descartável montado pela receita do `ci.yml` -- servidor novo e banco novo a cada rodada |
> | com o ini do `setup-php` (o JIT ligado) | **4 quedas em 12 rodadas**, todas numa planilha: duas no caso 236 e duas no 238 (a planilha pela tela antiga, `/mod-configuracoes/pacotes/exportar`). Nas quatro, o trabalhador virou zumbi com o **sinal 11** (`/proc/<pid>/stat`), sem uma linha no log: a cara do runner. Um servidor sem histórico serviu 300 planilhas seguidas sem cair: a queda depende do que o trabalhador compilou antes, e por isso vai e volta entre execuções |
> | sem o JIT | **nenhuma queda em 13 rodadas** (uma com o PHP do contêiner como está, doze com o ini do `setup-php` mais o conserto), 292 de 292 em todas, e mais 1.200 planilhas seguidas depois de quatro delas |
> | o conserto | `opcache.jit=disable` no `ini-values` dos quatro `setup-php` (vem depois do `jit.ini` no mesmo arquivo, e vale: conferido com as mesmas linhas no contêiner, `opcache_get_status()['jit']['on']` falso), e, antes de cada `php -S` da aplicação, o PHP do runner perguntado se o JIT ficou desligado (com o opcache ligado na linha; sai 1 com ele ligado, conferido com e sem o conserto). O `D115JitDesligadoNoCiTest` exige os dois no texto do `ci.yml` sem comentários |
>
> **O que isto explica de 06/09:** a mesma assinatura -- planilha, `php -S` do runner, morte sem mensagem, uma
> vez e não na seguinte, e o corpo cortado logo depois de 4 KB (o trabalhador morreu no meio do envio). Não
> foi reproduzido aquele pedido; é a explicação mais forte, e a única que o contêiner reproduz. **A pergunta do
> Content-Length** deixa de ser sobre produção: o corte era o trabalhador morrendo, e o JIT não está ligado no
> PHP-FPM do contêiner. A configuração de opcache da produção segue "A CONFIRMAR" (`docs/operacao-atual.md`):
> se alguém tiver posto `opcache.jit_buffer_size` lá, o mesmo trabalhador cai no PHP-FPM (o pedido vira 502,
> e o PHP-FPM sobe outro processo). Não visto: o CI verde com o conserto (nada foi empurrado).

Aconteceu **uma vez**, em 06/09/2026, na primeira execução em que o relatório
de clientes tinha uma linha para exportar.

O que se sabe, e é pouco:

| fato | evidência |
|---|---|
| o servidor respondeu ao XLS e morreu logo depois | `rel-clientes/xls 200 1.1s`, e os 23 pedidos seguintes com HTTP 0 |
| a planilha veio quebrada | o `ZipArchive` não abriu o corpo daquela resposta |
| não houve mensagem de erro | o log do servidor embutido termina sem `Fatal error`, sem `Allowed memory size` |
| não é determinístico | a execução seguinte, com o mesmo código e as mesmas fixtures, serviu o mesmo XLS em **0,1s** e seguiu até o fim |

Sem mensagem, o processo não saiu pelo caminho do PHP: ou foi sinal (SIGSEGV,
matador de memória do sistema), ou foi morte externa. **Um décimo de segundo
contra 1,1 segundo** na mesma requisição é a única outra pista, e ela aponta
para pressão de recursos no runner, não para o dado.

#### O que foi feito, e o que não foi

Não mexi em código de produção. Um defeito visto uma vez, sem mensagem, não
sustenta uma mudança no gerador de planilha — e o suspeito mais óbvio,
`setAutoSize(true)` em 19 colunas, é suspeito por parecer caro, o que não é
evidência.

O que foi feito é **deixar a próxima ocorrência legível**:

1. o servidor do CI registra `SERVIDOR SAIU COM <n>` ao terminar — **139** é
   SIGSEGV, **137** é o matador de memória, **255** é erro fatal do PHP;
2. o passo de diagnóstico pergunta antes se o servidor ainda responde, e filtra
   as linhas de `Accepted`/`Closing` para a mensagem não rolar para fora;
3. quando o `ZipArchive` não abre a planilha, o medidor imprime o tamanho e os
   primeiros 120 bytes: `PK` no começo é zip truncado, e qualquer outra coisa é
   outra coisa;
4. os quatro medidores param com `exit 2` quando algum pedido volta HTTP 0
   (D114), então uma repetição não vai mais chegar disfarçada de erro de
   cenário.

#### Por que isto não é (ainda) um defeito de produção

Em produção e no ambiente de desenvolvimento a aplicação roda sob PHP-FPM, onde
um worker que morre não leva o servidor junto. A mesma requisição, no mesmo
PHP 8.1.34, gerou aqui um XLSX válido de duas linhas. O `php -S` do CI é **um
processo só**, e é isso que transforma a morte de uma requisição em queda da
medição inteira.

Ou seja: pode ser um defeito real que a produção esconde atrás do FPM, ou pode
ser o runner. **A pergunta fica aberta com essas palavras**, e o instrumento
está pronto para respondê-la na próxima vez.

#### Repetiu no mesmo dia, e maior

Horas depois, a varredura de telas: **50 de 148** pedidos com HTTP 0, servidor
morto (`resposta a /: 000`), e de novo nenhuma linha de erro no log — nem o
`SERVIDOR SAIU COM <n>` que a sonda deveria escrever, o que sugere que o
processo não terminou pelo caminho normal.

A mitigação foi tirar do CI a condição que transforma **uma** morte em queda de
tudo: `PHP_CLI_SERVER_WORKERS=4`. Sem essa variável o servidor embutido atende
um pedido por vez, então o que derruba o processo leva junto todos os pedidos
seguintes — e o log para de crescer exatamente onde a causa estaria.

Produção roda PHP-FPM, com vários processos. Com quatro trabalhadores o CI passa
a medir nessa condição, que é a que vale.

**Isto não fecha o D115.** Pedido que morre continua devolvendo resposta
quebrada — foi assim que a planilha apareceu, com 200 e um corpo que o
`ZipArchive` não abre. O que deixa de acontecer é a cascata, que apagava a
evidência junto com a medição.

#### Com quatro trabalhadores, a evidência apareceu

Sem a cascata, o medidor conseguiu descrever o corpo em vez de só constatar a
ausência dele. Duas execuções, dois relatórios diferentes:

| execução | artefato | corpo |
|---|---|---|
| 1ª | `rel-vendas/exportar` | 4.114 bytes, começando em `PK`, com `[Content_Types].xml` dentro |
| 2ª | `rel-clientes/xls` | 4.264 bytes, mesmo começo, **sem `Content-Length`** |

Localmente, o mesmo pedido devolve 6.240 bytes que abrem.

Então **chegou o começo de um zip de verdade** — o diretório central de um zip
fica no fim, e é por isso que o `ZipArchive` recusa. E sem `Content-Length` o
cURL não tem como saber que faltou pedaço: lê até a conexão fechar e chama de
sucesso. O servidor embutido fecha cedo; o PHP-FPM da produção não aparece nesta
história — nem o nginx, nem o navegador de ninguém.

Os dois tamanhos, 4,1 e 4,3 KB, ficam logo acima de 4.096. Isso é pista de
fronteira de buffer, não de conteúdo.

**O que mudou no medidor:** planilha que começa em `PK` e não abre passou a ser
uma linha visível em toda corrida — `PLANILHA CORTADA NO TRANSPORTE` — e não
reprova, porque não é sobre o dado. O que este portão existe para pegar,
planilha só com cabeçalho, continua reprovando: essa conferência só acontece
quando o zip **abre**.

**A pergunta que fica**, e que não é do CI: as ações que devolvem planilha não
mandam `Content-Length`. Em produção, uma resposta cortada chegaria ao navegador
como arquivo corrompido, sem erro em lugar nenhum. Mandar o tamanho exigiria
montar o arquivo em memória antes de enviar — decisão de projeto, com custo de
memória, que não vou tomar de passagem.

---

**A revisão de 07/11/2026 (um segundo agente tentando derrubar a investigação).** Três ressalvas, aceitas, e por isso o débito fica PARCIAL e não CORRIGIDO: (1) a queda foi reproduzida no contêiner `app`, que é `aarch64`, e o runner é x86_64 -- o JIT gera código diferente em cada arquitetura, e o próprio `setup-php` usa outro ini no ARM; ninguém viu o JIT ligado nem o sinal 11 no runner; a conta (4 quedas em 12 rodadas com JIT, ou 4 em 14 contando duas passadas verdes, contra 0 em 13 sem ele) é consistente mas estatisticamente fraca; (2) o incidente de 06/09 (o XLS de clientes) não foi reproduzido; (3) uma rodada com JIT terminou com 40 casos, do 192 ao 238, respondendo 401 sem trabalhador morto (`rep-jitrep-1.txt` no rascunho da sessão), sem rodada de controle sem JIT no mesmo roteiro -- pode ser defeito do roteiro ou outro sintoma do JIT, resposta errada em silêncio. A produção roda PHP-FPM e o opcache dela está "A CONFIRMAR" em `docs/operacao-atual.md`: o PHP 8.1 de fábrica nasce sem JIT (o `jit_buffer_size` padrão é 0), então o risco é baixo, mas não está medido. Fecha quando: o runner sair verde com o `opcache.jit=disable` em algumas execuções seguidas, e a configuração de opcache da produção for lida.

**PARCIAL em 08/11/2026 (a fatia 122, RELATORIOS-ARQUIVO-FRASE-E-MEMORIA): a pergunta do Content-Length, respondida no código.**
O corpo deixava aberta a pergunta "as ações que devolvem planilha não mandam `Content-Length`", com o custo de memória de montar
o arquivo antes. A tela nova já o fazia (`PlanilhaDoRelatorio::bytes`, o `RelatorioAdmController::arquivo` e o
`PacotesController::exportarAction`); restavam os cinco geradores antigos, lidos pelo grep de `php://output` e de `echo $conteudo`:
`ISRelatorio\Xls\Atendimentos` (o `.xls`, OLE2), `VendasRealizadas`, `VendasVersao1`, `VendasVersao2` e `ISMarketing\Xls\Sorteio`.
Os cinco passam a devolver os bytes (`conteudo(...)`, montados em `php://temp` pelo `ISBase\Util\PlanilhaParaBaixar::bytes`; o do
sorteio já os montava), sem `header()`, `ob_clean()` nem `exit`, e as quatro ações que os chamam
(`/mod-relatorios/rel-atendimentos/gerar`, `/mod-relatorios/vendas/exportar`, `/mod-vendas/vendas/index-vendas-exportar-xls` e
`/mod-marketing/sorteios/exportarXls`) devolvem a resposta do Laminas pelo `PlanilhaParaBaixar::naResposta`: o tipo, o anexo com o
nome de sempre, o `Content-Length` em bytes e `no-store`. O custo é o arquivo inteiro na memória uma vez -- os geradores já sobem o
limite a 1024M, e a planilha montada é menor que a `Spreadsheet` que a gerou. Os PDFs antigos (o `Output('I')` do FPDF) ficam como
estão. Os casos, em processo e nos dois bancos (antes, a ação terminava em `exit` e nenhum caso via a resposta): o
`D115PlanilhaComOTamanhoTest` (o XLS do Atendimentos com o cache do ano plantado e tirado, o ajudante e a vigia de que nenhum dos
cinco volta a escrever na saida ou a terminar o processo, lida pelo tokenizador sem os comentários), e um caso em cada vizinho --
`ListagemDeVendasTest::testAPlanilhaDaTelaAntigaVoltaNaRespostaComOTamanho`,
`PedidoDaCotaParteAntigaTest::testAPlanilhaVoltaNaRespostaComOTamanho` (a versão 1 e a 2) e
`TelasDosSorteiosDoErpTest::testAPlanilhaTemUmaLinhaPorVendaAtiva` --, que conferem o `Content-Length` igual ao corpo, o começo
(`PK` no `.xlsx`, a assinatura do OLE2 no `.xls`), o zip que abre pelo `ZipArchive` e a planilha pelo PhpSpreadsheet (o trait
`PlanilhaComOTamanho`). Mutantes, um de cada vez, devolvidos pelo sha1: sem o `Content-Length` (vermelho nos dois bancos), o tamanho
em caracteres (`mb_strlen`), o `header()` de volta no gerador do sorteio e a Vendas Realizadas sem devolver a resposta (vermelhos).
**O que segue aberto, com as palavras de 07/11:** a causa da queda no CI é o JIT, e ela segue sem a prova do runner x86 verde (o CI
está parado por cobrança desde 07/11/2026); a configuração de opcache da produção segue "A CONFIRMAR". Os geradores antigos morrem
na A4 depois do corte: o ganho é para o tempo até lá. PARCIAL.

### D116 — Dois contratos congelados descreviam uma resposta que produção nunca envia — CORRIGIDO em 06/09/2026

Achado em 06/09/2026, por acidente: eu tentava dar dado ao Dashboard
Colaboradores e a suíte de contrato caiu.

`ProdutoRepository::popularComboboxServicos()` acrescenta um campo `categoria`
a cada serviço — **e só quando a área do produto é 1, 3, 4 ou 8**:

```php
if ($produto['areaId'] == 3)      { $retorno[$key]['categoria'] = "barbeiro"; }
else if ($produto['areaId'] == 1) { $retorno[$key]['categoria'] = "esteticista"; }
else if ($produto['areaId'] == 8) { $retorno[$key]['categoria'] = "lavavip"; }
else if ($produto['areaId'] == 4) { $retorno[$key]['categoria'] = "massagista"; }
```

São os ids de produção: 1 esteticista, 3 barbeiro, 4 massagista, 8 Lava VIP.
Qualquer outra área sai **sem a chave**.

O cenário de teste inventava a própria área (`90001000`, "Barbearia Teste"), e
por isso os instantâneos de `unidade-selecionar` e `unidade-selecionar-v2`
congelaram um serviço **sem `categoria`** — uma forma que o aplicativo instalado
nunca recebe, porque em produção todo serviço cai numa das quatro áreas.

É a família do D55, com uma diferença que importa: ali o cenário era *vazio*, e
o instantâneo congelava "não achei nada". Aqui o cenário tem dado, e ele é
**estranho** — inventado onde o código espera um id de produção.

#### O conserto

Uma área 3 no cenário, com um serviço dentro. Os dois instantâneos passaram a
descrever `categoria`, e são duas linhas de diferença — nada mais mudou nos 67.

E a área nova **não pode existir sozinha**: `semCarga()` desce pela estrutura
inteira e condena o instantâneo se achar qualquer lista vazia. Área sem serviço
entra na resposta com `servicos: []`, e os dois contratos caíram com "a resposta
veio vazia" sobre uma resposta que tinha unidade, colaboradores e serviços. Por
isso a fixture traz área **e** serviço, na mesma linha do arquivo.

#### O que este achado ensina sobre números mágicos

Duas telas do sistema filtram por ids que valem uma linha do banco de produção:
o Dashboard Colaboradores exige `area = 3` (barbeiro) e a rotina de evasão exige
`grupo = 1` (colaborador, 1.748 usuários). **Um cenário que inventa os próprios
ids nunca satisfaz um filtro escrito em cima de um id de produção** — e o
sintoma é sempre o mesmo: 200, página montada, nenhuma linha.

Não é defeito: é decisão de produto escrita em número. Mas quem monta cenário
precisa saber quais números são esses, e agora estão escritos.

---

### D117 — Duas rotinas de cron morriam no PostgreSQL, e ninguém podia saber porque elas nunca tinham o que fazer — CORRIGIDO em 06/09/2026

Achado em 06/09/2026, ao dar ao cenário um colaborador que a rotina de evasão
enxergasse. Ela rodou pela primeira vez com trabalho de verdade e respondeu
**500**:

```
SQLSTATE[22P02]: invalid input syntax for type integer: "t"
```

Três consultas dividiam **um único `:status` entre duas colunas de tipos
diferentes**:

```php
" WHERE ven.status = :status AND cli.status = :status AND col.id = :colaborador"
```

`vendas.status` é **inteiro** (1 = finalizada) e `clientes.status` é
**booleano**. Quem chamava mandava `true`, e o MySQL converte silenciosamente;
o PostgreSQL recusa o `"t"` na coluna inteira. É o D81 na sua forma mais
econômica: um parâmetro fazendo dois trabalhos incompatíveis.

E a subconsulta dentro da própria `fidelizacaoPorColaborador` já estava certa —
`ven2.status = 1 AND cli2.status = true`, cada literal no seu tipo. A metade de
fora é que dividia o parâmetro.

| consulta | quem chama |
|---|---|
| `evasaoPorColaborador` | `relatoriosEvasaoAtualizar` |
| `evasaoPorColaboradorTotalVendas` | as duas rotinas |
| `fidelizacaoPorColaborador` | `relatoriosFidelizacaoAtualizar` |

#### Por que ninguém tinha visto

`selecionarUsuariosComVendas($unidade, null, 1)` filtra `usu.grupo = 1` — o id
de produção de "Colaborador". O cenário inventava os próprios grupos, então **o
laço não tinha nenhum colaborador para percorrer**: a rotina gravava `[]` no
cache, respondia 200, e a consulta com o defeito nunca chegava a executar.

A medição de cron dizia "nenhuma rotina quebrou" — e era verdade, do mesmo jeito
que era verdade que nenhum relatório quebrava enquanto todos vinham vazios
(D113). Rotina que não tem o que fazer não quebra.

**E as duas apareceram em fila**: consertada a evasão, a de fidelização caiu na
execução seguinte, pelo mesmo motivo, uma consulta adiante. Cada correção
descobria a próxima.

#### O medidor de cron passou a perguntar quanto cada rotina fez

O buraco não era só destas duas: **a varredura respondia uma pergunta só**, e
"quebrou?" não distingue a rotina que processou trinta unidades da que processou
zero. Era essa diferença que escondia o defeito.

O sistema já registra o quanto fez — quase toda rotina termina com
`logScripts("nome - atualizou N unidades")`, em `data/logs/log_scripts.txt`. O
medidor passou a ler o que foi acrescentado durante cada chamada e a imprimir ao
lado. Não foi preciso inventar uma expectativa por rotina, que seria o
`PRECISA_MOSTRAR` dos relatórios multiplicado por 22.

Na primeira execução:

```
  Registraram o que fizeram: 11 de 22.
```

E entre as onze que falam, várias falam **zero**: `clientesAtualizarConsumo -
atualizou 0 clientes`, `fidelizacaoAtualizar - atualizou 0 clientes`,
`comandasCancelar - 0 comandas`. Agora isso está à vista, linha a linha.

A lista das onze mudas **não reprova** — nem toda rotina escreve no log, e
algumas não têm o que escrever num cenário pequeno. Ela existe porque foi
exatamente ali que este débito morou.

Isto **não prova que o trabalho está certo**. Prova que houve trabalho, e separa
"processou 3" de "processou 0" — que era a diferença invisível.

#### O que isso teria custado no corte

As duas rotinas rodam no crontab, que chama por `curl` e não lê a resposta
(D4). No dia da migração elas passariam a falhar em silêncio, e o sintoma
apareceria semanas depois como "o relatório de evasão está vazio" — sem nada,
em lugar nenhum, ligando uma coisa à outra.

---

### D118 — `SUM()` volta número no MySQL e texto no PostgreSQL, e o valor ia gravado num arquivo — CORRIGIDO em 06/09/2026

Achado em 06/09/2026, na primeira comparação entre os dois bancos feita **depois
de os relatórios terem dado dentro**.

Isso importa mais que o defeito: a comparação existe desde o D107 e vinha
respondendo "os dois bancos dizem o mesmo" sobre 190 artefatos que eram, na
maioria, formulários de filtro e tabelas vazias. **Dois vazios são sempre
idênticos.** Com o cenário povoado (D113), ela passou a comparar 226 artefatos
com conteúdo — e achou na primeira execução.

#### A diferença

```
PostgreSQL   "totalVendas":{"1":"89.9", ...}
MySQL        "totalVendas":{"1":89.9,  ...}
```

`SUM(pro.valorTotal)` volta como **número** no MySQL e como **texto** no
PostgreSQL. `selecionarTotalVendas()` devolvia o valor cru, e ele não fica na
memória: é gravado em
`data/cache/relatorios/atendimentos/<unidade>_<ano>.json`, que a tela e o XLS
leem depois.

Nada quebra por causa disso — até alguém comparar com `===`, somar sem
converter, ou entregar a um gráfico que recusa texto. **É o D82 saindo pela
porta da frente:** a diferença de tipo deixa de ser um detalhe do driver e vira
o conteúdo de um arquivo que outra parte do sistema lê.

O conserto é um molde para `float` no retorno. O zero continua **inteiro** de
propósito: `(float) 0` seria serializado como `0.0`, e isso mudaria o arquivo
que o MySQL produz hoje.

#### A segunda diferença era o relógio

O XLS legado de atendimentos também divergia — 10.752 bytes dos dois lados, 16
diferentes. Decodificados, eram **dois FILETIME do cabeçalho OLE2**: 18:31:25 e
18:40:37 UTC, exatamente o intervalo entre a passagem do PostgreSQL e a do
MySQL. O resto do arquivo é idêntico.

Entrou na lista de diferenças conhecidas com essa explicação, e **não sai de
lá**: o formato carimba a hora, e as duas passagens nunca vão ser simultâneas.

Vale registrar o caminho: eu quase escrevi "diferença de arredondamento entre os
bancos". Os bytes diziam outra coisa — decodificá-los custou uma linha de PHP e
evitou um débito inventado.

#### Como ficou

```
  arquivos:      226
  identicos:     224
  conhecidas:    2
  diferentes:    0
```

---

### D119 — No dia do corte, a busca de cliente deixa de achar 8,5% das pessoas — PREPARADO E DESLIGADO em 11/09/2026, espera a janela de quem opera

Achado em 06/09/2026, lendo `ClienteRepository::autoComplite()` antes de
escolher a quarta fatia da Fase 2.

A busca do ERP é `cli.tags LIKE :filtro`. A coluna `tags` é montada pelo próprio
sistema — nome, telefone e e-mail concatenados —, **em minúsculas** e **com os
acentos preservados**. Nos dois bancos a coluna é idêntica; o que muda é o
`LIKE`.

Medido na cópia de produção, com o mesmo termo pelos drivers da própria
aplicação:

| termo | MySQL | PostgreSQL |
|---|---:|---:|
| `jose` | **5.710** | 3.576 |
| `JOSE` | **5.710** | **0** |
| `josé` | **5.710** | 2.223 |

O MySQL responde o mesmo número nos três: a collation `utf8mb4_general_ci` é
insensível a caixa **e** a acento. O PostgreSQL compara byte a byte e responde
três números diferentes, nenhum deles 5.710.

E o tamanho do problema, na mesma cópia:

| | |
|---|---:|
| clientes | 242.587 |
| **com acento nas tags** | **20.716 (8,5%)** |
| com maiúscula nas tags | 0 |

#### São dois defeitos, com preços muito diferentes

**A caixa é barata.** A coluna já está toda em minúsculas — zero linhas com
maiúscula. O que falta é minúsculo **no termo**, que é uma linha no repositório
e não depende de migração de dado nenhum. Hoje o MySQL esconde isso: quem digita
"JOSE" acha tudo, e no PostgreSQL acharia nada.

**O acento é caro.** São 20.716 clientes cujo nome só aparece se quem busca
digitar o acento certo. Consertar exige normalizar os dois lados: tirar acento
da coluna derivada — 242.587 linhas — e do termo. Alternativas consideradas:

| caminho | custo | observação |
|---|---|---|
| normalizar `tags` e o termo | reconstruir uma coluna derivada | portável, sem extensão, mesmo comportamento nos dois bancos |
| `unaccent()` na consulta | extensão no PostgreSQL | mata o uso de índice, e deixa os bancos diferentes |
| `citext` | extensão | resolve caixa, **não** resolve acento |

O primeiro é o que se parece com este projeto: a coluna é derivada, pode ser
reconstruída, e o resultado é o mesmo comportamento nos dois motores — hoje e
depois da migração.

#### A metade barata foi feita no mesmo dia

`ISBase\Util\BuscaPorTags::normalizar()` aplica ao termo a **mesma**
transformação que `Cliente::setTags()` aplica ao gravar —
`mb_convert_case(MB_CASE_LOWER)` —, e as **treze** consultas que procuram nesta
coluna, em seis arquivos, passam a usá-la.

No MySQL não muda nada: a collation já ignorava a caixa, e a coluna já é
minúscula. No PostgreSQL é a diferença entre achar e não achar — `JOSE` deixa de
responder zero.

Ela existe como função, e não como uma linha repetida treze vezes, por causa da
outra metade: quando a decisão do acento vier, ela entra **num lugar só** e vale
para as treze.

E `scripts/conferir-busca-por-tags.php` guarda a regra: para cada consulta que
procura nessa coluna, o termo tem de passar pela função. Corrigir as treze foi o
trabalho de um dia; garantir que a décima quarta nasça certa é o trabalho do
portão. Ele roda em `conferir-tudo.sh` e no CI.

Na primeira execução ele **acusou o próprio arquivo que explica a regra** — o
cabeçalho de `BuscaPorTags` cita `cli.tags LIKE :termo`. É a mesma armadilha do
portão de bytes de controle, que também se acusou antes de acusar qualquer outra
coisa; agora ele pula linhas de comentário, e isso está escrito nele.

#### A outra metade ficou pronta em 11/09/2026, e desligada

Reconstruir `tags` para 242 mil linhas é operação de janela, e a decisão de
quando fazê-la é de quem opera. O que dava para fazer antes da janela era tudo
o resto — e é o que está no repositório desde 11/09/2026, **sem mudar o
comportamento de hoje em nada**.

**`BuscaPorTags::semAcento()`**, um mapa explícito, e não `Normalizer` do
`intl`. Esta transformação governa uma coluna **gravada**: o que a passada
escrever em 242 mil linhas tem de continuar batendo com o que a busca calcular
daqui a cinco anos, em outra versão de PHP e outra tabela do ICU. Um mapa no
repositório não muda sozinho.

> **Correção de 11/09/2026, no mesmo dia:** este parágrafo dizia que depender do
> `intl` seria *"pagar uma dependência nova"*. **Está errado.** Medido no
> `composer.lock`: `laminas/laminas-i18n` exige `ext-intl`, então a extensão é
> dependência dura do conjunto instalado e está carregada (ICU 76.1). O que
> continua verdadeiro é que o `composer.json` do projeto não a declara e que
> nenhum código do projeto a usa — e nenhuma das duas coisas é o motivo da
> escolha. O motivo é o de cima, e ele basta sozinho: a tabela que decide o que
> está **gravado** não pode mudar junto com uma atualização de biblioteca.
> Achado ao consertar o `composer install` do CI, que falhava por falta de
> `intl`, `gd` e `zip`.

**`normalizarTags()` é função separada de `normalizar()`, e isso mudou o plano
descrito acima.** O D119 previa que o acento entraria em `normalizar()`. Entre
o D119 e hoje, o **D120** espalhou essa mesma função para 27 consultas em
colunas **naturais** — `pro.nome`, `cid.nome`, `sai.descricao` —, que guardam o
texto como o usuário digitou e que ninguém reconstrói. Tirar acento ali seria
tirar de um lado só: no PostgreSQL, `joao` deixaria de achar `João`, que hoje
ele acha. Seria trocar de defeito, não consertar.

Então são duas funções, e as 13 consultas em `tags` passaram para a nova.
`scripts/conferir-busca-por-tags.php` ganhou **uma terceira regra**, que vigia o
erro simétrico: `normalizarTags()` fora da vizinhança de um `tags LIKE` reprova
o portão. As três foram falsificadas — plantei o defeito de cada uma e vi o
vermelho antes de confiar no verde.

**`scripts/normalizar-tags.php`**, a passada. Sem `--aplicar` só mede; não
imprime tag nenhuma em modo nenhum, porque a coluna é feita de nome, telefone e
e-mail. Ele **para com erro se a tabela vier vazia**: no PostgreSQL o RLS com
`FORCE` faz uma conexão sem inquilino declarado enxergar zero linha, sem erro
nenhum, e um roteiro que imprimisse "0 a normalizar, tudo certo" ali estaria
mentindo com cara de verde. Falsifiquei essa guarda também, declarando o
inquilino 999: ele para com saída 2.

Ele tira o acento **do que está gravado**, e não remonta a coluna a partir do
cadastro. Remontar arrastaria junto qualquer divergência acumulada entre o
cadastro e a coluna derivada, em 242 mil linhas, numa janela — mais do que a
decisão do acento pediu. A diferença medida entre os dois caminhos é a
duplicata, que um `LIKE` não distingue e o próximo salvamento desfaz sozinho.

#### O inventário de caracteres, medido nos dois bancos em 11/09/2026

O roteiro não diz "tira acento": ele lista o que **este** banco tem e o que o
mapa faz com cada caractere. São **32 caracteres multibyte** na coluna, e o
mapa cobre **24**. Os oito que ficam não são letra acentuada:

| | linhas | |
|---|---:|---|
| `´` acento agudo solto (U+00B4) | 30 | |
| `’` apóstrofe curva (U+2019) | 10 | |
| espaço inquebrável (U+00A0) | 4 | invisível |
| `ª` indicador ordinal (U+00AA) | 1 | |
| U+200E, U+202A | 2 | marcas de direção de texto, invisíveis |
| `…` e `”` | 2 | |

Eles ficam **iguais dos dois lados** da comparação, então a busca não piora; só
não melhora para quem os tiver no cadastro. Deixá-los é decisão: o remédio de um
espaço inquebrável não é um mapa de acentos. Fica anotado aqui porque quem
operar precisa saber antes, e não depois.

Os números batem nos dois bancos: **20.716** linhas com multibyte, **20.672** a
normalizar — a diferença de 44 são as linhas cujo único multibyte está entre os
oito de fora.

#### O que falta, e é decisão de quem opera

    1. docker compose exec -T app php scripts/normalizar-tags.php --aplicar
    2. em config/autoload/local.php:  'busca' => ['tags_sem_acento' => true],

**Nessa ordem.** A chave faz o termo e a gravação perderem o acento; ligá-la
antes de reconstruir mandaria um termo sem acento a uma coluna que ainda o tem,
e no PostgreSQL a busca pararia de achar quem ela mesma gravou. O intervalo
entre 1 e 2 é invisível no MySQL — a collation já ignorava o acento — e degrada
no PostgreSQL, então os dois passos pedem a mesma janela.

**Desde 03/10/2026 eles estão no procedimento do corte** (`docs/procedimento-de-corte.md`, o 7c, e
o ensaio geral): depois de apontar a aplicação para o PostgreSQL — a passada lê o banco do
`local.php` —, medir, aplicar, medir de novo até "a normalizar 0", e só então a chave; e no teste
manual, buscar sem acento quem tem acento. Fazer ou pular continua sendo de quem opera, e o tempo da
passada não foi medido.

**Enquanto a chave não existe, `normalizarTags()` e `normalizar()` devolvem
exatamente a mesma coisa.** É o que o teste
`testComAChaveDesligadaONormalizarTagsNaoTiraAcento` guarda: se alguém ligar a
chave por engano, ou se o padrão inverter numa refatoração, ele fica vermelho
antes de a busca começar a perder gente.

**O que o teste prova:** `BuscaDeClienteComAcentoTest` passou de 6 para 11
casos, verdes nos dois bancos. Os seis antigos continuam registrando o
comportamento de hoje, palavra por palavra. Os cinco novos provam o mapa contra
os caracteres medidos, a convergência das duas formas do acento (precomposta e
combinante), a idempotência de que a passada depende para poder ser reinterrompida
e recomeçada, a chave desligada — e, o decisivo,
`testComAColunaNormalizadaOsDoisBancosAchamComOuSemAcento`, que normaliza a
coluna **dentro da transação revertida** e mostra os dois bancos achando por
`JOSÉ`, `jose`, `José` e pela forma combinante. É exatamente a linha desta
tabela em que o PostgreSQL respondia **0**, e a suíte contém a própria
falsificação: `testSemAcentoSoOMysqlAcha` continua afirmando esse zero na coluna
não normalizada, no mesmo banco e com o mesmo termo.

#### Uma assimetria pré-existente, anotada e não corrigida

Medindo os dois lados, apareceu uma diferença que não é do acento e não foi
mexida: `Cliente::tagsBuscasPalavras()` remove pontuação ao **gravar** — a lista
inclui `.`, `-`, `'` e outros vinte —, e `BuscaPorTags::normalizar()` não remove
nada ao **buscar**. Quem digita `J.P.` procura por `j.p.` numa coluna onde está
`jp`. É defeito antigo, pequeno, e mexer nele agora misturaria duas decisões na
mesma janela.

---

### D120 — A busca de serviço do PDV perdia 78% dos resultados no PostgreSQL — CORRIGIDO em 07/09/2026 (27 de 27 consultas)

Achado logo depois do D119, fazendo a pergunta óbvia: **e as outras buscas?**

São **27 consultas com `LIKE`** fora da coluna `tags`, e elas procuram em
colunas que ninguém normalizou — nem ao gravar, nem ao consultar. Enquanto o
banco é MySQL, a collation `utf8mb4_general_ci` responde por todas.

Medido na cópia de produção, mesmo termo, pelos drivers da aplicação:

| termo em `produtos.nome` | MySQL | PostgreSQL |
|---|---:|---:|
| `corte` | **590** | 129 |
| `Corte` | **590** | 316 |
| `CORTE` | **590** | 145 |
| `barba` | **798** | 177 |
| `sobrancelha` | **159** | 31 |

Quem digita "corte" no PDV — em minúsculas, como se digita — encontra **129 de
590 serviços**. Some 78% do catálogo, sem erro nenhum.

#### E é pior que o D119, por um motivo estrutural

Na busca de cliente, a coluna `tags` **já era minúscula**: só o termo estava
errado, e uma função resolveu. Aqui as duas pontas estão cruas:

| tabela | coluna | linhas | com acento | **com maiúscula** |
|---|---|---:|---:|---:|
| `produtos` | `nome` | 9.506 | 1.889 | **9.003 (95%)** |
| `logradouros` | `nome` | 709.440 | 276.896 | **709.440 (100%)** |
| `saidas` | `descricao` | 13.236 | 2.120 | **13.228 (100%)** |
| `notificacoes` | `titulo` | 2.787 | 2.787 | **2.787 (100%)** |
| `mensagens` | `titulo` | 74 | 39 | 73 |
| `sorteios` | `nome` | 4 | 4 | 4 |

Não dá para baixar a caixa da coluna: **ela é o que o usuário lê**. "Corte
Masculino" não pode virar "corte masculino" na tela.

#### Os caminhos, e o que cada um custa

| caminho | resolve | custo |
|---|---|---|
| `LOWER(col) LIKE :termo`, com o termo em minúsculas | caixa | portável, sem migração |
| índice de trigrama (`pg_trgm`) sobre `LOWER(col)` | velocidade | extensão + índice, só no PostgreSQL |
| coluna derivada normalizada, como `tags` | caixa e acento | reconstrução de dados por tabela |
| `unaccent()` na consulta | acento | extensão no PostgreSQL, e os bancos deixam de ser iguais |

`LOWER()` é a resposta imediata e portável, e no MySQL não muda resultado nenhum
— a collation já ignorava a caixa.

#### O custo do `LOWER()` na tabela grande, medido

Eu havia escrito aqui que `LOWER()` **"perde o índice"**. Está errado, e a
correção importa para a decisão: a busca é `LIKE '%termo%'`, com curinga à
esquerda, e **nenhum índice B-tree serve para isso** — nem no MySQL, nem no
PostgreSQL. Além disso `logradouros` só tem índice na chave primária e na
estrangeira do bairro. **Não há índice a perder: a consulta de endereço já
varre as 709.440 linhas hoje.**

Medido na cópia de produção, contando sem `LIMIT` para forçar a varredura
inteira:

| consulta | linhas | melhor de 3 |
|---|---:|---:|
| `nome LIKE '%Paulo%'` — como está hoje | 4.906 | **75,7 ms** |
| `LOWER(nome) LIKE '%paulo%'` | 4.913 | **246,9 ms** |
| a mesma, com índice de trigrama | 4.913 | **13,5 ms** |

O `LOWER()` sozinho triplica o tempo. Com um índice GIN de trigrama sobre
`LOWER(nome)`, a busca **corrigida** fica **5,6 vezes mais rápida que a busca
quebrada de hoje**. A extensão `pg_trgm` já está disponível na imagem (1.6),
como `unaccent` (1.1) e `citext` (1.6).

Ou seja: o dilema que eu descrevi não existe. O que existe é uma decisão de
schema — criar a extensão e o índice — que vale a pena por si só, porque a busca
de endereço hoje varre setecentas mil linhas a cada tecla.

*(O índice foi criado só para medir e removido em seguida; nada ficou no banco.)*

#### E uma observação sobre as duas cópias

`logradouros` tem **3 linhas no MySQL de desenvolvimento e 709.440 no
PostgreSQL**: o dicionário de CEP já foi esvaziado de um lado e não do outro. É
o mesmo assunto do procedimento de corte — o PostgreSQL precisa ser carregado a
partir de um MySQL que já tenha o dicionário vazio, ou quatro tabelas de
endereço divergem parecendo perda de 756 mil linhas.

> **De onde vem `logradouros` no corte, escrito no procedimento em 03/10/2026:** do MySQL de
> produção, como estiver no passo 3 — e lá o dicionário continua cheio (709.440 na última cópia),
> porque `scripts/limpar-dicionario-cep.sql` (D74) só rodou no desenvolvimento e a produção está
> congelada desde 23/08. A carga o leva inteiro, e as duas pontas do corte batem; o que diverge são as
> cópias locais, e o ensaio feito sobre a do desenvolvimento mede ~758 mil linhas a menos de carga.
> Esvaziá-lo em produção antes do dump é decisão do dono (lote de perguntas, 03/10/2026).

#### A tabela que não tinha dilema foi corrigida

`produtos` tem 9.506 linhas e é a busca mais usada do sistema — o operador
digita o nome do serviço na comanda a cada atendimento. Aqui `LOWER()` não custa
nada, e não havia decisão a tomar: as **sete** consultas que procuram em
`produtos.nome` passaram a comparar `LOWER(p.nome)` com o termo normalizado pela
mesma função do D119.

Medido depois, na cópia de produção:

| termo | MySQL | PostgreSQL |
|---|---:|---:|
| `corte` | 590 | **589** |
| `CORTE` | 590 | **589** |
| `barba` | 798 | **798** |

Os 589 contra 590 quase viraram uma anotação sobre acento — e não são. A linha
que falta é `90001150 "Corte da Sessao"`, uma **fixture** que existe na cópia
MySQL e não na PostgreSQL, porque a segunda foi carregada antes de ela existir.
Sobre as linhas que as duas cópias têm em comum, os dois bancos respondem o
mesmo número.

`BuscaDeProdutoTest` guarda isso: acha digitando em minúsculas, em maiúsculas e
como está escrito, **nos dois bancos**. Sem ele, tirar o `LOWER()` não quebra
nada no MySQL, e o defeito só reapareceria depois do corte.

#### Depois da medida, as outras dezoito também

Com o dilema desfeito, sobrou uma pergunta simples: em que tabelas `LOWER()` é
de graça? Em todas as que restavam, menos duas. As dezoito consultas em
`cidades`, `grupos`, `convenios`, `cupons`, `arquivos`, `assistentes`,
`areas_atuacao`, `usuarios`, `vipclass`, `sorteios`, `mensagens`, `pesquisas`,
`notificacoes`, `app_banners`, `saidas` e `agendas_fechamentos` passaram a
comparar `LOWER(coluna)` com o termo normalizado. A maior dessas tabelas tem
**13.236 linhas**.

**As duas que ficaram eram as grandes** — e uma delas caiu depois de ser
medida:

| consulta | tabela | linhas | o que a medida disse |
|---|---|---:|---|
| `pro.descontoMotivo LIKE` | `vendas_produtos` | 3.264.601 | **corrigida** — ver abaixo |
| `l.nome LIKE :logradouro` | `logradouros` | 709.440 | espera o índice de trigrama |

##### O motivo do desconto não dependia do índice

A tabela é a maior do sistema, mas a consulta do relatório de descontos filtra
**unidade e período antes** do `LIKE` — o que sobra para comparar é pouco.
Medido na cópia de produção:

| consulta | linhas | ms |
|---|---:|---:|
| sem `LOWER`, termo `Convênio` | **16.213** | 331,5 |
| sem `LOWER`, termo `convênio` — como se digita | **1** | 285,1 |
| com `LOWER`, termo `convênio` | **16.215** | 405,6 |

Quem procura o motivo em minúsculas encontra **1 de 16.215**. O `LOWER()` custa
**74 ms num relatório que já leva 330** — 22% a mais para parar de perder
99,99% das linhas. Não é decisão: é conta.

Três sítios tinham o termo montado com `$param` no singular, fora do padrão que
a transformação reconhecia — e o resultado seria pior que o defeito: coluna em
minúsculas comparada com termo cru **nos dois bancos**. Achei conferindo se cada
`LOWER()` novo tinha um `normalizar()` do lado; os três estavam descasados, e
foram corrigidos à mão.

#### A última: o dicionário de CEP, e o índice que o schema passou a ter

Aprovado por você em 07/09/2026. `postgres/schema.sql` passou a criar a extensão
`pg_trgm` e dois índices sobre `logradouros`, e
`LogradouroRepository::autoCompliteLogradouro()` passou a comparar
`LOWER(l.nome)` com o termo normalizado.

| termo digitado | sem `LOWER` | com `LOWER` |
|---|---:|---:|
| `presidente` | **0** | 2.520 |
| `são` | **272** | 16.203 |

O segundo caso é o mais traiçoeiro dos dois, e não estava na medida de ontem: a
busca não devolvia lista vazia, devolvia **272 de 16.203** — as poucas ruas que
o arquivo dos Correios gravou em minúsculas. Lista curta passa por resultado;
lista vazia levanta suspeita.

**O custo, e por que o índice não era luxo.** `LIKE` com curinga à esquerda não
usa índice comum — não há prefixo por onde começar —, e por isso `LOWER()`
sozinho levava a consulta de 86 ms para 282. O índice GIN de trigrama guarda
pedaços de três letras, e curinga dos dois lados continua sendo consulta por
pedaço:

| | ms |
|---|---:|
| hoje, quebrada | 86,4 |
| com `LOWER`, sem índice | 281,6 |
| com `LOWER`, com o índice | **27,6** |

Medido em 07/09/2026 nas 709.440 linhas da cópia de produção. O índice ocupa
31 MB contra 51 MB da tabela. **Ele é sobre a expressão `LOWER(nome)`**: quem
tirar o `LOWER()` da consulta não quebra só a busca — desliga o índice em
silêncio, e só a lentidão denuncia.

#### O índice que apareceu no caminho

`logradouros` é consultada por CEP toda vez que alguém preenche um endereço no
aplicativo — e **não havia índice em `cep`**. Eram 709.440 linhas varridas para
achar uma: **60,2 ms**, contra **0,2 ms** com o índice. Trezentas vezes, por
21 MB. Não tem nada a ver com caixa nem com collation; apareceu porque medir uma
consulta obriga a olhar o plano da outra.

#### O portão passou a vigiar as duas regras

`scripts/conferir-busca-por-tags.php` guardava só `clientes.tags`. Agora ele
publica os dois denominadores — **13 consultas** naquela coluna e **27** nas
demais — e exige, fora de `tags`, `LOWER(` na própria linha do `LIKE` e
`BuscaPorTags::` no método que a contém.

A primeira regra é exata e é a que pega a consulta nova escrita no reflexo
antigo; a segunda é mais fraca, e o próprio portão diz isso: prova que alguém se
lembrou do termo, não que o termo certo foi normalizado. Duas das 27 têm a
atribuição a seis linhas de distância, e é por isso que a janela dessa regra é o
método inteiro, e não as linhas vizinhas.

E `tests/Caracterizacao/BuscaDeLogradouroTest.php` congela o comportamento nos
dois bancos, inclusive o que **continua** dependendo do banco: digitar "sao" sem
til acha "SÃO" no MySQL e não acha no PostgreSQL. Isso é o D119, não o D120 — e
o teste registra a diferença em vez de escondê-la.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 10): a opção (b), "Sim, no passo 3,
antes do dump", com a observação dele: *"Sim, no passo 3, antes do dump. Utilize a api do ViaCEP para
popular as informações."* `logradouros` sai vazio do MySQL de produção no passo 3, e as quatro tabelas
de endereço batem nas duas pontas do corte, sem as cerca de 758 mil linhas; o índice de trigrama e o de
`cep` nascem sobre uma tabela vazia, que enche com o uso; `estados` nasce com as 27 UFs, que o roteiro
repõe desde a revisão de 06/10/2026 (o D430). O modo de repovoar -- o cache sob demanda, como o código
já faz, ou uma pré-carga dos CEPs dos cadastros -- espera a confirmação do dono (o D74).

**Decidido pelo dono 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." `logradouros` nasce vazio no PostgreSQL e enche com o uso, sem
pré-carga. Este débito estava fechado e continua: a decisão só tira a pendência que o parágrafo acima
apontava para o D74.

---

### D121 — As fixtures gravam id explícito, e no PostgreSQL a sequência fica para trás — CORRIGIDO em 07/09/2026

Achado em 07/09/2026, quando a quarta fatia passou a **cadastrar cliente pela
API** dentro do ensaio de fluxo. O `POST` respondeu **500** com a página
genérica do Laminas, e o log do servidor não trazia fatal nenhum.

A causa: `tests/Caracterizacao/fixtures.sql` insere com **id explícito**
(90001000 e, desde o mesmo dia, também 1 e 2), e **inserção com id explícito não
move a sequência no PostgreSQL**. A primeira gravação feita pela aplicação pedia
o id 1, encontrava o cliente avulso no lugar e violava a chave primária.

**O MySQL não tem esse problema**: o `AUTO_INCREMENT` sobe sozinho quando alguém
grava um id maior. É por isso que a diferença só aparece de um lado — e é o
mesmo tipo de assimetria do D81 e do D118: o MySQL perdoa, o PostgreSQL cobra.

#### A migração de produção já estava coberta

`scripts/migrar-dados.sh` termina com um `setval` por coluna de identidade, e o
comentário lá diz exatamente por quê: *"sem isto, a primeira inserção depois da
migração tenta o id 1 e colide — e o erro não aparece na carga, aparece na
primeira venda do dia seguinte"*.

O que faltava era o **mesmo passo nos ambientes de teste**. Nunca tinha
importado porque nada gravava por ali: os testes de caracterização inserem com
id explícito, e as três fatias anteriores só escreviam em tabelas cujas fixtures
já ocupavam a faixa alta.

#### O conserto

`postgres/repontar-sequencias.sql`, com o mesmo `DO $$` que a migração usa,
chamado depois de carregar as fixtures nos quatro lugares que as carregam: os
dois roteiros de suíte, o de medição e o job do CI.

Vale notar o que o defeito custou para aparecer: **quatro fatias**. Enquanto a
API só lia e escrevia em linhas que as fixtures já tinham criado, a sequência
podia estar em 1 sem incomodar ninguém.

---

### D122 — O cenário de testes envelhecia em silêncio nos bancos de desenvolvimento — CORRIGIDO em 07/09/2026

Achado em 07/09/2026, e ele já tinha uma metade consertada. Em 06/09 a suíte de
contrato do MySQL ficou verde sobre um cenário que não existia mais — o banco
dela é montado **uma vez** e reusado —, e o conserto foi gravar o sha1 do
`fixtures.sql` dentro do banco, com `rodar-contrato.sh` recusando rodar quando
os dois não batem.

Aquilo cobria **um** banco. Os de desenvolvimento, que ninguém monta, tinham
exatamente o mesmo problema e nenhuma marca:

```
$ sh scripts/rodar-caracterizacao.sh
Tests: 312, Assertions: 575, Errors: 31, Failures: 40
```

**Quarenta e cinco falhas, nenhuma real.** Os mesmos 312 testes passam no CI,
que remonta o banco a cada execução. O que tinha acontecido é que as fixtures
cresceram — cliente acentuado, cliente evadido, o telefone compartilhado — e os
dois `franquiabv_dev` ficaram onde estavam.

Quarenta e cinco vermelhos falsos são piores que portão nenhum: **ensinam a
ignorar o vermelho**, e o vermelho de verdade passa junto na próxima vez.

#### O conserto: a marca vai dentro do próprio arquivo

As fixtures são carregadas em **sete lugares** — os dois roteiros de suíte, os
dois de contrato, o de medição, o `setup-dev.sh` e os jobs do CI. Pedir a cada
um que lembre de gravar a marca é pedir para um deles esquecer, e um só que
esqueça devolve o problema inteiro, em silêncio.

Então a marca passou a morar **no `fixtures.sql`**, que termina declarando o
sha1 de tudo que está acima e gravando-o em `_fixtures_versao`. Quem carrega o
arquivo marca o banco sem saber que marca. `scripts/marcar-fixtures.php` a
regrava; `--conferir` é o portão.

O preço dessa escolha é que a marca precisa de portão próprio — **marca que
mente é pior que marca nenhuma**, porque tem a aparência de conferência. Ele
está no `conferir-tudo.sh` e no CI.

`CaracterizacaoTestCase` compara a marca do banco com a do arquivo e troca as 45
falhas por uma frase que diz o que fazer. **Banco sem a tabela é banco
defasado**, e não há meio-termo: se não tem a marca, foi carregado de um
`fixtures.sql` anterior a ela — mais velho que o de hoje, por definição. A
primeira versão desta conferência deixava esse caso passar, e assim não pegava
justamente os dois bancos que a motivaram.

A explicação longa sai **uma vez**; as outras 311 saem numa linha. Vinte linhas
vezes 312 desfariam a troca que este arquivo já fez uma vez, quando trocou "66
rastros de pilha por uma frase".

#### E o carregador do `setup-dev.sh` era surdo

```sh
docker compose exec -T mysql mysql ... < fixtures.sql 2>/dev/null || true
```

A razão de estar assim era boa: reaplicar as fixtures por cima de si mesmas
colide em chave primária, e `setup-dev.sh` foi feito para poder rodar de novo.
O preço era não saber a diferença entre *"já estavam lá"* e *"quebrou na metade
por chave estrangeira"* — que é a única distinção que importa ali.

Agora ele **pergunta antes**: se a unidade 90001000 existe, anuncia o passo
pulado; se não existe, carrega e falha alto se o cliente reclamar. É o mesmo
conserto que `montar-banco-contrato.sh` levou em 03/09, pela mesma razão — *"um
carregador que não sabe falhar é pior que nenhum"*.

#### O que continua não tendo conserto barato

**Não dá para reaplicar fixtures num banco que já as tem.** Os `INSERT` gravam
id explícito, e nem toda tabela do arquivo tem como distinguir a linha de
fixture da linha real — `vendas_produtos`, por exemplo, se identifica pela venda,
não por um id na faixa alta. Um script genérico de recarga seria adivinhação.

Então o banco de desenvolvimento continua servindo para o que só ele serve —
medir sobre dado real, 2,3 milhões de vendas —, e a suíte de cenário roda contra
os bancos que se remontam. A diferença é que agora isso está **escrito na falha**,
e não descoberto de novo a cada vez.

---

### D123 — A permissão da API nova nunca foi conferida: 31 endpoints abertos a qualquer sessão — CORRIGIDO em 07/09/2026 — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 07/11/2026, pela resposta 99 (a); o resgate de pontos foi ao D737

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decidir se as ações de dinheiro do cliente passam a exigir a funcionalidade órfã clientes/credito em vez de clientes/cadastro.

Achado em 07/09/2026, lendo o `ClientesController` para escrever a quinta
fatia. Os controllers de `/adm/api` conferem permissão assim:

```php
if (!$this->getAcesso('ler')) {
    return $this->erroPermissao('Sem permissão para ver clientes.');
}
```

Parece uma conferência de privilégio. Não é.

```php
public function getAcesso($privilegio = 'escrever') {
    return $this->sessao->getAcl($this->modulo, $this->funcionalidade, $privilegio);
}
```

**`$this->funcionalidade` nunca é definida em nenhum dos seis controllers.** Sem
ela, `SessaoAcl::getAcl()` cai no terceiro ramo:

```php
} else if (!empty($modulo)) {
    return $acl['funcionalidades'][$modulo] ?? null;
}
```

Ele devolve o **módulo inteiro** — `$acl['funcionalidades']['clientes']`, um
arranjo de cinco chaves que `setAcl()` monta para *todo* usuário, com `false`
dentro. Arranjo não vazio é verdadeiro. **O privilégio pedido não chega a ser
olhado**, e `'ler'` e `'escrever'` dão o mesmo resultado.

Dois dos seis — `Adm/ComandasController` e `Adm/VendasController` — não tinham
conferência **nenhuma**: só sessão, mais a autorização de gerente das duas ações
privilegiadas.

#### A medida, e ela estava rodando todo dia

`scripts/ensaiar-permissao-adm-api.php` entra como `colab.teste`, cujo grupo não
tem privilégio nenhum, e pede leitura e escrita em cinco recursos:

```
  recusou: 0 de 10
```

Não é leitura de código: é HTTP contra o sistema de pé. E uma das dez deixou
rastro — `POST /adm/api/clientes/90001000/desativacao` respondeu **200**, e o
cliente ficou com `status = 0`. Um usuário sem poder nenhum desativou um
cadastro.

**O mais desconfortável é que a demonstração já existia.**
`scripts/ensaiar-fluxo-adm-api.php` roda como `gerente.teste` e faz dezesseis
passos — cadastra cliente, marca horário, abre comanda, recebe pagamento, tira
dinheiro do caixa —, e passava em todo CI. As fixtures diziam, com todas as
letras, que aquele usuário entrava *"sem poder nenhum"*. O ensaio media o
caminho feliz e não tinha como notar que o caminho feliz não deveria existir.

#### O conserto

Quatro controllers ganharam `$this->funcionalidade` no construtor, espelhando a
tela do ERP que cada um substitui — `clientes/cadastro`, `agendas/visualizar`,
`vendas/caixa`. Os dois sem conferência ganharam `getAcesso2()` por ação, com a
funcionalidade que o ERP usa para a mesma operação: `comandasListar` para ler,
`comandasNova` para alterar, `comandasFechar` para finalizar, `cancelar` para
cancelar venda.

Depois: `recusou: 10 de 10`.

#### A fixture que precisou mudar de tabela, e o que isso ensinou

Com a conferência funcionando, `gerente.teste` passou a ser recusado — ele
nunca teve privilégio. Concedi os sete que o ensaio usa... na tabela errada:

```
1. cadastrar o cliente     403  FALHOU
```

Existem **duas** tabelas. `privilegios` é por GRUPO e alimenta a tela de
permissões; `privilegios_usuarios` é por USUÁRIO, e é dela que `SessaoAcl` monta
a ACL (`PrivilegioUsuario::selecionarPorUsuario()`). Escrever na primeira não
muda nada na sessão, e não dá erro nenhum.

O colaborador continua **sem privilégio de propósito**: é ele que prova a
recusa. Se um dia alguém lhe der um, o ensaio para de medir o que diz medir — e
vai dizer isso, porque a recusa deixa de vir.

#### E há uma funcionalidade que ninguém consulta

Achado no mesmo dia, escrevendo a quinta fatia. `SessaoAcl` declara
`clientes/credito` — "Clientes crédito" —, a tela de permissões deixa
concedê-la, e **nenhum controller a lê**. As telas de comprar crédito, devolver
crédito e resgatar pontuação vivem no `ClientesController`, que define
`funcionalidade = 'cadastro'` no construtor: é por `clientes/cadastro` que todas
passam.

O efeito prático: um administrador que **revoga "Clientes crédito"** acredita ter
bloqueado o mexer no dinheiro do cliente, e não bloqueou nada.

A quinta fatia usa `cadastro`, a mesma de hoje, de propósito — trocar mudaria em
silêncio quem pode mexer no dinheiro em produção, e isso é decisão de quem opera,
não de quem migra.

#### A varredura do ERP foi feita, e eu tinha escrito o contrário

A primeira versão desta entrada dizia: *"a mesma armadilha existe no ERP:
`ISVenda\Controller\CaixasController` também não define `funcionalidade`"*.
**Está errado, e a medição de 08/09/2026 desfaz a afirmação inteira.**

Aquele controller não chama `getAcesso()` — não há conferência inerte ali, há
outra coisa. E o número geral é o oposto do que eu supus:

| | |
|---|---|
| Controllers do ERP que chamam `getAcesso(` | **48** |
| Desses, quantos definem `funcionalidade` | **48** |

A armadilha era **exclusiva da API nova**, e a razão é a forma das duas. As
telas do ERP que mexem em caixa e venda sem passar pelo ACL — `CaixasController`,
`VendasProdutosController`, `VendasPagamentosController` — operam sobre
`selecionarPorUsuario($this->usuarioId)`: elas agem sobre **as linhas do próprio
usuário**, e é essa forma que as protege. A API pega o id **da rota**, e ao
mudar de forma perdeu a proteção que vinha de graça, sem que ninguém notasse.

Os outros onze controllers sem `getAcesso` também têm razão para não ter: sete
são as APIs do aplicativo (contrato congelado, autenticadas por token), um é o
login, e três são telas públicas abertas por id e hash.

Sobra **um**, e ele abriu um débito próprio: `FaturasController` decide com
`$grupoId == 3 || $grupoId == 4`. Ver **D124**.

#### O que este débito não fecha

A decisão sobre o `clientes/credito` órfão — se as ações de dinheiro devem
passar a exigi-lo em vez de `clientes/cadastro`. Trocar muda quem pode mexer no
dinheiro do cliente em produção, e é decisão de quem opera.

#### 07/11/2026: a resposta 99 (a), e o que entrou (a fatia 104, CREDITO-DO-CLIENTE-PEDE-PERMISSAO)

O dono respondeu a pergunta 99 em 12/10/2026 com a (a): "Pedir 'Clientes crédito' também no ERP e no servidor"
(`respostas/permissao-clientes-credito.json`). Conferido antes de mexer: o defeito ainda existia —
`ClientesController::creditoComprarAction` e `creditoDevolucaoAction` só chamavam `getAcesso()` (o cadastro), e a API
(`Adm\FidelidadeController::comprarCreditoAction` e `devolverCreditoAction`) também; só a lista antiga
(`aclCredito`) e a tela nova (`Console.tsx`, `PRIVILEGIO_DO_CREDITO`) escondiam o botão.

As portas foram derivadas dos chamadores do serviço, e não de memória: `comprarCreditoTravado` e `creditoDevolucao` têm
exatamente estes quatro chamadores. **As quatro pedem agora o cadastro E o crédito** (`clientes/credito/escrever`): no ERP,
pelo `getAcesso2(self::FUNCIONALIDADE_DO_CREDITO)`, e sem ele o 404 das outras recusas (a página não abre, o POST não
grava); na API, por `clienteParaEscrever($oQue, self::FUNCIONALIDADE_DO_CREDITO)`, o 403 `sem_permissao` antes de buscar o
cliente e antes da confirmação repetida da fatia 103. A lista antiga só desenha o "Crédito" a quem tem os dois (o modal
abre as duas páginas). O `creditoDetalhes` (ver o saldo) segue no cadastro: a pergunta foi sobre mexer no dinheiro.

**O resgate de pontos ficou como estava** — na API e na tela nova, o cadastro; no PDV antigo, o código do cliente ou a
senha do gerente (o D88). A pergunta 99 o citava no contexto, mas as opções e a tela nova falam de comprar e devolver;
a escolha é do dono, e foi ao lote como o **D737**. Um caso fixa o comportamento de hoje.

**Medido na cópia (SELECT de contagem, 07/11/2026):** dos não administradores ativos, 170 têm o cadastro com escrita e
165 têm "Clientes crédito"; 6 têm o cadastro sem o crédito (14 contando os inativos), e **nenhum** não administrador ativo
sem "Clientes crédito" comprou crédito alguma vez (as 5.054 compras da cópia são de 206 pessoas). O "umas 194" da
pergunta era a conta de outubro, e não se repete hoje. O aviso de distribuir a permissão antes de subir o código está no
`docs/procedimento-de-corte.md`; nenhuma permissão foi dada a ninguém na cópia. O gerente das fixtures (90001001) ganhou
`Clientes crédito` (a 11ª linha do `fixtures.sql`), porque os ensaios do fluxo e da fidelidade compram crédito como ele.

**Provado**, nos dois bancos: `D123CreditoPedePermissaoTest` (8 casos: a compra e a devolução do ERP sem o crédito, 404 e
nada gravado; só o crédito sem o cadastro, 404; as duas, grava; a lista; a API sem o crédito, 403 e nada gravado; as duas,
201 e 200; o resgate segue no cadastro). Os mutantes (arrancar o `getAcesso2` de cada página, a conferência de
`clienteParaEscrever`, o argumento de cada ação da API, a condição da lista) ficaram vermelhos e voltaram pelo sha1. O
`scripts/conferir-autorizacao-privilegiada.php` vigia as cinco: `getAcesso2` e `FUNCIONALIDADE_DO_CREDITO` nas duas
páginas, a constante nas duas ações da API e o `getAcesso2` em `clienteParaEscrever` (falsificado arrancando o código).

**A lista do corte só via a compra** (a verificação da fatia, 07/11/2026): a devolução, no código de antes do corte, não
grava quem a operou (o D179), e quem só devolve não aparecia nela. O item 13 do `docs/procedimento-de-corte-etapa-1.md`
ganhou a segunda consulta, só leitura: quem tem o cadastro com escrita e não tem "Clientes crédito" (na cópia, 6
pessoas), que são todos os que perderiam comprar ou devolver.

---

### D124 — A mesma pergunta, três listas de números diferentes: o grupo decidido por id — CORRIGIDO em 09/09/2026 — PARCIAL, lido em 05/10/2026 — PREPARADO em 13/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decidir quem um gerente pode nomear (GrupoRepository:36, a última decisão pendente); aplicar a migração do Líder em produção, quando o dono decidir.
>
> **06/10/2026:** decidido pelo dono (lote de perguntas, pergunta 11): a migração do Líder roda no passo 3 da janela, com a aplicação parada. Falta: o passo na janela, e quem um gerente pode nomear (GrupoRepository:36), que não estava no lote.

Achado em 08/09/2026, varrendo o ERP para fechar a pendência do D123. Encontrei
um controller decidindo permissão assim:

```php
if ($grupoId == 3 || $grupoId == 4) {
```

E não é um caso isolado. **São 56 sítios em 12 arquivos**, citando os grupos
1, 2, 3, 4, 7, 9 e 11 por número — em controllers, em formulários, no layout e
dentro do gerador de PDF de vendas.

#### O que esses números querem dizer, e por que a pergunta já tem resposta

A tabela `grupos` tem colunas booleanas que respondem exatamente o que o código
está perguntando: `gerente`, `administrador`, `caixa`, `colaborador`.

| id | nome | `gerente` | `administrador` | usuários ativos |
|---:|---|:---:|:---:|---:|
| 1 | Colaborador | | | 180 |
| 2 | Colaborador caixa | | | 61 |
| 3 | Gerente unidade | ✓ | | 105 |
| 4 | Gerente geral | ✓ | ✓ | 10 |
| 5 | Colaborador agenda | | | 0 |
| 6 | Colaborador comanda | | | 0 |
| **7** | **Gerente** | **✓** | | **3** |
| **8** | **Líder** | | | **5** |
| 9 | Relatório vendas | | | 0 |
| 10 | Vendas realizadas | | | 1 |
| 11 | API Integração | | | 1 |

**A mesma pergunta — "esta pessoa é gerente?" — é respondida por listas
diferentes em lugares diferentes:**

| onde | a lista |
|---|---|
| `FaturasController:22` | `3, 4` |
| `layout.phtml:606` | `3, 4` |
| `clientes/pacotes.phtml:53` | `3, 4, 7` |
| `ISRelatorio\Controller\VendasController:45` | `2, 3, 4, 7, 9` |

O grupo **7 é "Gerente", com `gerente = 1`** — e são **três pessoas** que veem
um sistema diferente das outras 115, conforme a tela tenha lembrado do 7 ou não.
O grupo **8 é "Líder", com cinco pessoas**, e não é citado em lugar nenhum: ele
cai no `else` de todas as 56 decisões.

#### Por que isto é débito de migração, e não só feiura

Porque **essas regras não aparecem na tela de permissões**. Quem administra o
sistema não consegue conceder nem revogar nada disso: para mudar quem vê a tela
de faturas é preciso editar PHP. E porque a Fase 3 vai reescrever essas telas em
React — e o front novo vai precisar das regras, que hoje só existem espalhadas
em 56 comparações.

É a mesma família do **D18** (ids 1 e 2 de clientes como sentinelas), do
`grupo = 1` da rotina de evasão e do `area = 3` do barbeiro: **identidade de
negócio expressa como número mágico**.

#### CORRIGIDO em 09/09/2026 — a metade que não dependia de decisão

**A premissa deste registro estava errada, e errada para melhor.** Ele dizia
para trocar `$grupoId == 3 || $grupoId == 4` por `$grupo->getGerente()`, como se
fosse preciso carregar a entidade. Não é: a sessão **já publica os quatro
booleanos**, ao lado do próprio id, desde sempre —
`SessaoAcl.php:60-74` monta `'grupo' => $grupo->getId()` seguido de
`'administrador'`, `'gerente'`, `'caixa'` e `'colaborador'`.

Ou seja: em todos os sítios, a resposta certa estava na mesma variável de onde
se lia o número. E os dois idiomas já conviviam — `VendasController` compara id
na linha 44 e escreve `if (!$usuario['administrador'])` na linha 56.

**Foram trocados os 27 sítios em que a lista de ids é IDÊNTICA ao conjunto de
uma coluna** — conferido no banco, e não suposto:

| lista no código | coluna | conjunto | sítios |
|---|---|---|---:|
| `3 \|\| 4 \|\| 7` | `gerente` | {3, 4, 7} | 20 |
| `2` | `caixa` | {2} | 6 |
| `4` | `administrador` | {4} | 2 |

Nenhuma pessoa muda de acesso: os conjuntos são os mesmos hoje. E não podem
derivar pela interface, porque **não há tela de grupo**: `GruposController`
tem `novoAction`, `editarAction` e `ativarRemoverAction`, e os três são
`return $this->notFoundAction()`. Só a listagem responde.

Isso corrige uma implicação deste registro: a frase "para mudar quem vê a tela
de faturas é preciso editar PHP" vira "é preciso editar SQL". Melhor, e ainda
não é a tela de permissões.

#### A única diferença semântica, e ela apareceu por um teste vermelho

Com ids, a cadeia `if (== 1) elseif (== 2) elseif (== 3||4||7)` era exclusiva
**por construção**: um id tem um valor só. Com colunas, `if (== 1) elseif
(caixa) elseif (gerente)` resolve empate **por ordem** — caixa ganha de gerente.

Nenhum dos onze grupos de produção acumula papel, então nada muda hoje. Eu
escrevi o teste afirmando isso para TODOS os grupos e **ele ficou vermelho na
hora**: o próprio `fixtures.sql` cria um "Gerente (teste)" que é gerente *e*
caixa. O cenário estava certo e a minha afirmação estava larga demais. O teste
passou a vigiar os grupos de produção, e a precedência ficou escrita nos dois
PDFs.

#### Os números deste registro estavam pequenos

Não eram 56 sítios em 12 arquivos. A varredura por `== <número>` perde quatro
formas, e todas existem aqui:

| o que a varredura perdia | onde |
|---|---|
| `!= 1` (escrito ao contrário) | `layout.phtml:333`, o menu "Dash. Colaboradores" |
| `in_array($usuarioGrupo, ['3','4'])` (strings) | `vendas/detalhes.phtml:153` |
| lista de ids **dentro do DQL** | `GrupoRepository.php:36` — `gru.id IN(1,2,3,6,8)` |
| filtro de id **dentro do DQL** | `UsuarioRepository.php:356` — `u.grupo = 1` |

O `!= 1` do layout foi achado **pelo portão novo**, e não por mim: eu tinha
inventariado à mão e passei direto por ele. Varredura por olho perde o caso
escrito ao contrário.

#### O que sobrou, e por que cada um sobrou

`scripts/conferir-grupos-por-id.php`, no `conferir-tudo.sh`. Ele recalcula as
decisões por id e compara com um inventário onde **cada uma tem escrito por que
ficou**. Falha nos dois sentidos, e falha também se a sessão deixar de publicar
as quatro colunas — que é o modo de falha silencioso da própria correção: sem a
chave, a leitura devolve nulo, o `if` fica falso, e todo gerente perde acesso
sem erro nenhum.

Restam **20 expressões / 36 comparações**: 33 esperando decisão, 2 de rota, 1
legítima (`VipClassRepository` monta a lista a partir de `vipclass.grupos` — ali
o id é mesmo um id).

#### As decisões, tomadas em 09/09/2026

As três perguntas foram levadas ao dono com a medida de cada uma ao lado, e
voltaram respondidas no mesmo dia.

**1. O grupo 7 "Gerente" é gerente — SIM.** As cinco listas que o excluíam
viraram `gerente`:

| onde | era | virou |
|---|---|---|
| `layout.phtml:600` (menu Faturas) | `3 \|\| 4` | `$usuario['gerente']` |
| `FaturasController:22` | `3 \|\| 4` | `$this->usuarioGerente` |
| `vendas/detalhes.phtml:153` | `in_array(…, ['3','4'])` | `$usuarioGerente` |
| `Form/Venda.php:21` | `2 \|\| 3 \|\| 4` | `caixa \|\| gerente` |
| `VendasController:46` | `{2,3,4,7,9}` | `gerente \|\| caixa \|\| grupo == 9` |

As três pessoas do grupo 7 passam a ver Faturas, o item de menu, a comissão por
item e o combo do relatório — o que a tabela já dizia serem delas, e o que 14
dos 16 sítios já concediam. **A tela de Faturas era a única das quatro sem
funcionalidade no ACL**: ali aquela linha era a guarda inteira.

Sobrou um pedaço, e está no portão: o `== 9` que restou de `{2,3,4,7,9}` depois
de `gerente` e `caixa` responderem pelo resto. O grupo 9 não tem coluna que o
descreva e tem zero usuários ativos.

**2. O grupo 8 "Líder" tem visão de gerente — e isso virou uma linha de SQL.**

Aqui está o retorno concreto do trabalho de hoje. Antes, dar visão de gerente ao
Líder exigiria editar PHP em cada tela, porque cada tela perguntava o id. Depois
da troca, as telas leem `grupos.gerente` — e a regra passou a caber no banco:

```sql
UPDATE grupos SET gerente = 1 WHERE id = 8 AND nome = 'Lider';
```

Está em `scripts/migracao-grupo-lider-gerente.sql`, esperando a janela de
produção junto com os `ALTER` do D54 e do D86. **`colaborador` não muda**: a
coluna é lida noutros lugares, e tirá-la resolveria um problema que ninguém tem.
O grupo passa a acumular os dois papéis **de propósito**, e a precedência das
cadeias (caixa, depois gerente) está escrita nos dois `PdfVendas`. O
`PapelDoGrupoTest` já carrega a exceção, para que aplicar o SQL não produza um
vermelho que ninguém esperava.

> **Até 03/10/2026 o procedimento do corte não a citava** — "junto com os `ALTER` do D54 e do D86"
> era a intenção, e o passo 0 só listava os `ALTER`. Agora ela é uma linha `| 0 |` (D323), e o portão
> do passo 0 reprova se ela sair. Ensaiada num banco descartável com a linha `8, 'Líder'`: deu
> `gerente = 1` — o `'Lider'` sem acento do `WHERE` casa pela collation `utf8mb3_general_ci` —, e a
> segunda vez não mudou nada.
>
> **E QUANDO aplicá-la é pergunta ao dono** (revisão de 03/10/2026, lote de perguntas). Antes da
> janela quem lê `grupos.gerente` é o código antigo (`eaf1fca`), e nele o papel autoriza fiado sem
> olhar a unidade de quem autoriza, devolve crédito de cliente com a senha de um gerente de qualquer
> unidade, e cancela venda da própria unidade -- não é só o relatório completo deste registro. Na
> janela (passo 3), o código antigo nunca a vê. Até a resposta, a linha do passo 0 diz "espera a sua
> decisão" e a migração não está no laço.

O que ele deixa de sofrer: hoje o grupo 8 não casa nenhuma cadeia dos
relatórios, então cai no `else` de todas — nos PDFs isso significa **linha
nenhuma**, tabela vazia, sem erro e sem log.

**3. Quem um gerente pode nomear** continua em aberto — `GrupoRepository:36`
segue com `gru.id IN(1,2,3,6,8)`, omitindo o 4 e o 7 e incluindo o 6, que tem
zero usuários. É a última decisão pendente deste débito, e está no portão.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 11): a opção (b), "Na janela, no passo
3" -- o código antigo nunca a vê, e o PostgreSQL já nasce com ela. A linha do passo 0 deixou de dizer
"espera a sua decisão": diz que a migração roda no passo 3, e o comando está lá, depois da limpeza e
antes do dump. O portão do passo 0 continua com saída 0 (ela é citada, e não está no laço); falsificado
em cópias do procedimento -- a linha sem citá-la, e a linha começando por "Aplicar" sem a entrada no
laço --, sai com 1 nas duas. **A revisão de 06/10/2026 notou** que essas duas falsificações provam o
portão que já existia, e não a decisão: com a linha de volta ao texto de antes ("**Espera a sua
decisão:**"), ela seguia citada e fora do laço, e o portão saía com 0. Quem pega isso agora é `php
scripts/ensaiar-sql-do-passo-3.php`, que exige "passo 3" no rótulo da linha do passo 0, recusa o
"Aplicar", e exige o `mysql franquiabv < scripts/migracao-grupo-lider-gerente.sql` num bloco de código do
passo 3, depois da limpeza -- falsificado com cada uma das três, sai com 1. E roda a migração num banco
descartável com o grupo `8, 'Líder'`: `gerente = 1`, e a segunda vez não muda nada, no 8.4.11 e no
8.0.46. O que resta deste débito: quem um gerente pode nomear (o 3, acima) e a migração na janela.

#### Quem um gerente pode nomear — 13/10/2026 (a resposta 131 do dono)

O dono respondeu a pergunta 131 do lote, em 11/10/2026, com a opção (b), **e não a recomendada**: *"Derivar a lista: todo
grupo sem administrador e sem funcionalidade de rede"*.

**O conserto** (o item UNIDADES-GRUPOS-ROYALTIES). `GrupoRepository::popularCombobox` deixou o `gru.id IN(1,2,3,6,8)`:
para quem não é administrador, a lista são os grupos ativos sem `administrador` (o nulo, que a coluna aceita, não é
administrador) e sem funcionalidade de rede no molde -- uma linha de `privilegios` do grupo que concede `ler` ou
`escrever` numa de `RegraDoPrivilegio::DE_REDE`; a linha "nada" não conta. É a mesma pergunta de
`UsuarioService::recusaDoGrupo`, que recusa ao gravar: a lista e a recusa não podem discordar. Na cópia, **entram o 7
(Gerente, 3 pessoas ativas), o 9 (Relatório vendas, 0) e o 10 (Vendas realizadas, 1)**, e ficam de fora o 4 (o de
administrador), o 5 (Colaborador agenda, com a "Agendas unidades" no molde) e o 11 (API Integração); os 107 que cadastram
colegas sem ser administradores passam a poder pô-los no grupo Gerente, e todo grupo novo entra sozinho. O formulário de
usuário do ERP (`UsuarioDados`, cujo `Select` recusa o que não oferece) e a `/adm/api/usuarios/grupos` -- o filtro da
tela nova de Usuários, que lê a lista do servidor e não mudou -- usam a mesma consulta. As outras listas que a usam (os
filtros de mensagens, assistentes, VIP Class, privilégios e marketing) passam a oferecer os mesmos grupos a quem não é
administrador; são filtros, e não dão acesso a nada. No `conferir-grupos-por-id.php` a linha da lista de ids saiu do
inventário (de 13 para 12 expressões): se a lista de dígitos voltar, a agulha de dígitos acusa.

**Provado** em `tests/Caracterizacao/GruposQueONaoAdministradorPoeTest.php` (4 casos, nos dois bancos, com grupos do caso
na faixa 90045001): quem não é administrador vê o comum, o da linha "nada" e o de administrador nulo, e não vê o de
administrador, o de rede nem o inativo; o administrador vê todos os ativos; o molde que ganha a funcionalidade de rede tira
o grupo da lista; e o formulário do ERP oferece a mesma lista, com a recusa ao gravar dizendo o mesmo. **Mutantes:** a
lista de ids de volta, o `NOT EXISTS` da rede arrancado, a linha "nada" contada como rede e o nulo contado como
administrador -- cada um vermelho.

**O que resta deste débito** é só a migração do Líder, que roda no passo 3 da janela (a resposta 11, de 06/10/2026), já
escrita e no procedimento: por isso PREPARADO.

**A revisão (13/10/2026): a lista e a recusa discordavam no grupo de administrador.** O parágrafo acima dizia que as duas
fazem a mesma pergunta, e não faziam: a lista tira o grupo com `administrador` (o 4, "Gerente geral"), e
`UsuarioService::recusaDoGrupo` só olhava a rede no molde -- e o molde do grupo de administrador não tem linha nenhuma,
porque o administrador entra sem linha. Na cópia, o 4 é o único grupo de administrador e o molde dele tem 0 linhas
(contagem de 13/10/2026): a recusa o deixava passar. O ERP ficava
protegido só porque o `Select` do `UsuarioDados` recusa o 4 antes; uma porta que gravasse o grupo confiando na recusa
deixaria quem não é administrador pôr um colega no grupo 4. **Consertado:** a recusa pergunta também o `administrador` do
grupo novo, com o motivo `RegraDoPrivilegio::GRUPO_DE_ADMINISTRADOR` e a frase do ERP ("O grupo escolhido é o de
administrador: só um administrador põe alguém nele."); o nulo continua não sendo administrador, como na lista. O caso
`testAListaEARecusaDizemOMesmoEmCadaGrupoAtivo` confere, grupo a grupo, que o grupo está na lista de quem não é
administrador se, e só se, a recusa o deixa passar, e que todo motivo tem a frase. **Mutante:** a recusa sem o grupo de
administrador, vermelho.

**O "LEGÍTIMO" da VIP Class saiu do código (05/11/2026, a fatia 81, VIPCLASS-QUEM-FEZ; o D716).** O inventário de
`scripts/conferir-grupos-por-id.php` tinha uma expressão LEGÍTIMA -- o `gru.id IN (" . $vipclass->getGruposStr()` de
`VipClassRepository::relatorioUsuarios`, o id do grupo como id, a lista vinda de `vipclass.grupos`. O relatório passou a
`VipClassRepository::equipe`, que confere a publicação do curso pela `RegraDoCursoVipClass::publicadoPara`, em PHP, com o id do
grupo da pessoa contra os ids do curso: a mesma decisão (id como id), sem SQL montado. A linha saiu do inventário (de 9 para 8
expressões); nenhuma decisão do dono mudou.

---

### D158 — O PDF esconde o valor do colaborador; o XLS do mesmo botão mostra — CORRIGIDO em 09/09/2026

Achado em 09/09/2026, varrendo o D124.

`ISRelatorio\Controller\VendasController::exportarAction` atende os dois
formatos sob a **mesma guarda** e com o **mesmo `$usuario`**. O que os separa é
um parâmetro que o próprio usuário controla:

```php
$request['formato'] = $this->params()->fromQuery('formato', 'pdf');
```

**O PDF decide por papel.** Para um colaborador comum, `PdfVendasVersao1.php:109`
monta a linha com `(nome, quantidade, cota-parte)` — sem o valor da venda.

**O XLS não decide nada.** `grep -n "grupo" module/ISRelatorio/src/Xls/*.php`
devolve **zero linhas** nos quatro geradores. `VendasVersao1.php:52-55` escreve
o cabeçalho `Produtos | Serviços | Gorjeta`, `Qtd.`, **`Valor Total`**,
`Cota-parte` para todo mundo.

#### Quem alcança

| grupo | ativos com `Relatórios de vendas` ler=1 |
|---|---:|
| 1 Colaborador | **178** |
| 2 Colaborador caixa | 58 |
| 3 Gerente unidade | 105 |
| 7 Gerente | 3 |
| 8 Líder | 1 |
| 10 Vendas realizadas | 1 |

(O grupo 4 não aparece porque é administrador e recebe tudo pelo
`Acl::carregarGrupos()`, não por linha em `privilegios_usuarios`.)

**178 colaboradores comuns podem ver, trocando `pdf` por `xls` na URL, o valor
que o PDF foi escrito para esconder deles.**

#### Por que isto é filho do D124

A regra "colaborador não vê valor" nunca existiu num lugar só: ela vive em 20
comparações espalhadas por dois geradores de PDF. O caminho do XLS foi escrito
depois e simplesmente **não a implementou** — e não havia como notar, porque não
há nada que diga o que a regra é.

#### DECIDIDO em 09/09/2026 — o XLS passa a esconder

Escolha do dono: o valor bruto não é do colaborador, que é o que o PDF já
afirmava. Os dois geradores XLS passaram a espelhar o PDF:

| papel | vê `Valor Total` | vê `Cota-parte` |
|---|:---:|:---:|
| colaborador | não | sim |
| caixa | sim | não |
| gerente | sim | sim |

As dezesseis escritas de célula em cada arquivo passaram por dois escritores
condicionais, `celulaValor()` e `celulaCotaParte()` — inclusive as do
**cabeçalho**, porque uma coluna vazia rotulada "Valor Total" convida à
pergunta que a remoção deveria encerrar.

**Uma diferença deliberada em relação ao PDF.** Lá, quem não casa nenhum dos
três papéis não recebe linha alguma — a tabela sai vazia. Aqui a linha sai com
nome e quantidade, sem os dois valores. Esconde tudo o que o PDF esconde, e não
troca uma planilha por um arquivo em branco, que não ajuda ninguém a entender o
que aconteceu.

**Mais três sítios saíram em 30/09/2026, com o Profissional Parceiro** (`docs/f3-relatorios.md`,
seção 5): os do Dash. Colaboradores -- o controller (`grupo == 1`, a linha que vê só a si
mesmo), a view (`grupo == 1`, o filtro de colaborador) e o item do menu no `layout.phtml`
(`grupo != 1`). O escopo passou a sair das MARCAS, numa função só,
`ISRelatorio\Regra\RegraDoProfissionalParceiro::escopo`: administrador vê a rede, gerente a
unidade, quem tem `visivel_dashboard` só a si mesmo, e o resto não abre. A mesma função decide
a marca de menu da sessão (`SessaoJson`), o menu da tela antiga e a API nova. O inventário de
`scripts/conferir-grupos-por-id.php` registra a saída dos três.

---

### D125 — O construtor do Produto apagava seis campos que a tela acabava de mandar — CORRIGIDO em 08/09/2026

Achado em 08/09/2026, lendo a entidade para escrever o `POST` da sexta fatia.

```php
public function __construct(array $options = array()) {
    (new ClassMethodsHydrator())->hydrate($options, $this);   // grava
    $this->dataCriacao = new \DateTime('now');
    $this->pacoteItens = new ArrayCollection();
    $this->periodos = 1;                                      // e apaga
    $this->area = null;
    $this->tag = null;
    $this->visivelApp = false;
    $this->linhaVip = false;
    $this->insumo = false;
}
```

Ele hidratava e **depois** sobrescrevia seis campos com o padrão. `insumo` e
`linha_vip` são dois selects **renderizados na tela de produto novo**
(`novo.phtml:92`) e declarados no formulário (`Form/Produto.php:37,43`): o
operador marcava, salvava, e o produto nascia com os dois falsos. Sem erro, sem
aviso.

#### O tamanho do contorno

Medido na cópia de produção, sobre 9.507 produtos:

| campo | produtos que o têm | |
|---|---:|---:|
| `area` | 6.775 | 71% |
| `visivel_app` | 6.344 | 67% |
| `tag` | 1.656 | 17% |
| `linha_vip` | 1.637 | 17% |
| `periodos ≠ 1` | 543 | 6% |
| `insumo` | 50 | 0,5% |

**Nenhum desses valores pôde ter sido gravado na criação.** Todos vieram de uma
edição posterior — `ProdutoService::update()` hidrata sem passar pelo construtor,
e por isso funciona. O sistema exigia dois passos e ninguém sabia por quê; quem
criasse e não voltasse para editar ficava com um produto invisível no aplicativo.

#### O conserto, e o que ficou onde estava

Os seis padrões passaram para **antes** da hidratação. `dataCriacao` e
`pacoteItens` continuam depois, de propósito: não são dado de formulário, e o
instante da criação é o da linha, não o do pedido — um `data_criacao` no corpo
não pode escolher quando o produto nasceu. Há teste para as três coisas.

**`clonar()` nunca foi afetado**: ele usa `clone`, não o construtor. O único
caminho que chamava `new Produto($data)` era o `insert`.

#### E um segundo, achado pelo teste e não pela leitura

`Produto::getValorVenda()` e `getValorCompra()` **mascaram por padrão**:
devolvem `"89,90"`, e o argumento `false` é o que faz devolver o número. O
recurso saía com `89.00`, porque `(float) "89,90"` é 89 — o PHP para de ler na
vírgula.

É a mesma família do `Cliente::getDataNascimento()`, que devolve `d/m/Y` e quase
mandou uma data brasileira como se fosse ISO na quarta fatia. **Getter que
formata para a tela só é pego por um teste de VALOR**: um teste de tipo passaria,
porque `"89,90"` e `"89.90"` são as duas strings.

---

### D126 — Cancelar movimentação de estoque não conferia unidade nem estado — CORRIGIDO em 08/09/2026; o piso, que comparava texto com número, e o cancelamento sob trava — CORRIGIDO em 04/10/2026 — DECIDIDO em 06/10/2026

Achado em 08/09/2026, lendo a tela de estoque para escrever a sétima fatia.

```php
public function estoqueCancelarAction() {
    if ($this->getAcesso()) {
        $movimentacao = $this->em->getRepository(ProdutoEstoque::class)->find($this->params()->fromRoute('id', 0));

        if (!empty($movimentacao)) {
            $this->sm->get(ProdutoService::class)->estoqueCancelar($movimentacao);
```

Duas guardas faltavam, e as duas importam porque **cancelar mexe no estoque**:
`estoqueCancelar()` reverte a quantidade no produto.

#### 1. A unidade

A ação irmã, `estoqueAction`, passa por `selecionarProduto()`, que confere
`getUnidade()->getId() == $this->unidadeId || $this->usuarioAdmin`. Esta fazia
`find($id)` sobre a movimentação e nada mais.

| | |
|---|---:|
| Movimentações na base | **33.573** |
| Maior id | 33.576 — **sequencial** |
| Unidades alcançadas | **51** |

Qualquer usuário com `configuracoes/produtos` cancelava a movimentação de
qualquer unidade trocando o número na URL. É a mesma família do D86: a regra
existia na tela ao lado e não nesta.

#### 2. O estado

Cancelar reverte a quantidade. Cancelar **duas vezes** reverte duas — e nada no
controller ou no serviço olha `getStatus()` antes de agir. Quem impedia era o
**template**, que esconde o botão quando a movimentação já está cancelada
(`estoque.phtml:107`).

Template não é guarda: o botão não é a única porta. Há **715 movimentações já
canceladas** na base, cada uma um alvo para uma segunda reversão.

#### O conserto

As duas entraram na tela do ERP — `selecionarProduto()` para a unidade,
`getStatus()` para o estado — e a sétima fatia da API nasce com elas fechadas,
com `404` para movimentação de outro produto e `409` para a já cancelada.

#### O que fica anotado

`ProdutoService::estoqueAtualizar()` continua aceitando levar o estoque a
negativo se alguém o chamar direto: a conferência de piso mora em **quem chama**
— um `if` no controller —, não nele. Mover a regra para o serviço é o conserto
certo e mexe no caminho da tela do ERP; não foi feito junto para a fatia não
mudar o comportamento que está caracterizando.

#### O conserto do piso, e o cancelamento sob trava — 04/10/2026

**O piso estava pior do que o parágrafo acima dizia.** Não era só "mora em quem chama": quem chamava
comparava errado. A tela do ERP fazia `$request['quantidade'] <= $produto->getQuantidade()` — o texto
do formulário, com a máscara, contra o número do banco. No PHP 8 o texto `"10,00"` não é numérico,
então o número vira `"5"` e a comparação é de texto. Medido no contêiner (PHP 8.1.34):
`"10,00" <= 5.0` dá `true` — a retirada de 10 passava com 5 no estoque e o deixava em -5 — e
`"2,00" <= 10.0` dá `false` — a retirada de 2 com 10 no estoque era recusada. A API comparava inteiros
e acertava.

**O conserto, no serviço, para as duas portas:**

- `ProdutoService::estoqueAtualizar()` tira a máscara e compara em **centésimos** (o ruído do `double`
  não recusa o saldo inteiro), **sob a trava do produto** e com o saldo **relido depois dela**
  (`refresh` com `PESSIMISTIC_WRITE`, a leitura com trava do D285). Recusa a operação que não é
  entrada nem saída (era o `if` da tela que a recusava; o `else` do serviço a tratava como saída) e a
  quantidade que não é número ou é negativa ("-10,00" como entrada tirava dez sem passar pelo piso).
  Na cópia, nenhuma das 33.576 movimentações é negativa; o zero continua aceito (há 11).
- A tela do ERP passou a ler a resposta e mostra a frase de antes com o saldo relido. A API também
  só lê a resposta e responde o mesmo 409, com o saldo relido. (Na primeira versão do conserto a API
  manteve a conferência dela antes do serviço, e ela truncava o saldo — ver a revisão, abaixo.)
- **O cancelamento (o resto do D199):** `estoqueCancelar()` trava o produto, relê a movimentação pela
  leitura com trava e desiste se ela já foi cancelada. As duas portas leem a resposta: o ERP diz
  *"Esta movimentação já estava cancelada."*, a API responde 409. Antes, duas sessões — o ERP numa
  aba e a tela nova noutra — desfaziam a quantidade duas vezes.
- **A irmã, `retirada()`** (o modal "Retirada"), ganhou a mesma trava e o mesmo piso, e o controller
  dela só lê a resposta (a conferência em `double` que ele tinha saiu na revisão, abaixo). E o ramo
  de recusa dela chamava `getQuantidade()` no nulo — o D387.

**Provado** por `tests/Caracterizacao/EstoqueSobTravaTest.php`, 31 execuções, verdes nos dois bancos.
**Vermelho antes, nos dois:** sobre o código do HEAD, 27 das 31 — as quatro que passavam são os
caminhos bons (o ERP e a API cancelando, a API e o modal tirando o que cabe). Os três casos da trava
rodaram um por vez: o primeiro deles fecha o `EntityManager` (a escrita de antes parava adiante, e
não num `FOR UPDATE`: o lançamento e a retirada no `INSERT` de `produtos_estoque`, o cancelamento no
`UPDATE` de `produtos`) e os seguintes viravam cascata. Os vizinhos (`ProdutoApiTest`,
`RetiradasNoRelatorioTest`, o catálogo, as duas travas de dinheiro e os privilégios), na mesma
rodada, depois do caso que grava de verdade: 142 verdes nos dois bancos.

**21 mutantes, um de cada vez, vermelhos nos dois bancos** (os arquivos voltaram, conferidos byte a
byte contra a cópia do conserto). No serviço: sem o piso; o piso em `double`;
sem tirar a máscara; o D126 de volta dentro dele (o texto contra o número); relendo o produto sem
trava; travando sem reler; o cancelamento sem reler a movimentação, relendo-a sem trava e sem
conferir o estado relido; a trava sem transação própria (só o caso fora da transação do teste o
pega); aceitando quantidade negativa ou que não é número; aceitando operação desconhecida; a
retirada sem o piso e fora da trava. Nas portas: o ERP e a API ignorando a recusa do lançamento e
a do cancelamento; o ERP comparando texto com número antes de chamar; a retirada do ERP sem frase
na recusa do serviço e com o `getQuantidade()` no nulo. O cancelamento relendo sem trava fica
vermelho no PostgreSQL só pela ordem das leituras: lá o `refresh` simples já lê o confirmado, e a
foto é coisa do MySQL.

**O que ele não alcança, e fica registrado:**

- ~~A baixa da venda e a devolução do cancelamento de venda gravam `produtos.quantidade` sem esta
  trava, a partir do que leram no começo do pedido: uma movimentação que termine no meio é apagada
  por cima. E a edição do produto grava a `quantidade` do formulário, que é a do momento em que a
  página abriu: salvar um preço minutos depois desfaz, sem corrida nenhuma, toda movimentação feita
  nesse meio-tempo. É o D388, e são duas perguntas ao dono (a venda mexe em toda venda; a edição
  muda o que a tela de editar faz).~~ CORRIGIDO em 07/10/2026 no D388, como o dono decidiu (perguntas
  20 e 21): a venda e o cancelamento continuam sem esta trava, mas gravam por conta no banco
  (`quantidade = quantidade - ?`, e `+ ?`), e a movimentação do meio vale; a edição do produto não
  grava mais a quantidade, que fica só de leitura na tela.
- Cancelar uma **entrada** continua sem piso: o saldo pode ir a negativo. Anotado no D199; o código
  não decide se é de propósito, e foi para o lote de perguntas.
- A corrida com dois pedidos HTTP ao mesmo tempo não foi medida: a trava presa por outra conexão, e a
  linha mudada por ela, são o que se mede no lugar.
- **Duas abas do mesmo ERP não chegam a correr:** a sessão em arquivo do PHP trava do começo ao fim do
  pedido, então a segunda espera a primeira terminar. No cancelamento, ela acha a movimentação já
  cancelada na conferência do controller e responde "não encontrado", como antes do conserto; a frase
  nova só aparece entre sessões diferentes (dois usuários, ou o ERP e a tela nova).

#### A revisão do conserto — 04/10/2026

A verificação adversarial achou sete pontos, todos de gravidade baixa; os do código foram consertados
e os do texto, corrigidos.

- **O piso em centésimos inteiros tinha teto.** `cabeNoEstoque()` fazia `(int) round(... * 100)`, e o
  `(int)` de um `float` fora do inteiro de 64 bits dá a volta no PHP 8.1.34: "92.233.720.368.547.758,08"
  virava -9223372036854775808 centésimos, "184.467.440.737.095.516,16" e "1e400" viravam 0 — e a saída
  passava com 5 no estoque, onde o texto de antes do D126 a recusava. Agora os dois lados ficam em
  `float` arredondado, sem `(int)`, e a quantidade tem de ser **finita** ("1e400" como entrada
  gravaria saldo infinito). Só um POST escrito à mão chegava aqui: a máscara da tela para em 15
  dígitos, e a API em 2147483647.
- **As portas não faziam a mesma conta.** O modal "Retirada" conferia em `double` antes do serviço
  (com 0,7999999999999999 no estoque, a retirada de 0,80 era recusada ali e aceita na aba
  "Estoque"), e a API conferia `$quantidade > (int) $saldo`, que trunca (com 4,9999999999, a saída de 5
  levava 409 "há 4"). As duas conferências saíram: quem decide é só o serviço, e as portas leem a
  resposta. No `franquiabv_dev`, nenhum dos 5.771 produtos de estoque tem saldo fracionário — a
  diferença era só de regra.
- **Casos novos** no `EstoqueSobTravaTest`, que passa a **39 execuções**: as três saídas gigantes e a
  entrada infinita no serviço; a retirada gigante pelo modal; o modal e a API tirando o saldo que a
  conta do serviço cobre; e a frase da aba "Estoque" com o saldo **relido** (o produto carregado com
  5, o banco com 1, a frase diz 1) — antes só a API e o modal tinham esse caso.
- **O cenário dos casos da trava estava mal descrito.** O docblock dizia que o `setUp` põe o saldo em
  5, mas `comATravaPresa` troca a transação do `setUp` por uma limpa, e lá o saldo é o nulo da
  fixture. A retirada fora da trava (o mutante S13) ficava vermelha porque o piso a recusava antes de
  escrever, e não por ter passado pela trava. O caso da retirada agora põe o 5 na memória — o que a
  sessão leu antes de a outra prender a linha —, e o mesmo mutante fica vermelho pelo motivo certo:
  grava e para no `INSERT`.
- **Os textos:** o parágrafo "Provado" acima dizia que as três escritas de antes paravam no `INSERT`
  (o cancelamento parava no `UPDATE` de `produtos`); o roadmap prometia a frase nova "em duas abas"
  (ver o último item da lista acima); e o D388 tratava a edição do produto como corrida de
  milissegundos.
- **26 mutantes, um de cada vez, vermelhos nos dois bancos:** os 21 do conserto, de novo sobre a
  versão revisada, e cinco novos — o piso de volta aos centésimos em `(int)` (vermelho nas saídas
  gigantes, no serviço e no modal); a quantidade infinita aceita (a entrada "1e400" cai no `INSERT`
  com valor fora do intervalo, nos dois bancos — o PostgreSQL recusa o texto, não chega a gravar
  `Infinity`); o modal conferindo em `double` antes do serviço; a API conferindo o saldo truncado; e
  a frase da aba "Estoque" com o saldo lido antes do serviço. O S13 (a retirada fora da trava) agora
  fica vermelho por *"parou na trava, e não adiante"*, no `INSERT` de `produtos_estoque`, nos dois.
- **Depois da revisão:** o arquivo, 39 verdes nos dois bancos; os vizinhos numa rodada só, 150 verdes
  nos dois; a suíte inteira, 1.758 execuções (13 puladas), verde nos dois; os portões
  (`conferir-tudo.sh`), 51 ok e o vermelho conhecido do agendamento (D50).

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 19): a opção (b), "Deixar como está"
-- o cancelamento de uma ENTRADA continua sem piso, porque é a ferramenta de corrigir lançamento errado,
e o estoque já fica negativo pela venda. Nada muda no código. A gravação da venda e da edição do produto
sem a trava, que este conserto também não alcança, é o D388, com decisão própria (decidida nas perguntas
20 e 21, e feita em 07/10/2026: ver o D388).

---

### D127 — Personificar perde o próprio rastro — ANOTADO em 08/09/2026, e a porta ganhou vigia em 15/09/2026: o motivo escrito dizia que ela era guardada, e nenhum portão conferia — CORRIGIDO em 12/10/2026

Achado em 08/09/2026, lendo `UsuariosController` para escrever a oitava fatia.

```php
public function autenticarComoAction() {
    if ($this->usuarioAdmin || !empty($this->sessao->getUsuario('usuarioSessao'))) {
        $usuario = $this->em->getRepository(Usuario::class)->find($this->params()->fromRoute('id', 0));
        ...
        if (empty($this->sessao->getUsuario('usuarioSessao'))) {
            $sessaoAut->setAcl($this->usuarioId);      // entra, e MARCA quem entrou
        } else {
            $sessaoAut->setAcl(null, null);            // "volta", e LIMPA a marca
        }
```

A intenção é clara: administrador entra como outra pessoa, a sessão guarda em
`usuarioSessao` **quem** entrou, e o botão de voltar desfaz. São **10 pessoas
ativas** com `administrador = 1` (grupo "Gerente geral").

**O ramo de voltar não volta para quem entrou — volta para o id da rota.** E a
condição da porta inclui `|| !empty(usuarioSessao)`: enquanto se está
personificando, ela passa para **qualquer id**.

Então: um administrador personifica X e, dali, "volta" como Y. Fica Y, e
`usuarioSessao` fica nulo — **sem rastro de que houve personificação**.

#### O que isto é, e o que não é

**Não é escalada de privilégio.** A porta só abre para quem já é administrador,
e administrador já pode personificar qualquer um pelo caminho normal.

**É perda de rastro**, e o rastro é a única coisa que distingue personificação
legítima de acesso indevido. Depois do desvio, a sessão é indistinguível de um
login normal de Y.

#### Por que não foi consertado junto

O conserto óbvio — o ramo de voltar usar `usuarioSessao` em vez do id da rota,
e a condição exigir que o id seja o de quem entrou — muda o fluxo de sessão do
ERP, e exige decidir o que a marca deve significar: ela é "estou personificando"
ou "sou fulano fingindo ser sicrano"? As duas leituras dão desenhos diferentes
para o botão de voltar.

É decisão de quem opera. A oitava fatia da API **não expõe personificação**, e
por isso não herda o problema.


#### A porta tinha um motivo escrito dizendo que era guardada, e ninguém conferia — 15/09/2026

O conserto continua sendo decisão de quem opera, e este registro segue ANOTADO.
O que mudou é o **vigia**, e o jeito como ele faltava é o que vale contar.

`scripts/conferir-guarda-do-erp.php` listava esta ação entre as 34 sem
conferência de privilégio, com o motivo escrito:

> `OUTRA-GUARDA: tem guarda: usuarioAdmin, o desenho do D88 — mas não é privilégio`

**A regra daquele portão é uma só:** `str_contains($corpo, '$this->getAcesso')`.
O motivo era uma **afirmação que ele nunca conferiu**. E o cabeçalho dele ia
adiante — diz, das duas `clonarAction`, que *"quem vigia que essas duas não
percam a guarda é o `conferir-recorte-por-unidade.php`"*, logo depois de afirmar
que o `autenticarComoAction` é *"a mesma decisão"*. Quem lê conclui que esta
porta também é vigiada.

**Ela não era.** Aquele portão tinha 40 métodos vigiados, e nenhum era este.

#### A contraprova, medida

Arranquei `$this->usuarioAdmin || ` da porta e rodei os dois portões no mesmo
estado:

| portão | com a guarda arrancada |
|---|---|
| `conferir-recorte-por-unidade.php` (depois desta entrada) | **EXIT 1** — `SEM A GUARDA: …UsuariosController.php::autenticarComoAction` |
| `conferir-guarda-do-erp.php` (o que carregava o motivo) | **EXIT 0** — 295 ações, as mesmas 34 |

O segundo continua verde porque a ação segue sem `getAcesso` e segue casando com
a chave da lista. **Ele nunca guardou esta porta**, e o texto ao lado da chave
dizia o contrário.

E o que a guarda arrancada deixa de pé não é pouco: some `usuarioAdmin` e a
condição que sobra é `!empty(getUsuario('usuarioSessao'))` — **a marca de quem já
está personificando**. Qualquer sessão autenticada entraria como qualquer pessoa.

#### O que foi feito

1. A ação entrou em `$vigiados` de `scripts/conferir-recorte-por-unidade.php`,
   com o mesmo desenho que as duas `clonarAction` já usavam — de 40 para **41**.
2. O cabeçalho e o motivo em `conferir-guarda-do-erp.php` passaram a dizer a
   verdade, e a **nomear quem confere**. A lição ficou escrita lá: *um motivo
   escrito ao lado de uma chave não é uma guarda; enquanto ninguém o confere, ele
   é uma promessa — e promessa em comentário envelhece sem nunca ficar vermelha.*

**Falsificação:** guarda arrancada → EXIT 1 nomeando o método; método renomeado →
**EXIT 2** na trilha `SUMIRAM` (perder o que se devia vigiar não é verde);
restaurado byte a byte, `git status --porcelain` vazio, e o portão de volta em
41/0/0.

#### Três correções nesta própria entrada

**1. A sessão NÃO fica indistinguível de um login de Y.** Esta entrada afirmava
que sim. `autenticarComoAction` chama `SessaoAcl::setAcl()`, que escreve apenas
no array de sessão — **não toca a identidade do Laminas e não chama
`setCookie()`**. Aliás `SessaoAcl::setCookie()` não é chamado por *ninguém* no
repositório inteiro. Então o storage de autenticação e os cookies `login` e
`token_web` continuam sendo os **do administrador**.

> **Correção de registro (07/10/2026, o item CODIGO-MORTO-E-DOCS):** o "não é chamado por
> *ninguém*" está errado, e desde a importação inicial. `SessaoAcl::setCookie()` é chamado pelos
> dois logins de `AutenticacaoController`: o do formulário (`:85`) e o do cookie, `entrarCookies`
> (`:341`). Quem não o chama é a personificação, e é só isso que este parágrafo precisava: a
> conclusão fica de pé -- na personificação, o storage e os cookies `login` e `token_web` continuam
> os do administrador.

Isso não salva o débito: nada disso é lido como auditoria e nada disso é
persistido. Mas a frase estava errada, e frase errada num registro é o que faz a
próxima pessoa procurar no lugar errado.

**2. O rastro não é "perdido no desvio" — ele nunca existiu em disco.** A
personificação não chama `AutenticacaoService::criarToken()` nem `logAcesso()`,
que é o que o login normal faz (`AutenticacaoController:36` e `:201`) e o que
grava `usuarios_acessos`. Logo a tela dos últimos 500 acessos
(`localizarAcessosAction`) **jamais** mostrou personificação — nem antes do
desvio, nem depois. O único traço é a marca em sessão e o cadeado em
`layout.phtml:124-129`, e os dois somem quando a sessão some.

O título continua certo; o mecanismo é outro. Persistir o rastro é trabalho novo,
não conserto de um rastro que se perdeu.

**3. `UsuarioService::autenticarComo()` é código morto.** Zero chamadores no
repositório. E `module/ISConfiguracao/src/Api/UsuarioJson.php:19,26` (o docblock de então) o cita como
razão **viva** para esconder `getSalt()` da API:

> `getSalt()` usado como CREDENCIAL em `autenticarComo()`

Esconder `getSalt()` continua certo — pelo D78, que registra que a coluna `salt`
guarda a senha em texto reversível. O que está velho é a **razão escrita**. É a
mesma família do achado principal desta rodada: um comentário que promete o que o
código não faz. Fica anotado aqui, e o conserto do texto pertence ao D78.

> **Desde 03/10/2026 (o item SESSAO-SENHA-EMAIL):** o método foi apagado -- conferido sem chamador
> em `module/`, `tests/` e `scripts/` -- e o docblock de `UsuarioJson` passou a dar a razão do D78
> (a coluna `salt` é a senha decifrável, e o `entrarCookies` ainda a manda como credencial). O docblock
> do `testNenhumaCredencialAtravessaORecurso` (`UsuarioApiTest`) dizia o mesmo, e mudou junto.

#### O que continua aberto, e é do dono

- **O ramo estreito:** no `else`, resolver o usuário por
  `$this->sessao->getUsuario('usuarioSessao')` e recusar quando o id da rota
  divergir. São duas linhas, e fecham a adulteração de URL — mas mudam o fluxo de
  sessão do ERP, e exigem decidir o que a marca significa: *"estou
  personificando"* ou *"sou fulano fingindo ser sicrano"*. As duas leituras dão
  botões de voltar diferentes.
- **O ensaio HTTP**, que só deve ser escrito **junto** com o conserto: um ensaio
  que afirme o comportamento de hoje congelaria o defeito.
- **Persistir o rastro** em `usuarios_acessos` — exige decidir onde e o quê, e a
  tabela tem 1.880.090 linhas e uma coluna `local` que a entidade nem mapeia.

#### A faixa chegou à tela nova — 23/09/2026 (B1)

O que continua ANOTADO é o mesmo: o rastro não é gravado, e o ramo de voltar
continua aceitando qualquer id. O que mudou é que o **único traço** — a marca na
sessão — passou a aparecer também na tela nova, que até aqui o escondia.

`/adm/api/sessao` publicava só o **id** de quem entrou de verdade, e o front
deixava a faixa de fora: escrever "você continua sendo o usuário 412" era pôr na
tela um número que ninguém no balcão lê. Agora `SessaoJson` publica
`usuarioOriginal` com **id, nome e papel**, e a barra do topo diz *"Você está
vendo o sistema como Fulano, e continua sendo Beltrano"*, com o botão **voltar a
ser eu**.

O botão usa o **mesmo endereço do cadeado do ERP** (`layout.phtml:126`), com o
id de quem entrou — a tela nova não abriu caminho próprio de volta, justamente
para não decidir aqui o que é do dono.

**Provado:** `SessaoApiTest` com três casos novos — voltar ao formato de antes,
só o id, deixa dois vermelhos —; seis mutações no `ConsoleLigado`, cada uma
deixando um caso vermelho; e no navegador, contra o banco de contrato com um
grupo marcado administrador só para a medição: personificar, ver a faixa, voltar
e a sessão responder `ativa: false`.

#### A vigência do horário herda o desvio — 02/10/2026 (F4.1)

`usuarios_horarios` é o primeiro rastro GRAVADO de quem mudou o quê: cada linha
tem `gravado_por` (quem entrou) e `em_nome_de` (a pessoa assumida), tirados de
`CrudController::autorDaEscrita`, que lê `SessaoAcl::loginDaSessao` (o D315). Na
personificação normal o rastro sai certo. **No caminho deste débito, não:** o
administrador A assume B e, da lista de B, "volta" como C; a sessão vira a de C,
sem marca, e o horário que A salvar dali fica com `gravado_por = C` e
`em_nome_de` nulo — o rastro diz que C mudou o horário, e nada diz que A esteve lá.

Não há o que a F4.1 possa fazer sozinha: depois do desvio, a sessão não sabe mais
quem entrou. Fecha junto com o ramo estreito acima, que é do dono.

**Caracterizado**, no molde dos controles do D127 que o D315 deixou:
`D315PersonificacaoTest::testNoCaminhoDoD127OAutorDaEscritaEAPessoaDaRota` (o
autor e a linha gravada). Ele afirma o comportamento de hoje **e diz que muda com
a decisão** — quem consertar o ramo o vê vermelho e o reescreve, junto com a
seção da F4 em `docs/f3-relatorios.md`.

---

#### 12/10/2026 — A VOLTA SÓ PARA QUEM ENTROU, E A TROCA NO REGISTRO — pelas respostas 105 e 104 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 105** (a opção "a"): *"O voltar volta só para quem entrou -- para assumir uma terceira pessoa, volta antes;
outro endereço é recusado, também o cadeado da lista de usuários quando a pessoa assumida é administradora."* **A 104**
grava a troca no registro de acessos (o D76, acima).

##### O conserto

`UsuariosController::autenticarComoAction`: personificando, o id da rota tem de ser o da marca (`usuarioSessao`, quem
entrou); outro é recusado com a frase `VOLTE_ANTES` ("Volte a ser você antes de assumir outra pessoa."), de volta à
lista de usuários, e a sessão fica como estava. Vale também quando a pessoa assumida é administradora: ali a porta passa
pelo `usuarioAdmin` DELA, e é a recusa da volta que segura -- é o cadeado da lista. O cadeado da barra do ERP e o
"voltar a ser eu" da tela nova já mandavam o id de quem entrou, e não mudaram. Com isso as três consequências deste
registro fecham: a marca não se perde, o "Sair" depois de qualquer clique apaga o token de quem entrou (e não o da pessoa
da rota), e o autor das escritas da F4.1 continua quem entrou, em nome da pessoa assumida. E a entrada fica no registro
de acessos da pessoa assumida, como "assumida por Beltrano" (a 104).

##### Provado

- `D315PersonificacaoTest`, nos dois bancos: os três casos que caracterizavam o caminho de antes mudaram junto, como
  diziam: (g) a volta para outra pessoa é recusada -- com a pessoa assumida comum e administradora (o cadeado da lista)
  --, a frase, a sessão intacta, e a volta para quem entrou passa depois; (g2) o "Sair" depois da volta recusada apaga o
  token de quem entrou e deixa o da pessoa da rota, cujo aparelho segue; (g3) depois da volta recusada, a vigência do
  horário grava o administrador em nome da pessoa assumida. `docs/f3-relatorios.md` (a F4.1) diz o mesmo.
- **O ensaio HTTP que este registro pedia junto com o conserto**: o caso 22 do `ensaiar-sessao-do-erp.php`, nos dois
  bancos de contrato -- um administrador do ensaio (grupo e usuário criados com a senha do gerente, e tirados no
  encerramento) assume o colaborador pelo POST da mesma origem; a sessão mostra o colaborador e diz quem entrou; o
  registro ganha `assumida:<id>`; o "Autenticar como" de outra pessoa volta 302 à lista e a sessão fica igual; a volta
  para quem entrou passa. 23 de 23 nos dois. Falsificado por HTTP, no MySQL: com a volta para qualquer um, o caso 22 fica
  vermelho (22 de 23).
- Mutantes, no MySQL, cada um vermelho: a volta para qualquer um (os cinco casos g, g2 e g3) e a recusa só quando a
  pessoa assumida não é administradora (o caso do cadeado da lista). Uma entrada nova no
  `conferir-recorte-por-unidade.php`, falsificada arrancando o código.


### D128 — `status` não é uma coisa: 47 tabelas, quatro tipos — CORRIGIDO no sítio novo em 08/09/2026 — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 06/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: varrer as consultas do ERP por booleano computado sobre coluna inteira antes do corte; só o sítio novo da API foi corrigido.
>
> **06/10/2026:** a varredura foi feita, e deu zero; o portão passou a ver o booleano calculado e, na revisão do mesmo dia, o literal `true`/`false` escrito na DQL contra coluna que não é booleana (zero também). Ver "O conserto" e "O literal escrito na DQL", no fim deste registro.

Achado em 08/09/2026 pelo **medidor, no CI** — não por leitura. A oitava fatia
tinha acabado de subir com as suítes verdes nos dois bancos, e
`GET /adm/api/usuarios` respondeu:

```
SQLSTATE[22P02]: Invalid text representation: 7 ERROR:
invalid input syntax for type integer: "t"
```

Eu passara `'status' => $estado === 'ativo'` — um booleano PHP —, exatamente
como a **quarta** e a **sexta** fatias passam. Nas duas está certo. Aqui não.

#### Por que o mesmo idioma é certo três vezes e errado na quarta

| tabela | tipo de `status` no PostgreSQL |
|---|---|
| `clientes` | boolean |
| `produtos` | boolean |
| `grupos` | boolean |
| **`usuarios`** | **integer** |

E o quadro geral é pior do que quatro tabelas sugerem. São **47 tabelas com uma
coluna chamada `status`**:

| tipo | tabelas |
|---|---:|
| boolean | **32** |
| integer | 6 |
| smallint | 5 |
| character varying | 4 |

**Quinze das 47 não são booleanas.** As inteiras e smallint são
`agendas_exclusoes`, `assistentes`, `mensagens`, `notificacoes`, `pesquisas`,
`pesquisas_perguntas`, `pesquisas_respostas`, `pontuacoes_historicos`,
`sorteios`, `testes_estilhos_colunas`, `testes_estilhos_opcoes`, `usuarios`,
`vendas`, `vipclass` e `visagismo`.

**O nome não diz o tipo.** Quem escrever a nona fatia e copiar o idioma da
sexta vai acertar ou errar conforme a tabela — e o MySQL nunca reclama, porque
converte.

#### Por que os portões de texto não pegaram

`conferir-booleanos-em-consulta.php` e `conferir-booleano-em-coluna-inteira.php`
leem o código à procura de **literais** `true`/`false` junto de colunas
inteiras. O meu era `$estado === 'ativo'` — um booleano **computado**, que
nenhuma varredura textual reconhece como booleano.

Quem pegou foi o instrumento que **chama o endpoint**. Vale registrar como
lição: portão de texto pega o descuido escrito; só a chamada real pega o
descuido calculado.

#### O que foi corrigido, e o que não

O sítio novo passa `1`/`0`. **Não varri o ERP**: as telas antigas rodam contra
MySQL hoje, e lá o problema não aparece — ele nasce no dia do corte. A varredura
das consultas do ERP por booleano computado sobre coluna inteira é trabalho
próprio, e entra como pendência ao lado do D81.

#### O conserto — 06/10/2026: a varredura do ERP, feita pelo portão que passou a ver o calculado

**A varredura deu zero.** Nenhuma consulta do ERP liga booleano — literal ou
calculado — a coluna `integer` ou `smallint`. Não houve sítio para trocar por `1`/`0`
além do que este registro já tinha consertado na API. O número saiu do instrumento,
e não de leitura:

| | quantas |
|---|---:|
| ligações `apelido.campo = :param` a campo (não associação) conferidas no `module/` | **517** |
| decididas pelo tipo da coluna no `postgres/schema.sql` | 517 |
| em que o esquema e o mapeamento discordam sobre ser booleano | **0** |
| booleano no próprio método | **0** |
| booleano montado por quem chama (um salto) | **0** |
| não resolvidas, conferidas à mão (as 11 do `filtrosListagemIndexRelatorio`, texto do `$request`; as 2 de `parceiroHorarios`, `?int`) | 13 |

**O que o portão passou a ver** (`scripts/conferir-booleano-em-coluna-inteira.php`).
O valor ligado é lido pelos tokens, até a vírgula ou o fecho do nível de fora, e é
booleano quando o operador de fora é de comparação (`===`, `!==`, `==`, `!=`, `<`,
`>`, `<=`, `>=`, `instanceof`) ou lógico, quando começa por `!` ou `(bool)`, quando é
uma chamada inteira a função que devolve booleano (`empty`, `isset`, `in_array`,
`is_*`…), ou um ternário de dois ramos booleanos — e **não** quando é
`$estado === 'ativo' ? 1 : 0` nem `(int) ($estado === 'ativo')`, que são inteiros. Segue
a variável atribuída no mesmo método, o parâmetro tipado `bool` ou com padrão
booleano, e um salto até quem chama: o argumento na posição do parâmetro, ou a chave
do arranjo que vai nela, literal no argumento ou montado na variável. O tipo da
coluna vem do esquema do PostgreSQL, que é onde o `t` é recusado; o metadata só
decide quando a coluna não está lá (hoje, nenhuma).

**A classe do método chamado, quando o texto a diz.** A primeira rodada acusou **20**:
as quatro telas novas passam `'status' => $estado === 'ativo'` a `listagemIndex` de
Cliente, Convênio, Unidade e Cupom — colunas **booleanas**, certo —, e a ligação por
nome as casava com o `listagemIndex` de Usuário, Notificação e Sorteio. O portão agora
liga `$this->m()` ao próprio arquivo e `getRepository(X::class)->m()` — direto ou pela
variável que o recebeu — ao repositório de X; sem isso, continua casando pelo nome, e
o erro desse lado é acusar a mais. 698 chamadas de mesmo nome descartadas pela classe;
os 20 saíram, e nenhum era defeito.

**Os pontos cegos, medidos em vez de só declarados:**

- **O retorno de método** (`$x->getAtivo()`), cujo tipo o texto não diz. Listados um a
  um os 350 valores que quem chama passa a ligações não booleanas: os que são chamada
  de método são todos `getId()`, `format()` e `fromQuery()`. Nenhum devolve booleano.
- **O PDO, medido no `pdo_pgsql` deste ambiente, só com `SELECT CAST(? AS integer)`:**
  `PARAM_BOOL` (o que o Doctrine infere para um booleano numa DQL) é recusado nos dois
  valores; `PARAM_INT` vira `1`/`0` e passa; `PARAM_STR` passa `true` como `'1'` e
  recusa `false`, que vira `''`. Duas consequências: o **setter de entidade** com
  booleano em campo `integer` não é defeito — o Doctrine grava campo inteiro com
  `PARAM_INT`, e os `alterStatus($id, $ativar)` do ERP passam —, e o **SQL nativo** sem
  tipo quebraria com `false` em coluna inteira **e** booleana. São 7 chamadas nativas no
  `module/`, e nenhuma liga booleano: as duas `insert` (`agendas_exclusoes`,
  `usuarios_horarios`) já passam `? 1 : 0` ou o retrato normalizado.
- **Dois saltos** (de quem chama quem chama) continuam fora, escrito no portão.

**Falsificado, um mutante por vez, restaurado pelo sha256** (o portão sai com 1 em
todos os que deviam, e com 0 no controle):

| mutante | o portão |
|---|---|
| o caso do D128 de volta: `'status' => $estado === 'ativo'` no `Adm\UsuariosController` | vermelho: `listagemIndex(): status <- calculado: ===`, `Usuario::status e integer` |
| o caso do D82 de volta: `'statusVenda' => true` no `CronJobService` | vermelho: os dois métodos que recebem o arranjo |
| `'status' => $caixaId > 0` no `setParameters` de `VendaRepository::totalTrocoPorCaixa` | vermelho: `calculado: >` |
| `->setParameter('status', !empty($dql))` em `TesteEstiloColunaRepository` | vermelho: `calculado: !`, `smallint` |
| `$ativo = $estado === 'ativo'` e `'status' => $ativo`, no chamador | vermelho: `via $ativo = ...` |
| parâmetro `bool $fechado = true` ligado a `ven.status` | vermelho: `parametro tipado bool` |
| **controle:** `'status' => (int) ($estado === 'ativo')` | **verde** — é inteiro |

E o portão **de antes** (o do HEAD), com o primeiro mutante aplicado: **verde**, "Nada
mudou desde a correção de 02/09" — era exatamente o ponto cego que deixou este débito
passar pelo CI.

#### O literal escrito na DQL — a revisão de 06/10/2026

**Faltava o literal no TEXTO da consulta.** O portão lia o valor ligado a um `:param`; o
irmão do D81 (`conferir-booleanos-em-consulta.php`) mede o sentido contrário — literal
**inteiro** contra coluna booleana. Um `ven.status = true` escrito na DQL não passa por
ligação nenhuma: o Doctrine o traduz para o `true` do PostgreSQL, e `vendas.status` é
`integer` — "operator does not exist: integer = boolean". Os dois portões ficavam verdes.

Agora o portão lê também `apelido.campo = true/false` (e `<>`, `!=`, e o literal do lado
esquerdo), resolve o apelido como resolve o do parâmetro e pergunta o tipo ao mesmo
esquema. **Medido: 31 literais resolvidos, todos em coluna `boolean`; 0 contra coluna de
outro tipo.** Os 3 não resolvidos são do `filtrosListagemIndexRelatorio`, que recebe o
começo da consulta por parâmetro (o mesmo motivo das 11 ligações não resolvidas):
`cli.status = true` e `cli.programaFidelidade = true/false`, e as duas colunas de
`clientes` são `boolean` — conferido à mão no esquema. Entram na linha de base com esse
motivo. O literal fora de uma comparação com `apelido.campo` (`IN (true)`,
`CASE ... THEN true`, `COALESCE(x, false)`) continua fora, e está escrito no portão: em
06/10/2026 não havia nenhum no `module/`.

**Falsificado, um mutante por vez, em `VendaRepository::totalTrocoPorCaixa`, restaurado
pelo sha256:**

| mutante | o portão |
|---|---|
| `AND ven.status = true` | vermelho: `Venda::status e integer` |
| `AND FALSE <> ven.status` (o literal à esquerda) | vermelho |
| `ven.caixa = true` (associação: a chave estrangeira é inteira) | vermelho |
| **controle:** `AND ven.comandaTemp = true` (`comanda_temp` é `boolean`) | **verde** |

Com o primeiro aplicado, o portão do HEAD e o irmão do D81 ficam **verdes** — era o
ponto cego.

---

### D129 — Um `OR` sem parênteses partia o filtro de convênios — CORRIGIDO em 08/09/2026

Achado em 08/09/2026, lendo `ConvenioRepository` para dimensionar a nona fatia.

```php
if (!empty($request['unidade'])) {
    $dql .= " AND conuni.unidade = :unidade OR conuni.unidade IS NULL";
}
```

`A AND B OR C` é `(A AND B) OR C`. Sem os parênteses, o `OR` **parte o `WHERE`
inteiro** — e o ramo da direita, "convênio sem unidade nenhuma", entra na lista
ignorando o filtro de `status` que veio antes:

```sql
WHERE con.status = :status
  AND conuni.unidade = :unidade
   OR conuni.unidade IS NULL          -- <- este ramo não vê o status
```

#### A medida

Na cópia de produção, filtrando pela unidade com mais convênios (a 33):

| | convênios |
|---|---:|
| como está hoje | **290** |
| com os parênteses | **285** |

As cinco a mais são exatamente os convênios **inativos e sem unidade** —
`72 (X-One)`, `96 (CROSSFIT BLUMENAU)`, `117 (Itamirim)`, `160 (AFPB)` e
`595 (Teste VICTOR)` — aparecendo numa lista de **ativos**. Um deles se chama
"Teste VICTOR", o que sugere que ninguém olhou aquela lista com atenção.

A base tem 638 convênios: 616 ativos, 22 inativos, e 157 sem unidade nenhuma.

#### Por que só cinco, e por que ainda importa

Porque o vazamento exige as duas condições juntas — inativo **e** sem unidade —
e hoje há cinco assim. Amanhã pode haver mais: `157` convênios não têm unidade,
e basta que um deles seja desativado para entrar na conta.

E o filtro que falha é o de **status**, que a tela promete respeitar. Uma lista
que ignora silenciosamente o próprio filtro é pior que uma lista errada por um
motivo visível.

#### O sítio é único

Varri os repositórios por `AND ... OR` sem parênteses: há **uma** ocorrência, e
era esta. A outra que a busca traz é um comentário no `ClienteRepository`
descrevendo exatamente esta armadilha em outro contexto — o `?unidade=1 OR 1=1`
do D73, que era injeção e não precedência, mas quebra pelo mesmo mecanismo: um
`OR` solto torna verdadeiro o que veio antes dele.

---

### D130 — O construtor de `Unidade` descartava o que o formulário mandava — CORRIGIDO em 08/09/2026

Encontrado ao ler a entidade para a décima fatia. É o **D125 outra vez** — o
mesmo defeito que a `Produto` tinha, encontrado na sexta fatia — mas aqui o
próprio formulário do ERP prova o dano.

```php
public function __construct(array $options = array()) {
    (new ClassMethodsHydrator())->hydrate($options, $this);
    $this->dataCriacao = new \DateTime('now');
    ...
    $this->idYungas = null;
    $this->reducaoPotencial = 0;
    $this->linkBoasVindas = null;
    $this->agendaPrimeiroNome = false;
    ...
    $this->cashback = 1;
```

Seis atribuições **sem condição nenhuma**, logo depois da hidratação. Elas não
completavam o que faltava: apagavam o que tinha acabado de chegar.

#### A medida

Um POST sintético com os cinco campos preenchidos, construindo a entidade
direto:

| campo | enviado | ficou (antes) | ficou (depois) |
|---|---|---|---|
| `cashback` | 5 | **1** | 5 |
| `reducao_potencial` | 12 | **0** | 12 |
| `link_boas_vindas` | `https://exemplo.test/…` | **NULL** | preservado |
| `agenda_primeiro_nome` | 1 | **false** | true |
| `id_yungas` | 777 | **NULL** | 777 |

**Cinco de cinco descartados. Depois da correção, zero de cinco.**

#### Não era hipotético

`module/ISConfiguracao/src/Form/Unidade.php` monta a tela de "nova unidade" e
manda `cashback`, `reducao_potencial`, `link_boas_vindas` e
`agenda_primeiro_nome`. Os quatro chegavam ao construtor para morrer nas linhas
seguintes. Quem cadastrasse uma unidade com cashback 5 recebia cashback 1, sem
aviso.

O quinto, `id_yungas`, escapava **por acidente**: `UnidadeService::insert()` o
define outra vez, depois de construir. Campo salvo por uma segunda atribuição em
outro arquivo não é campo seguro — e por isso ele entrou na correção junto com
os outros.

#### A correção, e o que continua depois da hidratação

Os cinco padrões passaram para **antes** do `hydrate()`. Continuam depois, de
propósito:

- `dataCriacao`, `relatorioAtendimentos` e `relatorioDashboardColaboradores` —
  carimbos de controle. Aceitar do chamador seria deixar reescrever quando as
  rotinas já rodaram.
- `setHash()` — gera **segredo**. Aceitar um hash de fora seria deixar escolher
  a própria credencial. `UnidadeApiTest` cobra isso: manda `hash` no construtor
  e exige que o valor gravado seja outro.
- As três coleções, que precisam existir vazias.

---

### D131 — A conferência de privilégio do ERP é opt-in, e 34 ações ainda não optam — PARCIAL em 08/09/2026, triado em 09/09/2026

Este sistema **não tem guarda global de privilégio**. O único listener de
`MvcEvent::EVENT_DISPATCH` que decide alguma coisa é o `handleAuthRedirect` do
`ISConfiguracao\Module`, e ele pergunta uma coisa só: *há sessão?* Quem responde
por privilégio é cada ação, chamando `getAcesso()` ou `getAcesso2()` no próprio
corpo.

    296 ações na superfície do navegador
    130 não chamam nenhuma das duas

Sessão não é privilégio. Cada uma dessas 130 está aberta a **qualquer usuário
autenticado** — inclusive um colaborador cujo grupo não tem direito nenhum.

É a mesma família do **D123**, que fechou 31 endpoints da API nova. A diferença é
o mecanismo: lá a guarda existia e era inerte (`getAcesso()` sem
`$this->funcionalidade`); aqui a guarda simplesmente não existe.

#### Em 09/09/2026: de 130 para 70, e a lista passou a dizer *por quê*

Uma varredura fechou **60 ações** em oito passos — D143, D144, D145, D147, D148,
D149, D150, D151, D152, D153 e D154 —, cada uma medida antes de fechar.

E as 70 que restam deixaram de ser uma lista plana. Cada entrada de
`$conhecidas` no `conferir-guarda-do-erp.php` carrega agora uma **categoria e um
motivo**, e o portão imprime o resumo:

| categoria | quantas | o que significa |
|---|---:|---|
| `PUBLICA` | 14 | o próprio **cliente** abre, sem login — NPS, fidelidade, satisfação, CEP |
| `FORA-DE-ESCOPO` | 6 | visagismo, adiado por decisão do dono |
| `OUTRA-GUARDA` | 5 | **tem** guarda, mas não é `getAcesso()` — `usuarioAdmin`, id de grupo, código do app |
| `PROPRIA` | 5 | age sobre o usuário da **sessão**, não sobre um id da requisição |
| `COTO` | 3 | o corpo só recusa |
| `CAPACIDADE` | 1 | guardada por hash na URL |

**A categoria `ABERTA` chegou a zero em 09/09/2026** (D155 e D156), e a `PDV`
também (D138) — a medida de uso derrubou a trava, e as 22 foram guardadas.
Restam **34**, e nenhuma delas é trabalho pendente.

**"70 ações sem guarda" não diz o que fazer; "17 abertas e 21 travadas no D138"
diz.** Metade do que restava não é trabalho pendente: é decisão do dono (`PDV`),
desenho correto (`PUBLICA`, `PROPRIA`, `OUTRA-GUARDA`, `CAPACIDADE`, `COTO`) ou
escopo adiado. O portão continua falhando nos dois sentidos — provado nos dois
ensaios depois da mudança de estrutura — e quem acrescentar uma entrada precisa
escrever a categoria e o motivo.

#### O que ficou de fora da conta, e por quê

- `module/*/src/Controller/Adm/*` — a API da Fase 2. Tem portão próprio,
  `conferir-acl-da-api.php`, que exige guarda em **todas** as 62 ações.
- `module/ISApi/*` — outra superfície, com outra autenticação: o
  `CronJobController` compara token com `hash_equals` (D4/D77), e o
  `AplicativoController` responde ao aplicativo do cliente, não ao ERP.
- `AutenticacaoController` — é o que roda **antes** de haver sessão.

#### As cinco corrigidas

As de `UnidadesController`, encontradas ao recortar a décima fatia:

| ação | guarda posta | quem chama |
|---|---|---|
| `localizarAcessos` | `unidades`/`ler` | `unidadesHistoricoAcessos()`, no index de unidades |
| `socioSalvar` | `unidades`/`escrever` | a tela de editar unidade |
| `socioPesquisar` | `unidades`/`escrever` | idem |
| `socioRemover` | `unidades`/`escrever` | idem |
| **`selecionarHorarios`** | **`usuarios`/`escrever`** | `usuariosCopiarHorariosUnidade()`, na tela de editar **usuário** |

O que cada uma deixava fazer:

- **`socioPesquisar`** era a pior. Devolvia o cadastro inteiro de um sócio por
  id — CPF, data de nascimento, telefone, e-mail e endereço completo — e é a
  única das três que **só lê**, o que permitia varrer os ids em sequência sem
  deixar rastro de escrita.
- **`socioSalvar`** criava e editava sócio de qualquer unidade.
- **`socioRemover`** desativava sócio por id.
- **`localizarAcessos`** devolvia 500 registros de acesso de qualquer unidade —
  nome de colaborador e data e hora de cada entrada.

#### Por que as outras 130 não podem ser corrigidas em varredura

A quinta linha da tabela é a resposta. `selecionarHorarios` está no
`UnidadesController`, mas quem a chama é a tela de **usuários** — e uma varredura
que aplicasse `getAcesso()` em todas teria fechado essa porta na cara de quem
administra usuários e não administra unidades, que é o caso comum.

O privilégio certo é o da **tela que chama**, e descobrir isso é ler o
JavaScript que faz o POST, uma ação de cada vez. Cento e trinta vezes.

#### O portão: `scripts/conferir-guarda-do-erp.php`

Congela a lista das 130 e garante a única coisa garantível hoje: **que ela não
cresça**. Falha nos dois sentidos — ação nova sem guarda é vermelho, e ação da
lista que ganhou guarda também, pedindo para tirar da lista. Lista que envelhece
em silêncio vira decoração, e decoração ensina a ignorar o vermelho.

**A lista é o que pega, e não o número.** No ensaio vermelho, tirar uma guarda de
`UnidadesController` e pôr outra em `EnderecosController` manteve o total em
**130 nas duas medições**. Um portão que contasse teria passado nas duas.

---

### D132 — Excluir parcelas em lote atravessava a franquia — CORRIGIDO em 08/09/2026

O mais grave achado ao abrir o financeiro, e o primeiro deste projeto em que uma
escrita **atravessa o inquilino**.

```php
public function excluirParcelas($data) {
    foreach ($data['ids'] as $pacelaId) {
        $parcela = $this->em->getRepository(SaidaParcela::class)->find($pacelaId);
        if (!empty($parcela)) { $parcela->setStatus(false); ... }
```

`excluirParcelasAction` mandava ao serviço **os ids crus do POST**, e o serviço
fazia `find()` em cada um sem perguntar de quem eram.

A ação irmã, `excluirParcelaAction`, passa por `selecionarParcela()`, que confere
`unidade == sessão OU administrador`. A de lote não passava por lugar nenhum. O
JavaScript monta a lista a partir de caixas de seleção da própria página — e nada
impedia mandar outra lista.

#### A medida

Um teste antes da correção, com duas parcelas de unidades diferentes:

| parcela | unidade | pedido feito pela unidade 90001000 |
|---|---|---|
| 90001001 | 90001000 | excluída — correto |
| **90001100** | **90001100** | **excluída — não podia** |

`Failed asserting that false is true` — a parcela da outra franquia saiu.

São ~32 unidades no mesmo banco. Bastava o privilégio `financeiro/saidas` em
qualquer uma delas.

#### A correção

`excluirParcelas($data, $unidadeId)` — o segundo parâmetro é **obrigatório**, e
`null` (só o administrador) é a única forma de alcançar outra unidade. A
conferência mora no **serviço**, e não apenas no controlador, pelo motivo do
D126: serviço que confia em quem chama protege apenas o caminho que existe hoje.

---

### D133 — A guarda de "saída com parcela paga" era contornável — CORRIGIDO em 08/09/2026

`excluirSaidaAction` recusa excluir uma saída que tenha parcela quitada, e diz
isso na tela: *"Não foi possível excluir a saída pois ela possui N parcela(s)
quitada(s)"*.

A contagem percorria `$saida->getParcelas()` — e esse método **filtra
`status = true`**:

```php
public function getParcelas() {
    $criteria->where(Criteria::expr()->eq('status', true));
```

Então bastava excluir a parcela paga primeiro, **pelo botão que existe na mesma
tela**, e a saída passava a guarda. A regra estava escrita e não valia.

#### Por que contar a parcela excluída

O pagamento continua em `saidas_parcelas_pagamentos` faça-se o que fizer: o
dinheiro saiu, e desativar a linha da parcela não o traz de volta. Contar a
parcela paga mesmo excluída é exatamente o que a mensagem de erro sempre
prometeu.

`Saida::totalParcelasQuitadas()` percorre a coleção crua. A contagem saiu do
controlador para a entidade, onde pode ser testada.

---

### D134 — Dois filtros da tela de saídas devolviam 500 — CORRIGIDO em 08/09/2026

```php
$request['categoria'] = $this->params()->fromQuery('categoria', null);
$request['local']     = $this->params()->fromQuery('local', null);
```

O controlador lia os dois da query e a consulta os usava —
`AND sai.categoria = :categoria`. **A entidade `Saida` não tem esses campos.** A
tabela tem sete colunas: `id`, `unidade`, `tipo`, `descricao`, `status`,
`data_criacao`, `data_alteracao`. O formulário de filtro da própria tela também
não os oferece.

#### A medida

```
com ?categoria=1   QUEBROU -- Doctrine\ORM\Query\QueryException
   [Semantical Error] Class ISFinanceiro\Entity\Saida has no field or
   association named categoria
com ?local=1       QUEBROU -- idem
sem filtro extra   OK
com ?descricao=…   OK
```

Um 500 alcançável por qualquer pessoa que acrescentasse o parâmetro à URL. Não
era filtro que devolvia lista errada — era a tela caindo.

Removidos do controlador e da consulta. Não havia o que preservar: nunca
funcionaram. Um link antigo com o parâmetro passa a ser ignorado, que é o que
ele sempre deveria ter sido.

---

### D135 — A soma da tela de saídas conta parcela que a lista não mostra — A PORTA FECHADA em 09/09/2026, a soma ainda decide — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 13/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decidir se o total da saída passa a somar só as parcelas listadas, ou se a lista passa a mostrar as excluídas.

Encontrado em 08/09/2026, **medido e não corrigido**: qual dos dois números está
certo é uma decisão de contabilidade, não de código.

```php
public function getParcelas() {              // FILTRA status = true
    $criteria->where(Criteria::expr()->eq('status', true));

public function getTotalValorPrevisto(...) { // NAO filtra
    foreach ($this->parcelas as $parcela) {
```

A tela lista as parcelas por `getParcelas()` e mostra o total por
`getTotalValorPrevisto()`. Depois de qualquer exclusão de parcela, **a soma
deixa de bater com a soma das linhas exibidas** — e nada na tela explica a
diferença.

#### A medida, na cópia de produção

| | |
|---|---:|
| saídas ativas | 13.236 |
| com ao menos uma parcela excluída | **144** |
| diferença somada (total da tela − soma das linhas) | **R$ 864.595,41** |

#### A medida que faltava: as excluídas nunca foram pagas

A decisão parecia contábil porque a leitura 2 supunha um caso — *"parcela paga e
depois excluída representa dinheiro que saiu de verdade"*. **Esse caso não
existe.** Medido na cópia de produção, sobre as 666 parcelas excluídas de saídas
ativas:

| | |
|---|---:|
| excluídas com `quitada = 1` | **0** |
| excluídas com `valor_pago > 0` | **0** |
| excluídas com pagamento **ativo** | **0** |
| soma de `valor_previsto` das excluídas | **R$ 864.595,41** |
| soma de `valor_pago` das excluídas | **R$ 0,00** |

Os R$ 864.595,41 de diferença são, integralmente, dinheiro que **nunca saiu**.

Trinta e cinco daquelas parcelas têm linha em `saidas_parcelas_pagamentos` — e
**todos os 36 pagamentos estão eles próprios cancelados** (`status = 0`). O
padrão é consistente e revela a prática da casa: cancela-se o pagamento
primeiro, depois exclui-se a parcela. Ninguém nunca excluiu uma parcela paga.

Vale o contraste para dimensionar: das 15.072 parcelas, **13.934 estão
quitadas**. Parcela paga é a regra — e mesmo assim nenhuma paga foi jamais
excluída.

#### O que foi corrigido: a porta que não perguntava nada

`SaidaService::excluirParcela()` fazia `setStatus(false)` e mais nada. Sem
checar `quitada`, sem checar `valor_pago`, sem olhar os pagamentos.

**A regra já existia um nível acima.** `excluirSaidaAction` recusa apagar a
saída que tenha parcela quitada, e o comentário do **D133** registra o porquê:
*"bastava excluir a parcela paga antes, pelo botão da mesma tela, e esta guarda
passava a ver zero"*. O D133 contornou o buraco contando a coleção crua; agora
ele está fechado na origem.

`SaidaParcela::temPagamento()` faz **três perguntas**, porque as três podem
discordar: `quitada` é a marca, `valor_pago` é o acumulado, e a coleção de
pagamentos é o rastro. Uma parcela com pagamento lançado e `quitada = 0` ainda
recebeu dinheiro.

**A guarda é gratuita:** nenhuma das 666 exclusões reais teria sido barrada. Ela
escreve a prática que já existia e que só não estava escrita.

**E o lote passou pela mesma porta.** `excluirParcelas()` repetia a exclusão
linha a linha em vez de chamar `excluirParcela()` — uma guarda escrita numa não
valeria na outra. Agora delega, e devolve falso quando alguma foi recusada; os
dois controladores passaram a mostrar a recusa, em vez de dizer "excluída com
sucesso" sem olhar o retorno.

#### O teste do D133 mudou, e a mudança é o registro

Ele exercitava a burla: excluía a parcela paga pelo botão e exigia que a guarda
da saída continuasse contando. Esse caminho deixou de existir.

O teste passou a exercitar **as duas camadas**: primeiro exige que o serviço
**recuse**, depois alcança o mesmo estado por fora do serviço e exige que
`totalParcelasQuitadas()` continue contando. A propriedade do D133 vale para
qualquer outro caminho até ali — um `UPDATE` manual, uma migração, um código
futuro. *Fechar a porta da frente não dispensa a contagem certa.*

#### A decisão que resta, e ficou pequena

Com a porta fechada, **uma parcela excluída nunca mais representará dinheiro que
saiu** — a leitura 2 deixou de ter cenário possível, e não apenas de ter
exemplos. Resta escolher o que fazer com o passado: os R$ 864.595,41 que hoje o
total soma e a lista não mostra.

> **Corrigido em 22/09/2026 — a frase acima estava errada.** A porta da parcela
> estava fechada; a da **saída**, não. Excluir a saída desativava toda parcela
> ativa conferindo só a marca de quitada, e pagamento parcial não liga a marca:
> 12 saídas, de 3 unidades, escondiam parcela com dinheiro pago. A segunda porta
> foi fechada no **D205**, pela mesma pergunta — `temPagamento()`.

1. **`getTotalValorPrevisto()` e `getTotalValorPago()` passam a usar
   `getParcelas()`** — o total volta a bater com a lista, e 144 saídas passam a
   informar R$ 864.595,41 a menos no previsto. É dinheiro que nunca saiu, e
   "previsto" descreve o que está planejado; parcela excluída não está.
2. **Mostrar as excluídas na lista** — mantém os números e explica a diferença.

Não escolho sozinho porque **muda número exibido em tela de contabilidade**,
ainda que a medida aponte com clareza para a primeira.

---

#### CORRIGIDO em 13/10/2026 -- a resposta 143 do dono (o item CONTAS-A-PAGAR)

**Decidido pelo dono em 13/10/2026** (pergunta 143 da página do lote, a opção (a), a recomendada): "Não conta: as duas
telas somam o que mostram". As 144 despesas passam a mostrar R$ 864.595,41 a menos no previsto -- dinheiro que nunca
saiu: nenhuma das 666 parcelas excluídas tem dinheiro ativo. **Corrigido pela verificação, 13/10/2026:** este parágrafo
dizia "nenhuma recebeu um real", e não é verdade -- 35 delas receberam 36 pagamentos (R$ 18.727,35, de 12/2020 a
03/2025), todos estornados antes da exclusão. O líquido é zero, e é o que o total mede; "nunca recebeu", não.

**O conserto.** `Saida::getTotalValorPrevisto()` e `getTotalValorPago()` passaram a percorrer `getParcelas()` -- as que a
tela lista --, e não a coleção crua. Quem os lê é só a tela antiga das parcelas (`parcelas.phtml`); a API já somava o
que lista (`SaidaJson::completo`), e o cabeçalho dela, que dava o número como "pendente", passou a dizer a regra. As
contagens da guarda (`totalParcelasQuitadas`, `totalParcelasComPagamento`) continuam na coleção crua, pelo motivo do
D133. Nada muda no banco.

**Provado** em `tests/Caracterizacao/ContasAPagarRespostasTest.php` (24 casos), `D375SangriaAmarradaTest.php` (21) e
`ContasAPagarSemAsColunasTest.php` (4), novos, e nos vizinhos ajustados às assinaturas novas (`SaidaFinanceiroTest`,
`SaidaSobTravaTest`, `SaidaApiTest`, `SaidaApiEscritasTest`, `CaixaTest`, `CaixaApiTest`, `CaixaSobTravaTest`,
`ColunasForaDoMapeamentoTest`): 358 casos do filtro do item (Contas a Pagar, o caixa, as colunas fora do mapeamento e
os vizinhos), verdes no MySQL e no PostgreSQL, em série. O item CONTAS-A-PAGAR (o roadmap de 13/10/2026) tem a tabela
inteira dos mutantes; os deste débito estão abaixo.

- a despesa das fixtures (R$ 1.500 + R$ 1.500 ativas, R$ 700 excluída) soma R$ 3.000,00 no previsto e R$ 1.500,00 no
  pago -- antes, R$ 3.700,00 --; o pago escrito à mão na excluída não entra; e a tela das parcelas, renderizada com o
  que a ação entrega, mostra `<b>3.000,00</b>` e não `3.700,00`;
- dois mutantes, cada um sozinho e restaurado pelo sha256, vermelhos: o previsto de volta à coleção crua (2 casos) e o
  pago de volta à coleção crua (1).

### D136 — A funcionalidade `clientes/devedores` escondia um botão e não guardava nada — CORRIGIDO em 08/09/2026

`DevedoresController` guardava a listagem com `clientes/cadastro`, e as outras
duas ações **não guardavam nada**. Enquanto isso, o botão que leva à tela
depende de outra funcionalidade:

```php
// clientes/index.phtml
if ($aclDevedores['escrever']) { ... <a>Devedores</a> ... }
```

Ou seja: `clientes/devedores` — que existe na tela de permissões, e que alguém
pode conceder e revogar — governava **a visibilidade de um botão**, e nenhuma
porta.

#### A medida

Entre os 1.171 usuários com privilégio, na cópia de produção:

| | |
|---|---:|
| tinham `cadastro/ler` e **não** `devedores/ler` | 5 |
| tinham `devedores/ler` e **não** `cadastro/ler` | **4** |

O segundo grupo é o que decide: esses quatro **viam o botão e recebiam 404 ao
clicar**. É defeito com sintoma visível, e alinhar a porta ao botão conserta os
dois lados de uma vez.

#### As três guardas

| ação | privilégio | o que estava aberto |
|---|---|---|
| `indexAction` | `devedores/ler` | era `cadastro/ler` — funcionalidade errada |
| `selecionarDebitoAction` | `devedores/ler` | **nada** — devolvia o valor de qualquer débito por id |
| `pagarDebitoAction` | `devedores/escrever` | **nada** — ver D137 |

As duas últimas estavam entre as 129 do D131. A lista congelada caiu para 127.

---

### D137 — Quitar dívida atravessava a franquia, e o dinheiro entrava no caixa errado — CORRIGIDO em 08/09/2026

O mais grave desta noite, e o segundo do tipo (o primeiro foi o D132, no
financeiro).

```php
public function pagarDebito(array $data, $debito, $usuarioId) {
    $caixa = ...->selecionarPorUsuario($usuarioId, true);   // o caixa de QUEM CHAMA
    ...
    $caixa->setValorFinal($caixa->getValorFinal(false) + $debito->getValor());
```

A ação não conferia privilégio (D136) **nem unidade**. E o efeito não é só
"apagar linha alheia": a rotina cria uma venda, um pagamento, baixa o débito
**e credita o caixa de quem está recebendo**.

Um usuário da franquia A quitava a dívida de um cliente da franquia B: o *a
receber* sumia de B, e o dinheiro aparecia no caixa de A. São ~32 franquias no
mesmo banco.

#### O ensaio vermelho

Com a guarda removida de propósito, o teste
`testQuitarNaoAlcancaDebitoDeOutraUnidade` falha com
`Failed asserting that true is false` — o débito da unidade 90001100 é baixado
num pedido feito pela 90001000. Com a guarda, ele passa, e o caixa de quem pediu
fica intacto.

#### Duas correções, e a segunda não estava no plano

A unidade entrou como parâmetro **obrigatório** do serviço — `null` só para
administrador, a mesma regra de `selecionarParcela()` e de `selecionarSaida()`.
A conferência mora no serviço, e não apenas no controlador, pelo motivo do D126.

E ao escrever o teste apareceu a segunda: `selecionarPorUsuario()` devolve `null`
quando o usuário **não tem caixa aberto**, e a linha seguinte fazia
`$caixa->getUsuario()`. Era um fatal. Agora recusa, que é a verdade: não há onde
lançar o valor.

#### Sobre a unidade do débito

`clientes_debitos` tem coluna `unidade` própria, e a listagem do ERP **não a
usa** — ela chega à unidade pela venda (`deb.venda → ven.usuario → usu.unidade`).
Conferido na cópia de produção: **9.096 débitos, zero divergências** entre as
duas. A guarda segue o caminho da listagem, para que guarda e lista falem da
mesma coisa se um dia divergirem.

---

### D138 — A tela do PDV não conferia privilégio — CORRIGIDO em 09/09/2026

Encontrado em 09/09/2026, ao tentar guardar `CuponsController::pesquisarCupom`.
**Medido e não corrigido**, e é a decisão mais consequente registrada até aqui.

#### Como apareceu

`pesquisarCupom` está entre as ações sem guarda do D131. Antes de guardá-la fui
ver quem a chama — a lição que a décima fatia deixou. Os campos que ela preenche
são `#pdvcarrinho_item_desconto`, `#pdvcliente_id`, `#pdvcarrinho_botao_trocar`:
ela roda **no PDV**, e não na tela de marketing.

Guardá-la com `marketing/cupons` fecharia o desconto de cupom na cara de todo
caixa. O privilégio certo é o da tela que chama.

**E a tela que chama não tem privilégio nenhum.** `VendasController::comandaAction`
confere o estado da venda e a unidade, e nada mais — ela está na própria lista
congelada, junto com `comandaAbrirAction` e `detalhesAction`.

#### A medida, e é ela que trava a decisão

| | |
|---|---:|
| usuários ativos | 370 |
| ativos **sem** `Comandas nova`/escrever | **200** |
| entraram no sistema nos últimos 90 dias | 232 |
| destes, **sem** `Comandas nova`/escrever | 140 |
| **destes, que não são de grupo administrador** | **133** |

Cento e trinta e três pessoas que **usam o sistema toda semana** não têm o
privilégio do PDV — e hoje alcançam a tela assim mesmo.

#### Correção de 09/09/2026: o administrador passa por cima

A primeira versão deste registro dizia **140**, e o número estava inflado. Ao ler
`ISConfiguracao\Permissions\Acl` para outra coisa, apareceu isto:

```php
if ($grupo->getAdministrador()) {
    $this->allow($grupo->getNome(), [], []);
}
```

`allow(role, [], [])` no ACL do Laminas concede **tudo**. Então quem está num
grupo administrador passa em qualquer `getAcesso()`, tenha ou não linha em
`privilegios_usuarios` — e sete dos 140 são administradores, que continuariam
entrando de todo jeito.

Há **1 grupo administrador**, com 10 usuários ativos.

A conclusão não muda: 133 ainda é mais da metade de quem usa o sistema, e a
decisão continua sendo sua. O que muda é o número que ela usa — e vale para
qualquer medida de privilégio deste registro: contar linhas em
`privilegios_usuarios` sem descontar os administradores superestima quem seria
bloqueado.

#### As duas leituras, e por que não escolho

1. **Os 140 não deveriam estar no PDV.** São barbeiros que só usam a agenda,
   administrativo, gente de outra função. A tela está aberta porque nunca
   perguntou, e guardar é fechar um buraco real.

2. **Os 140 usam o PDV todo dia.** O privilégio nunca foi concedido porque a
   tela nunca o exigiu — a concessão derivou justamente por não ser cobrada.
   Guardar amanhã de manhã para a operação de 32 barbearias.

**Os dados não distinguem as duas.** Saber qual é exige olhar o que aquelas 140
pessoas fazem no sistema, e isso é conhecimento do negócio, não do banco.

#### Fechado em 09/09/2026 — a medida que faltava era de COMPORTAMENTO

Este registro dizia: *"Saber qual [das duas leituras] é exige olhar o que aquelas
140 pessoas fazem no sistema, e isso é conhecimento do negócio, não do banco."*

**Estava errado.** O banco registra o que elas fazem — `vendas.usuario` e
`caixas.usuario` dizem quem vendeu e quem abriu caixa. A medida que travava a
decisão era de **privilégio**; a que a destrava é de **uso**.

| medida | resultado |
|---|---|
| venderam nos últimos 90 dias | **72** — dos quais **0** seriam bloqueados |
| abriram caixa nos últimos 90 dias | **71** — dos quais **0** seriam bloqueados |
| já venderam alguma vez, em 11 anos | 557, dos quais 239 sem o privilégio hoje |
| destes 239, **ainda ativos** | **4** |

E os quatro têm **uma ou duas vendas na vida inteira**: a mais recente é uma
venda única de junho de 2026; as outras são de 2023 e 2024. Não é gente que usa
o PDV — é gente que registrou uma venda uma vez.

**A leitura 1 estava certa.** As 133 pessoas têm acesso a uma tela que não usam,
e guardar não tira nada de ninguém que trabalhe ali.

#### A janela foi conferida antes de acreditar no zero

Zero é o resultado que mais merece desconfiança: se a cópia de produção fosse
antiga, a janela de 90 dias estaria vazia para todo mundo e a medida não mediria
nada. Ela não está — **43.337 vendas em 90 dias, por 72 pessoas distintas, e
17.513 acessos**. O zero é zero.

#### Duas correções ao que este registro dizia

**A funcionalidade era outra.** O registro mediu `Comandas nova`. A condição que
o menu usa para a tela do PDV *e* para a tela de caixa é
`getAcl("vendas", "nova", "escrever")` — **`Vendas nova`**. As duas têm 170
titulares não administradores, então o número não muda; a afirmação, sim.

**E o banco de desenvolvimento tem o cenário de testes dentro.** Dois
"bloqueados" que quase entraram neste registro eram os usuários 90001001 e
90001100 do `fixtures.sql`. Refiz as contas do **D157** com e sem eles: 15.495,
347 e 342 não mudam. Mas fica o aviso para qualquer medida futura sobre
`franquiabv_dev` — há 4 usuários, 2 grupos, 3 caixas, 5 vendas e 6 clientes de
cenário misturados à cópia.

#### O que foi feito

As **22 ações** ganharam guarda, e a escolha do privilégio é o assunto: **a
guarda é a da tela que CHAMA**, e não a do controller onde a ação mora.

| controller | ações | por que `vendas/nova` |
|---|---:|---|
| `ISVenda/Vendas` | 3 | é o PDV |
| `ISVenda/VendasPagamentos` | 5 | é o PDV |
| `ISVenda/VendasProdutos` | 2 | é o carrinho do PDV |
| `ISVenda/Caixas` | 3 | o menu condiciona a tela de caixa à mesma funcionalidade |
| `ISCadastro/Clientes` | 7 | seis buscas e a geração de código, chamadas do `mod-vendas.js` |
| `ISConfiguracao/Produtos` | 1 | o autocompletar do carrinho |
| `ISMarketing/Cupons` | 1 | **o caso que originou o D138** |

`getAcesso()` e `getAcesso2()` usam `$this->modulo`, o do próprio controller — e
quatro destes declaram outro (`clientes`, `configuracoes`, `marketing`) ou
nenhum. Um `getAcesso2('nova')` ali viraria `clientes/nova`, que não existe, e
recusaria todo mundo.

Daí o `CrudController::getAcessoDe($modulo, $funcionalidade, $privilegio)`: o
módulo vem **explícito**, porque quem escreve uma chamada dessas precisa saber de
que tela é a ação.

#### O que isso muda no D131

A categoria `PDV` da lista congelada **deixou de existir**. Das 34 que restam,
nenhuma é trabalho pendente: 14 são públicas de propósito, 6 estão fora de
escopo por decisão do dono, 5 agem sobre o próprio usuário da sessão, 5 têm
guarda que não é `getAcesso()`, 3 são cotos e 1 é URL-capacidade.

#### O que isso muda no D131 (registro de 09/09/2026, antes da correção)

Muda o enquadramento das 126 restantes. Elas não são "126 guardas faltando":
são guardas que **nunca existiram**, e por isso as concessões de privilégio se
acomodaram à ausência delas. Ligá-las é uma **migração de permissões**, não uma
correção de código:

1. decidir, por tela, quem deve alcançá-la;
2. conceder o privilégio a quem hoje usa e deveria continuar usando;
3. só então ligar a guarda.

Inverter essa ordem tranca gente que trabalha. É por isso que a lista está
congelada em vez de corrigida, e é por isso que as guardas que entraram nesta
noite — nas saídas, nos devedores, nas unidades — foram todas em telas onde a
medida mostrou que ninguém perderia acesso indevidamente.

#### O caminho barato, se houver pressa

Registrar sem bloquear: a guarda anota quem passaria a ser recusado, e deixa
passar. Depois de algumas semanas o registro responde a pergunta que os dados
não respondem hoje — e aí a decisão fica fácil. Não implementei porque
instrumentar 126 ações é trabalho, e a decisão de fazê-lo também é sua.

---

### D139 — Editar e desativar cupom alcançava a franquia vizinha — CORRIGIDO em 09/09/2026

O terceiro da mesma forma em duas noites, depois do D132 (saídas) e do D137
(devedores): pegar a entidade por id e mexer nela sem perguntar de que unidade
é.

```php
$cupom = ...->find($this->params()->fromRoute('id', 0));

if (!empty($cupom)) {          // editarAction e ativarRemoverAction
```

**Cupom é desconto.** Editar o cupom de outra franquia muda o desconto que o PDV
*dela* aplica; desativá-lo tira o desconto de lá.

#### A listagem tinha a mesma porta, por outro caminho

```php
$request['unidade'] = $this->params()->fromQuery('unidade', $this->unidadeId);
```

A lista suspensa do filtro **já restringia**: `popularCombobox($sessao)` só
oferece a própria unidade a quem não é administrador. Mas o controlador aceitava
qualquer `?unidade=` da query.

É o mesmo desenho do D136, uma hora antes: **o controle da interface era o
único controle**. Lista suspensa não guarda nada, botão escondido não guarda
nada.

#### E uma que já estava certa

`estatisticasAction` conferia — `$cupom->getUnidade()->getId() === $this->unidadeId
|| $this->usuarioAdmin`. A leitura era guardada e as duas escritas não.

A regra virou o método `daMinhaUnidade()`, porque passou a valer para três ações
e três cópias de uma regra de duas linhas divergem na primeira vez que alguém
mexe numa delas.

#### A medida

**146 cupons em 27 unidades**, 18 ativos.

#### Por que a correção é segura

Quem não é administrador nunca alcançava outro cupom pela interface: a lista
suspensa só oferece a própria unidade, e a listagem já vinha filtrada por ela.
Fechar a porta não tira nada que alguém alcançasse trabalhando — diferente do
D138, onde tirar seria trancar 140 pessoas.

#### O portão

`scripts/conferir-recorte-por-unidade.php`, novo, vigia **nove métodos** — os
três do cupom, os dois serviços do D132 e do D137, e os quatro da API nova. Ele
é **estrutural** e diz isso: prova que a conferência continua escrita, não que
funciona. Quem prova que funciona são `SaidaFinanceiroTest` e `DevedorTest`, que
chamam os serviços e exigem a recusa.

Falha nos dois sentidos, e o ensaio vermelho mostrou os dois: guarda removida
sai como *"sem a guarda"*, método renomeado sai como *"sumiram"*, com código 2.

#### Um achado ao ligar o portão

`conferir-guarda-do-erp.php`, o portão do D131 escrito algumas horas antes,
**nunca tinha sido ligado à CI**. Ele existe para pegar a ação nova sem guarda —
que chegaria no commit de outra pessoa — e só rodava quando eu o chamava à mão.
Os dois entraram no `ci.yml` juntos.

---

### D140 — Dois cupons do cenário usavam valores que o sistema não produz — CORRIGIDO em 09/09/2026

Achado ao recortar a fatia dos cupons, e é um defeito **do cenário**, não do
sistema — do tipo que o D122 já ensinou a levar a sério.

As fixtures criavam os cupons `90001000` e `90001150` com
`regra = 'produto'` e `momento = 'venda'`. **Nenhum dos dois valores existe:**

| | o que o sistema oferece |
|---|---|
| `Arrays::cuponsRegra()` | `consumiu`, `nao_consumiu` |
| `Arrays::cuponsMomentos()` | `checkin`, `checkout` — e devolve **null** para qualquer outra coisa |

Conferido na cópia de produção: **144 cupons, zero fora das duas enumerações**.
Os únicos discrepantes eram as fixtures.

#### O que isso escondia

`gerarCupons()` é chamado **só com `'checkout'`** — a chamada de `'checkin'` está
comentada em `AgendasController:492` — e só age sobre as duas regras válidas.
Com `produto`/`venda`, aqueles cupons eram **estruturalmente inertes**: nunca
consultados pela geração, nunca selecionados por ela.

Um cenário de geração de cupom que carrega um cupom invisível não descreve a
geração de cupom.

#### A prova veio sozinha

Ao trocar para `consumiu`/`checkout`, **três testes de `CupomTest` ficaram
vermelhos na hora** — o cupom passou a concorrer com os que aqueles testes
criam. Foi a demonstração de que ele estava inerte por acidente.

#### A correção, e por que não é "só ajustar o teste"

Os três testes afirmam regras reais (*"sem o consumo exigido no dia, nenhum cupom
é gerado"*). Mudá-los para acomodar uma fixture seria inverter quem manda.

O cupom ganhou `data_limite` no passado: `selecionarCuponsDiposniveis()` exige
`dataLimite IS NULL OR dataLimite >= hoje`, então ele sai da geração; e
`selecionarCuponsAtivosCliente()` **não olha `dataLimite`** — olha a validade do
cupom já emitido —, então ele continua servindo ao teste que precisa dele.

É o estado real de **uma campanha encerrada cujos cupons emitidos continuam
valendo**. Inerte de propósito, e por um mecanismo que existe, em vez de inerte
por um valor impossível.

---

### D141 — Cupom criado como "Inativo" nascia no ar — CORRIGIDO em 09/09/2026

O quinto da mesma família, depois do D125 (`Produto`) e do D130 (`Unidade`):
hidratar e sobrescrever logo abaixo.

```php
public function __construct($options = array()) {
    (new ClassMethodsHydrator())->hydrate($options, $this);
    $this->dataCriacao = new \DateTime('now');
    $this->clientes = new ArrayCollection();
    $this->status = true;          // <- apaga o que acabou de chegar
}
```

**O formulário de cupom tem um seletor de status** com "Ativo" e "Inativo", e
`CupomService::insert()` passa o POST inteiro ao construtor. Quem criava uma
campanha desligada — para revisar antes de publicar — recebia ela **no ar**.

E cupom é desconto: a campanha passa a valer no checkout do primeiro cliente que
casar com a regra.

#### A medida

```
antes:   status=0 enviado -> status=true
         sem status       -> status=true

depois:  status=0 enviado -> status=0
         sem status       -> status=true
```

Nascer ativo continua sendo o padrão. A diferença é que agora é **padrão**, e
não imposição.

#### O que continua depois da hidratação

`dataCriacao` e a coleção de clientes. O carimbo de criação não é do chamador, e
relação não se hidrata pelo construtor neste sistema — o teste cobra as duas
coisas.

---

### D142 — Mudar a composição de um pacote alcançava a franquia vizinha — CORRIGIDO em 09/09/2026

O quarto da mesma forma, depois do D132 (saídas), D137 (devedores) e D139
(cupons): pegar a entidade por id e mexer nela sem perguntar de que unidade é.

E é o de maior alcance dos quatro, porque duas das ações **mudam o que o pacote
contém**:

| ação | tinha guarda de privilégio? | conferia a unidade? |
|---|---|---|
| `indexAction` | sim (`ler`) | **não** — aceitava qualquer `?unidade=` |
| `editarAction` | sim (`escrever`) | **não** |
| `ativarRemoverAction` | sim (`escrever`) | **não** |
| `adicionarServicoAction` | **não** | **não** |
| `removerServicoAction` | **não** | **não** |

As duas últimas estavam entre as 126 do D131.

#### O que estava aberto

`adicionarServico` e `removerServico` definem **quais serviços o pacote dá,
quantas sessões de cada um, e por quanto sai cada sessão**. Sem privilégio e sem
unidade, qualquer sessão autenticada podia reescrever a composição de um pacote
de qualquer franquia.

#### A medida

| | |
|---|---:|
| pacotes | **238** |
| unidades | 21 |
| ativos | 143 |
| itens de pacote | 445 |
| pacotes já vendidos a clientes | **3.474** |

Os 3.474 são o que dá peso: um pacote vendido continua sendo consumido sessão a
sessão, e mexer na composição dele muda o que o cliente ainda tem direito a
receber.

#### Por que a correção é segura

As duas ações sem privilégio são chamadas de dentro da **tela de editar
pacote**, que já exige `configuracoes/pacotes`/`escrever` — quem alcança o
formulário já tem o privilégio. E a listagem: a lista suspensa do filtro
(`popularCombobox($sessao)`) só oferece a própria unidade a quem não é
administrador, então ninguém alcançava outro pacote pela interface.

Fechar não tira nada de quem trabalha — diferente do D138, onde tirar seria
trancar 140 pessoas.

#### O portão

A regra virou o método `daMinhaUnidade()`, e
`scripts/conferir-recorte-por-unidade.php` passou de 9 para **14 métodos
vigiados**.

---

### D143 — Qualquer sessão autenticada disparava uma campanha de marketing — CORRIGIDO em 09/09/2026

As cinco ações de apoio da tela de notificações não conferiam privilégio nenhum.
A pior é `salvarAction`: ela **cria e agenda a campanha**, e o próprio
controlador limita o envio a **200 contatos de WhatsApp por vez** — ou seja, o
sistema já sabe que aquilo dispara mensagens em nome da barbearia.

| ação | o que faz | privilégio posto |
|---|---|---|
| `salvarAction` | **cria e agenda a campanha** | `escrever` |
| `validarFormularioAction` | valida antes de salvar | `escrever` |
| `pesquisarClientesAction` | monta a audiência | `ler` |
| `pesquisarUsuariosAction` | idem, colaboradores | `ler` |
| `pesquisarAction` | lê uma campanha para editar | `ler` |

Já foram **2.787 campanhas** enviadas por esta tela.

#### A unidade não era o problema, e vale dizer por quê

Diferente do D132, D137, D139 e D142, aqui **não havia travessia de franquia**:

- `NotificacaoService::salvar()` faz `$envio->setUnidade($usuario->getUnidade())`
  — a campanha é sempre da unidade de quem a cria;
- `pesquisarClientesAction` já fazia
  `!$this->usuarioAdmin ? $this->unidadeId : $request['unidade']`;
- `pesquisarAction` e `cancelarAction` já conferiam a unidade do envio.

Faltava só a porta. Registrar isso importa: nem toda ação sem `getAcesso` é
também um vazamento entre inquilinos, e tratar as 119 restantes como se fossem
levaria a conclusões erradas sobre a urgência de cada uma.

#### Medido antes de fechar

O **D138** ensinou a não supor que guardar é inócuo. A medida, entre os 1.171
usuários com privilégio:

| | |
|---|---:|
| têm `ler` **e** `escrever` | 181 |
| têm `ler` **sem** `escrever` | **0** |
| têm `escrever` **sem** `ler` | **0** |

Todos os que abrem a tela têm os dois. **Fechar não tira nada de ninguém** — o
oposto exato do D138, e a diferença veio da medida, não do palpite.

A lista congelada do D131 caiu de 124 para **119**.

---

### D144 — Sortear o ganhador não conferia privilégio, e um id errado derrubava a ação — CORRIGIDO em 09/09/2026

Duas ações, dois defeitos na mesma linha.

#### O privilégio

`finalizarInternoAction` e `finalizarTerceirosAction` **sorteiam o ganhador**, e
não conferiam privilégio nenhum. A tela em si exige `marketing/sorteios`/`ler`
para abrir — e **nenhum usuário tem essa funcionalidade concedida**, o que na
prática significa que só administradores a alcançam, porque o ACL desta casa
deixa administrador passar por tudo.

O botão, porém, estava aberto a qualquer sessão autenticada. São 6 sorteios
cadastrados.

#### O `&` que não faz curto-circuito

```php
if (!empty($sorteio) & $sorteio->getStatus() == "aguardandoSorteio") {
```

`&` é bitwise, e **avalia os dois lados sempre**. Com um id inexistente,
`$sorteio` é nulo, `!empty(null)` é falso — e o lado direito roda assim mesmo.

Conferido no PHP desta imagem:

```
QUEBROU: Error -- Call to a member function getStatus() on null
```

Era **500 por id errado**, e não recusa. Com `&&` a avaliação para no primeiro
termo, que é o que a linha sempre quis dizer.

A precedência, por acaso, estava certa: `&` liga menos que `==`, então o
agrupamento era o pretendido. O defeito era só a ausência do curto-circuito — o
tipo de coisa que passa em leitura rápida porque *parece* um `&&` com um
caractere a menos.

#### Não havia travessia de franquia

`Sorteio` **não tem coluna `unidade`**: sorteio é global neste sistema. Conferido
antes de acusar, e é por isso que este débito não entra na família do D132/D137/
D139/D142.

---

### D145 — Três `salvarAction` gravavam sem conferir privilégio — CORRIGIDO em 09/09/2026 — CORRIGIDO em 07/10/2026

Três ações de gravação, três telas diferentes, o mesmo buraco: a **porta da tela
estava guardada e o POST que grava não**.

| ação | o que grava | privilégio da tela |
|---|---|---|
| `ClientesController::salvarAction` | cria e edita **cadastro de cliente** | `clientes/cadastro`/`escrever` |
| `AppBannersController::salvarAction` | recebe **arquivo enviado** | `configuracoes/appConfiguracoes` |
| `ArquivosController::salvarAction` | idem | `configuracoes/arquivosCadastrar` |

A de clientes é a que pesa: são **242.592 cadastros** com nome, telefone, CPF e
endereço, e qualquer sessão autenticada podia criar ou alterar qualquer um deles.

#### A unidade NÃO entra aqui, e isso é deliberado

Diferente do D142 e do D139, **não há travessia de franquia a corrigir**: a
listagem de clientes do ERP não filtra por unidade —

```
WHERE c.status = :status AND c.id != 2
```

— e a unidade só aparece na projeção, para exibir. **Cliente é da rede, e não da
loja**: são 242.592 em 60 unidades, e qualquer franquia atende qualquer um deles.
Guardar por unidade aqui quebraria o atendimento sem proteger nada.

Conferido antes de acusar. É a segunda vez nesta varredura que a resposta é "não
é defeito, é desenho" — a primeira foi o `Sorteio`, que não tem unidade nenhuma.

#### Medido antes de fechar

| funcionalidade | têm ambos | só `ler` | só `escrever` |
|---|---:|---:|---:|
| `Clientes cadastro` | 362 | **0** | **0** |
| `Configurações app configuraçoes` | — | — | — |
| `Arquivos cadastrar` | — | — | — |

Ninguém tem só um dos dois privilégios em clientes, então fechar não tira nada.
E as duas de arquivo **não têm nenhuma linha** em `privilegios_usuarios`: na
prática só administrador as alcança, porque o ACL desta casa deixa administrador
passar por tudo (ver a correção do D138).

A lista congelada do D131 caiu de 117 para **114**.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 24): a opção (a), "A regra da tela
vale no servidor" -- desativar e ativar um cliente, só a própria unidade ou o administrador, na ação
antiga e na API. A edição continua da rede, como este débito decidiu. A implementação está na fila
(`ClientesController::ativarRemoverAction` e o `POST /adm/api/clientes/:id/desativacao`; o D199).

**Implementada em 07/10/2026:** a regra mora em `RegraDoCadastroDeCliente::mudaOEstado`, e a ação antiga,
a API (as duas rotas, a de desativar e a de ativar) e a lista antiga a chamam. A edição continua da rede:
`salvarAction` e `editarAction` não mudaram nisso -- mas, desde a verificação do mesmo dia, não gravam mais
o estado que vier no POST, que era uma porta para fora da regra (o D438). O conserto e o provado estão no
D199 (PARCIAL em 07/10/2026).

---

### D146 — A autenticação do ERP é um `strpos` no nome da rota — CORRIGIDO em 09/09/2026 — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 07/11/2026: o pedido que espera JSON recebe 401 `nao_autenticado` (a fatia 115)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: a recusa de sessão em endpoint JSON ainda é a tela de login com HTTP 200; anotada como o próximo passo natural.
>
> **Feito em 07/11/2026 (a fatia 115):** o pedido que espera JSON recebe 401 `nao_autenticado`; ver a última seção.

**Estado:** corrigido. A guarda é recusa por omissão desde 09/09/2026; o texto
abaixo descreve o que havia antes, porque é o que explica por que a correção
tinha essa forma.

A única guarda de login desta casa estava em `ISConfiguracao\Module::handleAuthRedirect()`:

```php
if (!$authService->getAut()->getIdentity()) {
    if (strpos($routeName, 'admin') !== false || $routeName == 'home' || $rotaDocumentacaoApi) {
        // manda para a tela de entrar
    }
}
```

Não há lista de rotas públicas. Não há recusa por omissão. **Quem decide se uma
rota exige login é a grafia da chave dela no `module.config.php`** — e a
comparação é `strpos`, não igualdade, então qualquer nome que contenha "admin"
em qualquer posição passa a exigir sessão, e qualquer nome que não contenha
dispensa.

#### A medida

De **109 chaves de rota, 15 casam a guarda e 94 não**. As 94 chegam ao
controlador sem identidade nenhuma.

A primeira medida deste registro dizia **107 chaves e 92 anônimas**, e envelheceu
no mesmo dia: a décima sexta fatia acrescentou `adm-api-funcionalidades` e
`adm-api-grupos`. O portão acusou as duas e eu as anotei no inventário — mas não
atualizei os números escritos aqui nem no cabeçalho dele. **O portão continuou
verde porque recalcula; só o texto envelheceu.**

Isso **não é uma lista de 94 buracos**. As 94 foram lidas uma a uma e todas as
14 famílias têm guarda conhecida:

| família | rotas | o que guarda |
|---|---:|---|
| `isapi-cronjob-*` | 26 | token fixo no código, `hash_equals` (D5) |
| `isapi-aplicativo-*` | 21 | sessão onde expõe pessoa; catálogo público no resto (D13, D59) |
| `adm-api-*` | 14 | ACL por ação (`conferir-acl-da-api.php`) |
| `isconfiguracao-autenticacao-*` | 5 | público de propósito: entrar, sair, redefinir senha |
| `iscadastro-ia-*` | 4 | página estática com embed de chatbot |
| `iscadastro-fidelidade-*` | 4 | formulário público do cliente; o cadastro entra por hash |
| `api-v4-*` | 4 | `Authorization: Bearer` (D91) |
| `iscadastro-pesquisa-*` | 3 | pesquisa NPS pública, respondida pelo próprio cliente |
| `isapi-visagismo-*` | 3 | fora de escopo por decisão do dono |
| `isapi-exportacao-*` | 3 | hash da unidade, com janela do anterior (D77) |
| `isconfiguracao-pesquisa-*` | 2 | pesquisa de satisfação pública |
| `isconfiguracao-vipclass-*` | 1 | URL-capacidade: id + hash do material |
| `pesquisarcep` | 1 | consulta de CEP; não lê dado do sistema |
| `isapi-getclienteagenda` | 1 | sessão do ERP (D59) |

#### Então onde está o defeito

O defeito não é o estado de hoje: é que **o estado de hoje não estava escrito em
lugar nenhum**, e a próxima rota nasce anônima a menos que alguém, sem ser
avisado, escolha um nome com "admin".

As rotas de `/adm/api` são o exemplo que fecha o argumento. A chave delas é
`adm-api-...`, que **não contém "admin"** — a Fase 2 inteira passa direto pela
guarda de login. O que a segura é a ACL de cada ação. Funciona, e não foi
escolhido: é o portão errado segurando a linha certa, e ninguém sabia disso até
esta medição.

Três coisas decorrem, e nenhuma é hipotética:

1. **Renomear uma chave de rota é mudar a autenticação dela.** O nome é a
   credencial. Um renomear de intenção cosmética abre ou fecha uma porta.
2. **`strpos` casa em qualquer posição.** Uma rota chamada `relatorio-admin-x`
   exige login por acidente de grafia, não por decisão.
3. **A recusa por omissão não existe.** Toda guarda desta casa é opt-in, escrita
   ação a ação, e é por isso que o D131 tem 114 ações sem privilégio: não há
   camada que negue antes de alguém lembrar de negar.

#### O que foi feito agora

`scripts/conferir-rotas-sem-login.php`, na CI. Ele **recalcula** o conjunto de
rotas anônimas a partir do `module.config.php` a cada execução e compara com um
inventário onde cada rota tem escrito **o que a guarda**. Falha nos dois
sentidos: rota nova não anotada sai com 1, rota do inventário que sumiu sai com
2. O inventário não envelhece em silêncio porque não é digitado — é conferido
contra a configuração real.

O texto à direita de cada entrada é o produto da tabela, e não a chave: quem
acrescentar uma rota precisa escrever o que a defende, e **se não souber
escrever, essa é a resposta**.

#### CORRIGIDO em 09/09/2026 — a inversão foi feita, e medida antes

A decisão que faltava era do dono, e o bloqueio dela caiu junto com o Power BI
(**D9**): o consumidor externo sobre o qual ninguém sabia responder deixou de
existir. Restava o aplicativo móvel, e esse a medida alcança.

**A guarda agora é `RotaPublica::exigeSessao()`**, e a lista de liberação mora
em `module/ISConfiguracao/config/rotas-publicas.php` — 94 famílias, cada uma com
escrito o que a defende. Quem não está na lista exige sessão. Não há recusa a
escrever caso a caso.

#### A medida que tornou a inversão segura, e a que a corrigiu

O número deste registro estava certo e era pequeno demais. **109 chaves de rota**
é a contagem de primeiro nível; o que o `getMatchedRouteName()` devolve é
`pai/filho`, e há 102 blocos de `child_routes`. Andando a árvore:

| | antes | depois |
|---|---:|---:|
| rotas casáveis | 205 | 205 |
| exigem sessão | 21 | **22** |
| chegam sem identidade | 184 | **183** |
| famílias liberadas | — | 94 |

**Casar só o topo é equivalente a casar o nome inteiro.** Foi medido nas 205
antes de ser escolhido: divergência **zero**. É o que autoriza uma lista de 94
famílias a decidir por 205 rotas.

**E a medida por *glob* estava incompleta.** Contando os `module/*/config/`
davam 204; pela configuração **mesclada** são 205. A que faltava é
`doctrine_orm_module_yuml` (`/ocra_service_manager_yuml`), do DoctrineORMModule
— **anônima, e o portão nunca a viu**, porque enumerava por regex sobre os
módulos do projeto. Um `composer require` de biblioteca com rota HTTP entrava
sem que ninguém soubesse. O portão agora enumera da configuração mesclada.

Essa rota é **a única mudança de comportamento** da inversão, e é deliberada:
passou a exigir sessão. Ela já não respondia nada — a fábrica levanta
`ServiceNotFoundException` porque `laminas-developer-tools.toolbar.enabled` não
está definida, e não está porque o arquivo que a definiria,
`laminas-developer-tools.local-development.php`, **não casa o glob**
`{,*.}local.php` que o `application.config.php` carrega.

#### Quatro coisas que a revisão adversarial achou, e que a inversão exigia

Trinta e dois vereditos independentes, seis sobreviveram à refutação.

**1. O portão de cron era CEGO para a tela de login, e este era o bloqueador.**
`scripts/medir-cron-no-postgres.php` é o único instrumento que EXECUTA as
rotinas, e reprovava por `$status >= 500 || $motivo !== null`, com `$motivo`
saindo de três agulhas de erro sobre o corpo. A tela de entrar responde **200**,
e o texto dela — medido — não casa `SQLSTATE`, nem `Fatal error`, nem
`Exception`. As 22 rotinas podiam estar **todas desligadas** com o portão
dizendo verde. A lista `$mudas` também não salvava: o próprio roteiro diz que
ela "não reprova". Corrigido antes de tocar na guarda: rotina de cron responde
JSON, e o que não for JSON é quebra. *Portão que só sabe reprovar o barulho não
vigia o silêncio.*

**2. A closure descartava o retorno.** `$this->handleAuthRedirect(...)` sem
`return`. Hoje inofensivo, porque a recusa é por mutação do `RouteMatch`. Mas é
o valor devolvido pelo ouvinte que alimenta o curto-circuito do Laminas: no dia
em que a recusa devolvesse uma resposta, ela seria **engolida em silêncio** e o
despacho seguiria como se a guarda não existisse. Corrigido.

**3. O portão reescrevia a regra em vez de lê-la.** Ele reimplementava o
`strpos` em PHP estático, e o comentário assumia a cópia em voz alta. Depois da
inversão ele ficaria verde conferindo a própria cópia da regra velha, para
sempre. Agora ele **chama** `RotaPublica::exigeSessao()`.

**4. A metade positiva da guarda nunca foi exercida.** Nenhum teste, em lugar
nenhum, provava que uma rota protegida recusa: `handleAuthRedirect` não aparecia
uma vez em `tests/`, os 40 caminhos da suíte de contrato começam todos com
`/api/`, e todo roteiro HTTP faz login antes de medir. `GuardaDeLoginTest` cobre
isso agora — inclusive despachando pelo ouvinte de verdade.

#### O que o teste diferencial prova

Para **todas as 205 rotas**, a decisão de hoje é comparada com a regra antiga,
congelada byte a byte dentro do teste. A única divergência tolerada é a do
`doctrine_orm_module_yuml`, nomeada. Falsificado: tirar uma entrada da lista
deixa o teste vermelho nomeando a rota, e o portão vermelho nomeando a entrada
órfã.

#### O que a correção NÃO resolve, e está escrito

**A granularidade é a família, não o endpoint.** Uma rota-filha nova pendurada
sob um pai já liberado nasce pública. É aceitável porque as únicas famílias com
muitos filhos são `adm-api-*` (ACL por ação, conferida por
`conferir-acl-da-api.php`) e `api-v4-*` (Bearer); as outras 74 são 1:1. Mas é
uma fresta, e o portão vigia **as 183 rotas**, não só as 94 famílias — é essa
diferença que faz a fresta aparecer.

**A recusa continua sendo a tela de login com HTTP 200.** Para uma tela do ERP
está certo; para um endpoint JSON é uma resposta que mente. Não mudou aqui de
propósito: mudar o mecanismo mudaria o comportamento das 21 rotas de hoje, e
esta inversão se propôs a não mudar nenhuma. Fica anotado como o próximo passo
natural, e a `AplicativoApiTest` ganhou a asserção de valor que desfaz o
disfarce no único instantâneo onde as duas respostas colidiam byte a byte.

#### Uma duplicação removida no caminho

Medindo as chaves apareceu `iscadastro-pesquisa-nps` **declarada duas vezes**,
byte a byte igual, no `ISCadastro/config/module.config.php`. Em PHP a segunda
sobrescreve a primeira, então não havia efeito — texto morto, e não defeito.
Removida. **Zero duplicadas**, e a medição confirmou que o caso
perigoso não existia em nenhum módulo: duas chaves iguais com destinos
**diferentes**, onde uma encobre a outra em silêncio.

#### O resto: o pedido que espera JSON recebe 401 (07/11/2026, a fatia 115, ROTAS-404-E-401)

**A recusa que mentia ao `$.ajax` acabou.** Em `ISConfiguracao\Module::handleAuthRedirect`, depois de a rota exigir sessão e
antes de desviar para o `entrar`, o pedido que espera JSON recebe o 401 da convenção da `/adm/api` --
`{"erro": {"codigo": "nao_autenticado", "mensagem": "A sessão do ERP acabou. Entre de novo."}}`, em `application/json` -- e o
despacho para ali (`return` da resposta, o molde da `PersonificacaoComOAcessoDeAgora::respostaDaVolta`). A regra mora em
`ISConfiguracao\Permissions\RecusaSemSessao::pedeJson`, estreita de propósito: o `X-Requested-With: XMLHttpRequest` (que o
jQuery manda em todo `$.ajax`, `$.post`, `$.get` e `.load`), ou o `Accept` que pede `application/json` e **não** pede
`text/html`. A navegação sempre pede `text/html`; quem aceita os dois (o `pedir()` do `ensaiar-sessao-do-erp.php` manda
`application/json, text/html`), o `*/*` do `curl` e o pedido sem cabeçalho continuam no formulário de entrar com 200, como
antes; a rota liberada continua passando com ou sem o cabeçalho. Os `scripts/ensaiar-*-adm-api.php` (25 arquivos) mandam
`Accept: application/json` sozinho, e recebem agora este 401 -- ver a correção da verificação, abaixo, sobre o D539.

**Antes, a lista dos `$.ajax`, pelo roteador de verdade** (medido em processo em 07/11/2026): os scripts das telas antigas
pedem **101 endereços distintos que exigem sessão** (`mod-configuracoes.js` 36, `mod-cadastros.js` 26, `mod-vendas.js` 21,
`mod-marketing.js` 9, `mod-financeiro.js` 5, `mod-relatorios.js` 4, `theme-switcher.js` 2, e um na `whats-app/instancia.phtml`),
8 públicos (o CEP, a fidelidade, a pesquisa NPS, o entrar e o `validarHash`) e 10 que não casam rota nenhuma (endereços
montados por concatenação). Quase nenhum trata erro, e nenhum tratava a tela de entrar no lugar dos dados. Os que pedem
endereço com sessão são todos carregados pelo `layout.phtml`; as páginas sem o layout (o entrar, a adesão, a pesquisa NPS, as
do assistente) só pedem rotas públicas. Nada usa `global: false`. Por isso **um ouvinte só**: o `ajaxError` no fim do
`public/layout/scripts/geral.js`, quando o pedido volta 401 com o código `nao_autenticado` -- e só então: o 401 de outra porta,
o 403 e os outros erros continuam com quem pediu --, pede uma vez o `POST /adm/api/sessao/retomar` (a entrada pelo cookie
`token_web`, a mesma da tela nova) e, com a sessão de volta, deixa a pessoa na tela; só quando o cookie não entra leva ao
`/autenticacao/entrar` (a correção da verificação, abaixo). O motivo aparece no entrar quando o servidor o gravou: o outro
aparelho e a permissão mudada (`UmDispositivoPorVez::encerrarSeExpulsa`, que grava o flash); a unidade desativada
(`encerrarSessaoDoDesativado`, o D351) e a sessão que expirou não gravam mensagem nenhuma, e o formulário aparece sem motivo --
como antes da fatia, na navegação. O `?cache=` do `geral.js` subiu para 165. A tela nova não muda: o `pedir` e o `baixar`
dela falam com a `/adm/api`, que já respondia o 401; fora dela, o `voltarASerEu` não manda `Accept` nem `X-Requested-With`
(o `*/*` do `fetch`, que segue no entrar), e os `fetch` do entrar, do sair e da redefinição de senha (`api/entrada.ts`,
`api/redefinicao.ts`) vão a rotas públicas (`rotas-publicas.php`), que a guarda não recusa.

**Provado:** `GuardaDeLoginTest` ganhou dois casos, pelo ouvinte de verdade -- o pedido do jQuery (pedindo HTML e pedindo
JSON) e o `fetch` que só aceita JSON recebem o 401 com o corpo da convenção, sem trocar a rota; a navegação, o pedido que
aceita os dois, o `*/*` e o pedido sem cabeçalho continuam no `entrar`; a rota liberada passa -- e a regra sozinha em 12
formas de cabeçalho. Os vizinhos que despacham pela guarda (D223, D315, D351, D514, a ativação, a senha trocada, o privilégio
que derruba a sessão, a barreira de origem) verdes nos dois bancos. **Por HTTP**, o caso 26 novo do `ensaiar-sessao-do-erp.php`,
sem cookie: o `GET /mod-vendas/vendas/indexVendas` com `X-Requested-With` responde 401 `application/json` `nao_autenticado`; a
navegação ao mesmo caminho, 200 com o formulário; o pedido do jQuery ao `entrar`, 200 com o formulário. **No front**,
`sessaoCaidaNaTelaAntiga.teste.ts` roda o `geral.js` servido: o 401 `nao_autenticado` leva ao entrar (lido pelo jQuery ou só
pelo texto), os outros não, e o layout pede o `geral.js` com o cache de pelo menos 165. **Mutantes**, um por vez, devolvidos
pelo sha1: o ramo do JSON tirado da guarda (os casos vermelhos nos dois bancos, e o ensaio HTTP mede o ajax em **200
`text/html`**, o defeito); a regra larga, que tira a página de quem aceita os dois; a regra sem o `X-Requested-With`; e, no
`geral.js`, o ouvinte sem o desvio, o ouvinte que leva qualquer 401, e o cache de volta ao 164.

**Não visto num navegador:** o `ajaxError` com o jQuery de verdade, numa tela antiga com a sessão derrubada no meio.

**A correção da verificação (07/11/2026, a mesma fatia).** Nove achados confirmados, todos consertados:

- **A sessão que expira com o cookie valendo perdia a tela (médio).** A sessão do PHP vence antes do `token_web` (o D77): o
  computador da recepção que dorme no almoço com a agenda aberta. Antes da fatia, o ajax caía no `GET` do entrar, que entrava
  pelo cookie sem ninguém ver: aquele pedido saía errado, e o clique seguinte funcionava. Com o 401 e o ouvinte levando ao
  entrar, o `GET` do entrar entrava pelo cookie e mandava à tela inicial (`entrarCookies`, `redirect()->toUrl('/')`): a pessoa
  saía da agenda ou do PDV sem ver formulário nem motivo. Agora o ouvinte pede antes o `POST /adm/api/sessao/retomar` (a regra
  do `Permissions\EntradaPeloCookie`, sem redirecionar, `global: false`, um só enquanto anda) e, com a sessão de volta, a
  pessoa fica onde estava -- como antes, o pedido que caiu não é refeito (pode ser escrita). Só quando o cookie não entra a
  página vai ao entrar.
- **O expulso pelo ajax sem caso.** A mensagem do outro aparelho é gravada no pedido que responde 401 e lida dois pedidos
  depois (o retomar recusado e o entrar). Caso 28 novo do `ensaiar-sessao-do-erp.php`: o ajax do expulso, 401 com um id novo;
  o retomar, 401; o entrar, 200 com o formulário **e a mensagem**; quem expulsou segue. E o caso 27: o ajax só com o cookie,
  401 (a guarda não entra pelo cookie); o retomar, 200 com um id novo; o ajax seguinte, 200.
- **O cabeçalho ilegível virava 500.** A `RecusaSemSessao` lia `Accept` e `X-Requested-With` pelo `getHeaders()->get()` do
  Laminas, que lança exceção para um byte que ele não aceita (`Accept: \xff`; o nginx só barra CR, LF e NUL): o anônimo recebia
  500 onde antes recebia o entrar. Agora lê cru, por `OrigemDoPedido::cabecalho` (o leitor da barreira de origem); o que não se
  lê não é JSON. Caso novo no `GuardaDeLoginTest` com o `PhpEnvironment\Request` montado do `$_SERVER`.
- **Duas regras de "quem pede JSON".** A da `BarreiraDeOrigem` (qualquer `Accept` com `application/json`) escolhe só a forma de
  uma recusa que já vai acontecer; a da guarda decide recusar em vez de mostrar a página, e é estreita. A diferença fica, de
  propósito, escrita nos dois comentários, e um caso novo no `GuardaDeLoginTest` fixa onde elas concordam (o jQuery, o só-JSON,
  a navegação) e onde não (`application/json, text/html`).
- **O detector do D539 por ensaio ficou em parte cego.** Na família da `/adm/api` que faltar na `rotas-publicas.php`, o caso
  "sem sessão" dos `ensaiar-*-adm-api.php` recebe agora o 401 da guarda, e não a página de login com 200; o que só confere o
  status passa verde. Quem pega a família esquecida é o `conferir-rotas-sem-login.php` (a quarta conferência do D539), que lê
  a config: medido, com a linha `adm-api-notificacoes` arrancada da `rotas-publicas.php`, ele sai 1. Escrito no D539 e nos
  desenhos que justificavam a linha pela página de login (`tela-entrada.md`, `tela-assistentes.md`).
- **Frases erradas:** a "mensagem do motivo" para a unidade desativada e a sessão expirada (que não gravam mensagem), o
  `voltarASerEu` como "único `fetch`" da tela nova fora da `/adm/api`, os ensaios que "pedem os dois tipos", o comentário do
  `ci.yml` que dava o `conferir-rotas-sem-login.php` como o único que precisa do `vendor/` (são três: ele, o das rotas órfãs e
  o da barreira de origem), e o do caso 6 do `ensaiar-links-consertados-do-erp.php` (três dos quatro apelidos vão pela curinga
  do próprio módulo).

**Provado na correção:** o `GuardaDeLoginTest` com 11 casos nos dois bancos; o `ensaiar-sessao-do-erp.php` 28 de 28 no MySQL e
no PostgreSQL; o `sessaoCaidaNaTelaAntiga.teste.ts` com 8 casos. **Mutantes**, um por vez, devolvidos pelo sha1: a leitura do
`X-Requested-With` de volta ao leitor do Laminas (o caso do cabeçalho ilegível, com a exceção); a regra da barreira trocada pela
da guarda (o caso das duas regras); o retomar que gasta o flash (o caso 28 vermelho, "SEM a mensagem", e o 9 verde -- só o
caminho do ajax o pega); e, no `geral.js`, o ouvinte que vai direto ao entrar (5 casos), o que vai ao entrar mesmo com a sessão
de volta, o retomar sem `global: false` e o ouvinte sem a trava do retomar em andamento (1 caso cada).

---

### D147 — A listagem de arquivos conferia privilégio, e a entrega não — CORRIGIDO em 09/09/2026 — PARCIAL, lido em 05/10/2026 — DECIDIDO em 13/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decidir o recorte por categoria ou unidade e o par id+hash na entrega; adotar o hash exige regerar os 101 e preencher os oito vazios.

`ArquivosUsuariosController::indexAction` pede
`configuracoes/arquivosExibir`/`ler` antes de montar a lista. `abrirAction` e
`downloadAction`, que **entregam o conteúdo**, pegavam o arquivo com
`find($this->params()->fromRoute('id', 0))` e devolviam os bytes sem perguntar
nada.

É a mesma forma do D145, um andar abaixo: a porta da tela guardada e a ação que
serve o dado, não.

#### O que estava atrás do id

| categoria | arquivos ativos |
|---|---:|
| Materiais | 101 |
| Manuais Franquia | 8 |
| **total** | **109** |

Todos PDF (102), `.xlsx` (5) ou `.xlsm` (2) — material da franqueadora, não dado
de cliente. Os ids são sequenciais, então percorrer os 109 é escrever um inteiro
de cada vez.

A tabela `arquivos` **tem coluna `hash`**, preenchida em 101 dos 109, e nenhuma
das duas ações a olhava. O desenho de URL-capacidade que o
`VipClassAdminController::materialDownload` usa — `id` + `h`, resolvidos juntos
por `selecionarMaterialPorIdHash` — existia aqui como coluna e não como
verificação.

#### O controle era o menu

O bloco inteiro de Arquivos no `layout.phtml` está dentro de:

```php
if ($sessao->getAcl("configuracoes", "arquivosExibir", "ler")) {
```

Quem não tem o privilégio não vê o menu, não alcança a listagem e nunca recebe um
link. **O controle da interface era o único controle** — e ele não sobrevive a
alguém digitando a URL.

Vale notar de onde veio a pista: o `ISConfiguracao\Module` injeta
`categoriasArquivos` no ViewModel raiz de **todo** dispatch, para todo mundo. As
categorias são carregadas para quem não pode vê-las; só o `if` do template as
esconde.

#### Medido antes de fechar

Dos **356 usuários ativos não administradores**:

| | |
|---|---:|
| têm `ler` em "Arquivos exibir" | **111** |
| têm algum privilégio em "Arquivos cadastrar" | **0** |
| têm `cadastrar` mas não `exibir`/`ler` | **0** |

Os 111 continuam trabalhando. Os 10 administradores passam pelo `Acl` de
qualquer forma (`allow($grupo, [], [])`, a correção do D138). E a outra tela que
gera os mesmos links — `arquivos/index.phtml`, de cadastro — é de administrador
na prática, porque **nenhum** não administrador tem privilégio nela. Ninguém
perde acesso que usava.

#### A guarda não é inerte

`ArquivosUsuariosController` define `$this->modulo = 'configuracoes'` e
`$this->funcionalidade = 'arquivosExibir'` no construtor, então
`getAcesso('ler')` chega a olhar o privilégio pedido. Conferido de propósito:
no D123, seis controllers da API **não** definiam `funcionalidade`, e a mesma
chamada devolvia o módulo inteiro — um arranjo sempre verdadeiro.

A lista congelada do D131 caiu de **114 para 112**.

#### O que fica em aberto

Trocar o id cru pelo par `id` + `hash` nas duas ações continuaria valendo, e é
independente desta correção: o privilégio responde *"esta pessoa pode ver
arquivos?"*, e o hash responderia *"esta pessoa recebeu este arquivo?"*. Hoje
quem tem `arquivosExibir`/`ler` vê os 109, e não há recorte por categoria nem
por unidade. Se isso é o desejado, é decisão de quem administra a rede.

#### MEDIDO em 09/09/2026 — a coluna não estava "esperando"

Esta seção terminava dizendo que *"a coluna `hash` já está preenchida em 101
deles, esperando"*. **Estava errada em três pontos**, e cada um sozinho já
desaconselha adotá-la como está.

**1. Ela é escrita e nunca lida.** `Arquivo::getHash()` tem **zero chamadores**
em todo o projeto — a única ocorrência é a própria definição. O construtor
gera o token e ninguém jamais o confere. Não é uma guarda meio pronta; é uma
coluna que *parece* uma guarda.

**2. Oito das 109 estão VAZIAS, e vazio é pior que ausente.** A coluna é
`nullable=false`, e o `NOT NULL` faz exatamente o que promete: protege contra
nulo. Os oito guardam **string vazia**. O padrão que seria copiado é
`WHERE id = :id AND hash = :hash` (`VipClassMaterialRepository:19`) — e com
hash vazio na linha, `&h=` vazio na URL abre o arquivo. Adotar a coluna sem
preencher os oito seria criar oito capacidades abertas.

**3. Os 101 preenchidos vieram do `mt_rand`.** Nenhum arquivo foi criado depois
de 01/09/2026, que é quando o **D77** trocou o gerador por `random_int` — e o
próprio D77 registra que a troca *"não conserta os valores já gravados"*. São
tokens de um gerador determinístico.

**O lado bom, e ele é real:** como ninguém lê a coluna, **regerar é de graça
hoje**. No `unidades.hash` não é — regerar lá quebra quem já usa a URL de
vendas, e é por isso que aquilo segue parado no D77. Aqui a janela existe
justamente porque a coluna está morta, e ela se fecha no dia em que alguém a
adotar.

`scripts/medir-hashes.php` mede as cinco colunas de token do sistema — vazios,
repetidos, tamanho e **quem as lê**.

#### E a medida quase mentiu, o que vale registrar

A primeira versão daquele roteiro acusou que `clientes.hash` tem 32 caracteres
enquanto `Cliente::setHash()` gera 8 e `FidelidadeController:27` exige
`strlen($hash) == 8` — o que pareceria dizer que **nenhum link de fidelidade
abre**, para 242 mil clientes.

Não é verdade. `scripts/anonimizar.sql.modelo:61` faz
`hash = MD5(CONCAT('hash-cliente-', id))`, e MD5 tem exatamente 32 caracteres.
Eu estava medindo a anonimização, não o sistema. O mesmo vale para
`unidades.hash` (linha 134).

E os "seis clientes sem hash" e as "três unidades de hash curto" que a medida
apontou eram, todos, **linhas do meu próprio cenário de testes** — ids acima de
90000000, o mesmo aviso que o D138 já tinha deixado sobre este banco.

O roteiro passou a excluir as fixtures e a imprimir `anonimo` no lugar do
tamanho das duas colunas que o anonimizador reescreve. *Número que não vale não
se imprime como se valesse.*

#### Respondido em 11/10/2026 (a resposta 135 do dono) — registrado em 13/10/2026

A pergunta 135 do lote (a P1 da tela nova de Arquivos) veio com a opção (a): **os 109 arquivos da franqueadora continuam
abertos a todos que têm o "Arquivos exibir"**, como hoje -- 111 pessoas não administradoras, em 54 unidades, e os 10
administradores; sem recorte por categoria nem por unidade. O par `id` + `hash` na entrega, que este registro deixava em
aberto, também fica: quem tem o privilégio já vê a lista inteira, e o código no endereço não acrescentaria nada. Nada
muda no código, e as duas decisões que faltavam estão tomadas.

O que segue de pé é outro defeito, que a pergunta já separava: o endereço direto do arquivo abre sem login na cópia.
Fechá-lo é conserto da tela nova de Arquivos, com qualquer resposta (`docs/f3-telas-restantes/tela-arquivos.md`, a P1).

> **22/10/2026, a resposta 253 (c) do dono (a fatia 29b, RESPOSTA-253-QUEM-ABRIU):** a pergunta "registrar quem abre e quem baixa cada arquivo?", que a página do lote ligou a este débito, foi respondida com "um registro por pessoa". A entrega -- as duas portas, pelo `EntregaDoArquivo` -- grava cada abertura e cada download que sai em `arquivos_acessos`, e o administrador que cadastra vê "quem abriu" de cada arquivo na tela nova. O controle de acesso deste débito não mudou (a resposta 135 (a) segue: o arquivo é aberto a quem tem o privilégio de ler). A LGPD do registro é o **D583**.

---

### D148 — Qualquer sessão concedia a si mesma qualquer privilégio — CORRIGIDO em 09/09/2026

O pior buraco da varredura do D131, e ele estava na tela que **define quem pode
o quê**.

`UsuariosController::permissoesSalvarAction` não conferia nada:

```php
if ($request->isPost()) {                       // <- e mais nada
    $data = $request->getPost()->toArray();
    $usuario        = $this->em->getReference(Usuario::class,        $data['usuario']);
    $funcionalidade = $this->em->getReference(Funcionalidade::class, $data['funcionalidade']);
    ...
```

e o serviço escrevia o que chegasse:

```php
$privilegio->setLer((bool) $data['ler']);
$privilegio->setEscrever((bool) $data['escrever']);
```

`usuario` e `funcionalidade` vinham **crus do POST**. Qualquer uma das **370
sessões ativas** podia conceder a si mesma qualquer uma das **126
funcionalidades** cadastradas — "Vendas cancelar", "Financeiro saídas",
"Relatórios de franquias". Escalação de privilégio pelo endereço.

`permissoesRemoverAction` é a outra ponta: apagava qualquer linha das **15.495**
de `privilegios_usuarios` pelo id cru, tirando o acesso de qualquer pessoa. E
`permissoesPesquisarAction` lia qualquer uma delas.

#### O controle era a tela

`permissoesAction`, que **renderiza** a tela, sempre exigiu
`configuracoes/usuarios`. Quem não tem o privilégio não vê o botão. Mas o botão
não guarda nada, e as três ações atrás dele respondiam a qualquer sessão. É a
terceira vez nesta varredura que **o controle da interface era o único
controle** — depois do D145 e do D147.

O efeito de uma concessão aparece no **próximo login**, porque o `Acl` é montado
na entrada e guardado na sessão. Isso não reduz o alcance: adia em um login.

#### E não havia recorte por franquia — o quinto desta forma

Nem a tela nem as três ações perguntavam de que unidade era o usuário alvo. São
**107 pessoas não administradoras, em 54 unidades**, com `escrever` nessa
funcionalidade: cada uma podia editar o privilégio de quem trabalha em outra
franquia. E a `indexAction` aceitava qualquer `?unidade=` da barra de endereços
— a consulta usa esse valor no ramo do não administrador —, então listar os
usuários da franquia vizinha era trocar um número.

A lista suspensa já restringia (`popularCombobox($sessao)` só oferece a própria
unidade a quem não é administrador), e lista suspensa não guarda nada. Mesmo
desenho do D139 e do D142.

É o **quinto** defeito desta forma — depois de D132, D137, D139 e D142 —, e as
cinco ações entraram no `conferir-recorte-por-unidade.php`, que passou a vigiar
**19 métodos**.

#### `find` e não `getReference`, e isto era defeito de verdade

`getReference` **nunca** devolve nulo: monta um proxy mesmo para id que não
existe, e a exceção só estoura quando alguém toca no objeto. O
`!empty($usuario)` ao lado dele era decorativo — id inexistente dava 500, e não
recusa. Trocado por `find` nos dois. É a mesma correção que o
`unidadeAutorizada()` do D77 já tinha recebido.

#### Medido antes de fechar

| | |
|---|---:|
| usuários ativos | 370 |
| não administradores | 356 |
| não administradores com `escrever` em "Configurações usuários" | **107** |
| não administradores com **só** `ler` | **0** |
| administradores (passam pelo `Acl` de qualquer forma) | 10 |
| unidades distintas com alguém que tem `escrever` | 54 |

Ninguém perde acesso que usava. E conferido antes de escrever a guarda de
unidade: `usuarios.unidade` é **NOT NULL** (zero nulos em 2.431 linhas) e não há
privilégio órfão, então `daMinhaUnidade()` não estoura.

A lista congelada do D131 caiu de **112 para 109**.

---

### D149 — A cota-parte do colaborador era gravada sem privilégio e sem unidade — CORRIGIDO em 09/09/2026

Mesma tela do D148, mesma forma, e desta vez o que se grava é **dinheiro que sai
da venda e vai para a pessoa**.

`comissaoExtraSalvarAction`, `comissaoExtraPesquisarAction` e
`comissaoExtraRemoverAction` não conferiam privilégio nem unidade, com `usuario`
e `id` crus do POST. São **35 linhas para 16 colaboradores**, com valores de
**15 a 70**.

#### O modelo certo estava ao lado

`comissaoAction` — a tela — já confere as duas coisas:

```php
if ($this->getAcesso()) {
    $usuario = ...->find($this->params()->fromRoute('id', 0));
    if (!empty($usuario) && ($usuario->getUnidade()->getId() == $this->unidadeId || $this->usuarioAdmin)) {
```

As três ações atrás dela não conferiam nenhuma. É o mesmo desenho do D148, no
mesmo controlador: **a tela guardava, o endereço não**.

#### O produto também precisa ser da mesma unidade

`comissaoExtraSalvar` fazia `find($request['produto'])` e gravava. A lista
suspensa da tela já restringe —
`popularCombobox($usuario->getUnidade()->getId())` —, mas o id chegava cru: sem
conferência, dava para atribuir cota-parte de um produto da franquia vizinha a
um colaborador da sua. É a mesma correção que o `adicionarServico` recebeu no
D142.

#### Medido antes de fechar

A funcionalidade é a mesma do D148, `configuracoes/usuarios`: **107 não
administradores em 54 unidades** têm `escrever` e continuam trabalhando, **zero**
têm só `ler`, e os 10 administradores passam pelo `Acl` de qualquer forma.

A lista congelada do D131 caiu de **109 para 106**, e o
`conferir-recorte-por-unidade.php` passou a vigiar **22 métodos**.

#### Onde a ausência de guarda é a resposta certa

Registrado junto, porque a varredura precisa distinguir: `meusDadosPerfilAction`
e `meusDadosSenhaAction` do mesmo controlador buscam
`find($this->usuarioId)` — **o próprio usuário da sessão**, e não um id da
requisição. Não há o que guardar: quem chama só alcança a si mesmo. O mesmo vale
para `salvarTemaAction`, `obterTemaAction` e `menuCloseAction`, que gravam
preferência de interface do próprio usuário.

E `autenticarComoAction` **tem** guarda, ainda que não seja `getAcesso()`:
`$this->usuarioAdmin || !empty($this->sessao->getUsuario('usuarioSessao'))` — o
desenho do D88, onde só administrador assume outra identidade e o segundo ramo
serve para voltar. A lista congelada a conta porque a regra dela é sintática, e
mudar essa regra para "guarda parecida também vale" abriria a porta para
qualquer condição no lugar certo.

---

### D150 — As quinze ações da agenda não conferiam nada — CORRIGIDO em 09/09/2026

O maior bloco da lista congelada do D131, e o mais delicado de fechar: a agenda
é o que a barbearia usa o dia inteiro.

`AgendasController` declara `modulo = 'agendas'`, `funcionalidade =
'visualizar'`. Só `indexAction` (`ler`) e `bloqueiosRemoverAction` (`escrever`)
conferiam privilégio. As **outras quinze** — agendar, desmarcar, remarcar (duas
formas), check-in, abrir e fechar agenda, e as leituras de detalhe — não
conferiam nada, e todas pegavam a entidade com `find($request['id'])` sem
perguntar de que franquia era.

#### A regra estava escrita na tela, e eu a copiei em vez de inventar outra

`tabela.phtml` envolve **todo** controle interativo em:

```php
if ($sessao->getAcl("agendas", "gerenciar", "escrever")) {
```

e o ramo `else` desenha a **mesma tabela** com `cursor: default` e sem
`onclick`. Quem não tem o privilégio vê a agenda e não mexe nela.

Então a guarda das treze ações que agem é `getAcesso2('gerenciar')` — a mesma
expressão, e não uma parecida. Se a regra mudar na tela, precisa mudar aqui
junto; uma segunda opinião sobre "quem gerencia" seria uma terceira regra no
sistema.

#### Onde `escrever` teria sido a resposta errada

`atualizarAction` é a **leitura que monta a tabela** — chamada no carregamento
da página e pelo botão de busca, ambos fora do bloco `gerenciar`. Guardá-la com
`escrever` deixaria **104 pessoas sem agenda nenhuma**: são as que têm `ler` mas
não `escrever` em "Agendas visualizar". Ela pede `ler`, como o `indexAction`.

Essa distinção é o produto desta correção. Uma varredura que só sabe pôr
`getAcesso()` em tudo que não tem teria quebrado a operação de 104 pessoas para
fechar um buraco que não estava ali.

#### A unidade, e por que não é "administrador"

A unidade de um agendamento é a do **colaborador** que atende. E a exceção não é
`usuarioAdmin`: é `agendas/geral`, que o `SessaoAcl` mapeia para a funcionalidade
**"Agendas unidades"** —

```php
'geral' => $this->salvarAcesso($grupo->getNome(), 'Agendas unidades')
```

— e que administrador recebe pelo `Acl` de qualquer forma. É a mesma expressão
do `popularComboboxAgenda()`, que monta a lista suspensa de unidades da tela.

`atualizarAction` era o pior caso: `unidade` vinha do POST com a sessão apenas
como **valor padrão**. Trocar o número lia a agenda da franquia vizinha — quem
atende quem, a que horas, com nome e telefone no balão.

**Zero não administradores** têm "Agendas unidades" hoje, então na prática a
regra é a própria unidade — mas escrever a expressão certa importa: no dia em
que alguém receber essa funcionalidade, a guarda continua correta sozinha.

#### O balão de leitura esconde o que o endpoint entregava

Detalhe que decidiu a guarda de `horariosMarcadosAction`: o balão do ramo
somente-leitura mostra o nome (respeitando `agenda_primeiro_nome`) e o serviço,
e **omite o telefone**; o do ramo `gerenciar` mostra o telefone. O endpoint,
sem guarda, entregava a quem quisesse exatamente o que a interface tinha
decidido esconder.

#### As duas de mensagem

`clienteNovoMensagemAction` e `clienteSemRetornoMensagemAction` redirecionam para
`api.whatsapp.com/send?phone=...`: **a URL leva o telefone**. Sem guarda,
qualquer sessão transformava um inteiro sequencial no telefone de uma pessoa,
sobre 242.592 cadastros. Passam a exigir `ler`, o mesmo que as telas que geram
os botões.

Na primeira **não** há recorte por unidade, de propósito — cliente é da rede e
não da loja, e guardar por unidade quebraria o atendimento sem proteger nada
(o mesmo achado do D145). Na segunda **há**, porque o id da rota é de uma
**venda**, e venda pertence a uma franquia.

#### Um 500 latente, corrigido de passagem

`horariosMarcadosAction` fazia `find($request['cliente_id'])` e chamava
`getId()` no resultado na linha seguinte, sem conferir. `find` devolve nulo: id
inexistente derrubava a ação com 500 em vez de recusar.

#### Medido antes de fechar

| | |
|---|---:|
| não administradores ativos | 356 |
| com algo em "Agendas visualizar" | 354 |
| com `escrever` em "Agendas gerenciar" | **182** |
| com `ler` mas **sem** `escrever` em "Agendas visualizar" | 104 |
| com "Agendas unidades" | **0** |

Os 182 continuam agindo; os demais continuam vendo. Ninguém perde o que usava.

A lista congelada do D131 caiu de **106 para 91**, e o
`conferir-recorte-por-unidade.php` passou a vigiar **29 métodos**.

---

### D151 — Treze ações administrativas de duas telas, e o que nelas *não* se guarda — CORRIGIDO em 09/09/2026

Duas telas autocontidas, o mesmo defeito, e — o que dá sentido a juntá-las — a
mesma lição sobre o **outro lado**: em ambas, o que sobrou sem guarda ficou assim
por medida, e não por esquecimento.

#### VIP Class: sete de administração, uma de aluno

`VipClassAdminController` tem 18 ações; dez já conferiam `vipclass/admin`. As
sete que não conferiam apagavam aula, apagavam material e gravavam ou apagavam
pergunta e resposta de checkpoint, com os ids crus do POST.

**Zero usuários não administradores têm "VIP Class - Admin".** Na prática só
administrador já entrava ali, e administrador passa pelo `Acl` de qualquer
forma. Fechar não tira nada de ninguém.

E a oitava, `materialDownloadAction`, **fica sem guarda** — a mesma medida
explica por quê: quem baixa material são os **351** usuários com "VIP Class -
Edu", não os administradores. Ela é uma URL-capacidade (`i` + `h`, resolvidos
juntos por `selecionarMaterialPorIdHash`), e pôr `vipclass/admin` nela tiraria o
material de 351 pessoas para proteger o que o hash já protege.

Sem recorte por unidade, e não é esquecimento: `VipClass` tem uma **coleção** de
unidades — para quais franquias a aula foi publicada — e não uma unidade dona. O
conteúdo é da franqueadora.

#### Pesquisa de satisfação: seis de administração, duas do cliente

`PesquisasController` tinha oito sem guarda. Seis gravam e apagam as
**perguntas** e as **respostas possíveis** do formulário — e não conferiam nada.

**Um único usuário não administrador** tem "Pesquisa de satisfação", e tem
`escrever`. Fechar não tira nada de ninguém.

As outras duas, `responderAction` e `agradecimentoAction`, **ficam sem guarda**:
são as páginas que o cliente abre pelo link, sem login. Estão no inventário do
`conferir-rotas-sem-login.php` como públicas de propósito, e pôr privilégio
nelas seria fechar a pesquisa para quem ela existe.

#### O padrão que estas duas fecham

Em cada tela varrida até aqui a pergunta certa não foi *"esta ação tem
`getAcesso()`?"* — foi *"quem usa esta ação, e por qual caminho?"*. As respostas
divergiram:

| tela | fecha | fica aberta, e por quê |
|---|---:|---|
| Arquivos (D147) | 2 | — |
| Permissões (D148) | 3 | — |
| Cota-parte (D149) | 3 | — |
| Agenda (D150) | 15 | — (mas uma pediu `ler`, e não `escrever`, por 104 pessoas) |
| VIP Class (D151) | 7 | `materialDownload`, por 351 alunos |
| Pesquisa (D151) | 6 | `responder` e `agradecimento`, que são do cliente |

A lista congelada do D131 caiu de **91 para 78**.

---

### D152 — Clonar o catálogo de uma franquia para outra não conferia nada — CORRIGIDO em 09/09/2026

A pior das quatro do catálogo, e a de forma diferente de tudo que veio antes:
não é uma linha alcançada pelo id errado, é uma **operação em lote entre duas
franquias**, com as duas escolhidas por quem chama.

```php
if (!empty($request['origem']) && !empty($request['destino'])) {
    $unidadeDestino = ...->find($request['destino']);
    $produtos = ...->selecionarProdutosClonar($request['origem'], $request['tipo']);
    ...->clonar($unidadeDestino, $produtos);
```

Sem privilégio, sem unidade, `origem` e `destino` **crus do POST**. Qualquer
sessão autenticada despejava o catálogo de uma franquia dentro de outra — são
**4.121 produtos ativos em 61 unidades**.

Passa a exigir `configuracoes/produtos`/`escrever` **e que as duas pontas** sejam
alcançáveis por quem chama. Conferir só o destino deixaria metade do defeito de
pé: copiar o catálogo *da* franquia vizinha para a minha é ler o preço dela.

#### As duas baixas de estoque, e uma delas não tem chamador

`retiradaAction` dava baixa com `find($request["produto"])` — id cru — e o `else`
chamava `getQuantidade()` no resultado, então produto de outra franquia ou id
inexistente caía com **500** em vez de recusar. A irmã `retiradaPdvAction` já
usava `selecionarProduto()`, que confere a unidade; esta não usava. Agora usa o
mesmo auxiliar, e a recusa é limpa.

> **04/10/2026: não era (D387).** A recusa continuou no `else` da conferência, que
> monta a frase com `$produto->getQuantidade()` — no nulo. O produto de outra
> franquia seguiu caindo com 500 até o item ESTOQUE.

**E `retiradaPdvAction` não tem chamador nenhum.** Procurei em todo o `public/`
e em todo o `module/`, incluindo os minificados: a única ocorrência de
`retiradaPdv` é a própria definição. É um endpoint **morto com rota viva**, que
dá baixa em estoque.

Ganhou a guarda como as outras. **Remover a rota e a ação é decisão sua** —
guardar é estritamente mais seguro do que apagar algo que talvez seja chamado
por um caminho que eu não enxergo.

#### Duas buscas parecidas, dois públicos diferentes

`pesquisarAction` é a busca da janela de produtos da tela de **configurações**.
A unidade ela já passava (`pesquisar($request, $this->unidadeId)`); faltava o
privilégio, e ela pede `ler`.

`autoCompliteAction` **fica sem guarda**, e não é esquecimento: é o campo de
busca do **carrinho do PDV** (`mod-vendas.js`). Pôr `configuracoes/produtos`
nela cairia direto no **D138** — só 113 dos 356 não administradores têm essa
funcionalidade, e o PDV é usado por muito mais gente. É a mesma decisão pendente,
e continua sua.

#### Medido antes de fechar

| | |
|---|---:|
| não administradores com "Configurações produtos/serviços" | **113** |
| destes, com `escrever` | 112 |
| produtos ativos | 4.121 |
| unidades com produto | 61 |

Os 113 continuam trabalhando; as três telas fechadas são de configuração, e o
menu delas já exigia o privilégio.

A lista congelada do D131 caiu de **78 para 74**, e o
`conferir-recorte-por-unidade.php` passou a vigiar **31 métodos**.

---

### D153 — O clone do catálogo estava copiado num segundo controlador, e minha própria guarda estava errada — CORRIGIDO em 09/09/2026

Duas coisas, e a segunda é uma correção do que eu tinha escrito horas antes.

#### A cópia

`ServicosController::clonarAction` é **byte por byte** o mesmo defeito do D152:

```php
if (!empty($data['origem']) && !empty($data['destino'])) {
    $unidadeDestino = ...->find($data['destino']);
    $servicos = ...->selecionarServicosClonar($data['origem'], $data['tipo'] ?? null);
    ...->clonar($unidadeDestino, $servicos);
```

Sem privilégio, sem unidade, as duas franquias cruas do POST.

Os dois controladores declaram a **mesma funcionalidade**
(`configuracoes/produtos`), agem sobre a **mesma entidade** (`Produto`, mudando
só o `tipo`) e têm telas gêmeas. **A mesma correção precisou ser escrita duas
vezes**, e uma terceira cópia passaria despercebida — por isso as duas entraram
no `conferir-recorte-por-unidade.php`, que agora vigia **33 métodos**.

#### A guarda que eu tinha escrito estava errada

No D152 escrevi
`getAcesso() && unidadePermitida(origem) && unidadePermitida(destino)`. Isso
**nega o caso perigoso** — para quem não é administrador as duas pontas viram a
própria unidade, e clonar um catálogo sobre ele mesmo não faz nada. Mas não é a
regra que o sistema declara.

A regra está no `index.phtml` das duas telas, e o botão inteiro mora dentro
dela: `if ($administrador)`, com `$administrador` sendo `$this->usuarioAdmin`.
**Clonar catálogo entre franquias é operação de administrador**, e não de quem
tem `configuracoes/produtos` — são 113 pessoas com essa funcionalidade, e
nenhuma delas vê o botão.

Trocado por `$this->usuarioAdmin` nos dois. É o princípio que o próprio D150
estabeleceu: escrever a condição da tela, e não uma paralela que por acaso dá no
mesmo. E o auxiliar `unidadePermitida` do `ProdutosController`, que ficou órfão,
foi removido — código morto é o que este trabalho combate.

#### E por isso as duas voltaram para a lista congelada

`usuarioAdmin` **não é privilégio**, e o D131 conta ações sem conferência de
privilégio. As duas `clonarAction` voltaram para a lista, anotadas — a mesma
decisão que já valia para o `autenticarComoAction`: a regra daquele portão é
sintática, e afrouxá-la para *"guarda parecida também vale"* abriria a porta
para qualquer condição no lugar certo.

**Os dois portões pegaram a mudança sozinhos**, e é o melhor que aconteceu nesta
correção: o do D131 acusou `ACOES NOVAS SEM GUARDA`, e o de recorte acusou
`esperava encontrar unidadePermitida`. Nenhum dos dois foi enganado por uma
guarda que eu considerava boa.

#### O segundo endpoint morto da varredura

`selecionarServicosPorUnidadeAction` recebia `unidade` cru do POST e devolvia o
catálogo de qualquer franquia. A função `servicosSelecionarPorUnidade()` existe
no `mod-configuracoes.js` e **não é invocada em lugar nenhum** — procurei em
todo o `public/` e em todo o `module/`.

É o segundo endpoint morto com rota viva, depois do `retiradaPdvAction` do D152.
Ganhou `ler` e o recorte por unidade — a regra geral da casa, e não
`usuarioAdmin`: não dá para saber para que tela foi escrita, então a guarda
conservadora é a que vale em todas. **Remover a rota e a ação é decisão sua.**

A lista congelada do D131 continua em **74**: saiu o
`selecionarServicosPorUnidade` e voltou o `clonarAction` do catálogo.

---

### D154 — A foto do usuário: id cru dentro de um caminho de arquivo — CORRIGIDO em 09/09/2026

As quatro últimas ações sem guarda da tela de usuários, e uma delas é de outra
natureza: não é só acesso indevido, é **escrita de arquivo com caminho vindo da
requisição**.

#### O caminho

`uploadAction` não conferia nada — nem `isPost()`, nem privilégio, nem unidade —
e passava `$dados['id']` cru ao serviço, que monta:

```php
$diretorio = "./public/layout/img/avatar/cache/" . $usuario_id;
$nomeOriginal = $diretorio . "." . $extensao;
...
move_uploaded_file($arquivo['tmp_name'], $nomeOriginal);
```

Com `id` na forma `../../algum/lugar`, a escrita sai do diretório pretendido.

A extensão já era restrita a `jpg/jpeg/png/gif`, o que limita o estrago — não dá
para largar um `.php` executável. **Mas escrita de arquivo com caminho vindo da
requisição não se corrige com lista de extensões**: sobra sobrescrever imagens
existentes em qualquer diretório alcançável, e encher disco.

Agora o id vem da **entidade já conferida**, convertido para inteiro, e o
usuário precisa ser da mesma franquia. `definirImagemAction` — a segunda metade,
que recorta e promove o arquivo — monta o caminho do mesmo jeito e recebeu a
mesma correção.

Mesmo sem a travessia, o defeito de acesso já bastava: **qualquer sessão
autenticada trocava a foto de qualquer pessoa da rede**, inclusive a de um
administrador. A tela que chama as duas, `editarFotoAction`, sempre conferiu
`getAcesso()` e a unidade; as duas ações atrás dela, não.

#### As outras duas

`localizarAcessosAction` devolvia os **últimos 500 acessos** de qualquer usuário,
pelo id cru — o histórico de entrada de qualquer pessoa da rede. Passa a exigir
`ler` (o mesmo que a listagem que a chama) e a mesma franquia.

`selecionarPorUnidadeAction` recebia `unidade` cru e devolvia **os colaboradores
de qualquer franquia** — nome e id de quem trabalha na vizinha. Mesma guarda.

#### Uma regra, e não duas cópias

O `daMinhaUnidade($usuario)` que o D148 criou passou a delegar a um
`unidadePermitida($unidadeId)`, porque `selecionarPorUnidade` só tem o id da
unidade. Duas cópias da mesma condição divergiriam na primeira vez que alguém
mexesse numa delas — é o mesmo motivo pelo qual o auxiliar existe.

#### Onde está a tela de usuários agora

Das 16 ações que o D131 listava neste controlador, **10 foram fechadas** em três
passos (D148, D149, D154) e **6 permanecem por estarem certas assim**:
`meusDadosPerfil`, `meusDadosSenha`, `salvarTema`, `obterTema` e `menuClose`
agem sobre o próprio usuário da sessão, e `autenticarComo` tem guarda de
administrador — contada porque a regra do portão é sintática.

A lista congelada do D131 caiu de **74 para 70**, e o
`conferir-recorte-por-unidade.php` passou a vigiar **37 métodos**.

---

### D155 — Os relatórios guardavam a tela e não o detalhe — CORRIGIDO em 09/09/2026

Sete controladores de relatório, e a mesma simetria em todos: um `indexAction`
que confere `getAcesso('ler')`, e **uma ou duas ações de detalhe ou exportação
que não conferiam nada**.

| controlador | funcionalidade | o que ficava aberto |
|---|---|---|
| `Atendimentos` | `relatorios/atendimentos` | a exportação em PDF |
| `Evasao` | `relatorios/evasao` | a geração do PDF |
| `Caixas` | `relatorios/caixa` | as retiradas de um caixa |
| `PesquisasNps` | `relatorios/nps` | o detalhe por unidade **e** por colaborador |
| `Produtos` | `relatorios/vendasProdutos` | o histórico de retiradas de um produto |
| `DashboardColaboradores` | `relatorios/dashboardColaboradores` | o extrato de plano de carreira |
| `FluxoMovimento` | `relatorios/fluxoMovimento` | o gráfico de fluxo |

#### Fechar não custou nada, e desta vez por construção

Nas correções anteriores eu precisei **medir** quantas pessoas perderiam acesso.
Aqui a medida é desnecessária, e isso é melhor do que uma medida: as oito ações
são chamadas apenas pelo `mod-relatorios.js`, que só é carregado nas próprias
telas de relatório — e cada uma dessas telas já exige `ler` na mesma
funcionalidade que a ação passou a exigir.

**Quem alcança o botão já tem o privilégio.** Conferido: as quatro que eu não
tinha lido aparecem só nesse arquivo, e em nenhum outro `.js` ou `.phtml`.

#### Três precisavam também da unidade

`Produtos::retiradasHistorico` pegava o produto com `find($request['id'])` — id
cru — e devolvia **quanto a franquia vizinha tirou do estoque, quando e por qual
motivo**.

`DashboardColaboradores::extratoPlanoCarreira` passava `colaboradorId` direto
para a consulta: a evolução de carreira de quem trabalha em outra franquia,
seis meses de cada vez.

`FluxoMovimento::gerarGrafico` lia `unidade` da query com a sessão apenas como
**valor padrão** — o mesmo desenho do D139, do D142 e do D148. Agora só
administrador escolhe.

As outras cinco já conferiam a unidade. Uma delas, `Caixas::retiradasDetalhes`,
confere **sem exceção para administrador** — administrador também não vê a
retirada de outra franquia por ali. É escolha do sistema e não esquecimento meu;
deixei como estava.

#### Caminho de arquivo montado com texto da requisição, do outro lado

`Atendimentos::gerar` e `Evasao::pdf` já forçavam a unidade para quem não é
administrador, mas montavam o caminho do cache assim:

```php
$arquivo = "./data/cache/relatorios/atendimentos/" . $unidade . "_" . $ano . ".json";
```

Para um **administrador**, `?unidade=../../algo` mudava o arquivo lido. É
leitura, e não escrita — o oposto do D154, onde o mesmo descuido escrevia. Os
dois agora convertem para inteiro antes de compor o caminho.

A lista congelada do D131 caiu de **70 para 62**, e a categoria `ABERTA` de
**17 para 9**. O `conferir-recorte-por-unidade.php` passou a vigiar **40
métodos**.

---

### D156 — As seis últimas em aberto, e três que eu tinha rotulado errado — CORRIGIDO em 09/09/2026

A categoria `ABERTA` da lista congelada do D131 chegou a **zero**. O que restou
são 56 ações que ficam sem guarda por motivo escrito: 22 travadas na decisão do
D138, 28 corretas assim, 6 fora de escopo.

#### As seis fechadas

| ação | privilégio | medida |
|---|---|---|
| `Convenios::historicoVendas` | `configuracoes/convenios`/`ler` | 3 pessoas têm, 0 com `escrever` |
| `Convenios::clientesConveniados` | idem | idem |
| `Convenios::unidadesConveniadas` | idem | idem |
| `ProdutosTags::gerenciarSalvar` | `configuracoes/produtosTags`/`escrever` | **nenhuma linha** concede; só administrador |
| `WhatsApp::status` | `configuracoes/whatsapp`/`ler` | 119 pessoas têm |
| `Clientes::creditoDetalhes` | `clientes/cadastro`/`ler` | ver abaixo |

As três do convênio **não** ganharam recorte por unidade, e não é esquecimento:
`Convenio` tem uma **coleção** de unidades — quais franquias aderiram — e não uma
unidade dona. É a mesma forma do `VipClass` no D151.

`WhatsApp::status` já acertava a unidade sozinha: `getWhatsAppApi()` monta o
cliente a partir de `find($this->unidadeId)`, a unidade da **sessão**, e não de
um id da requisição. Faltava só o privilégio.

#### O privilégio "certo" teria sido o errado

`creditoDetalhes` devolve a pontuação e o crédito de um cliente, e existe uma
funcionalidade `clientes/credito` — "Clientes crédito", com **168** usuários não
administradores. Pelo nome, seria a guarda certa.

Mas os **dois botões** que chamam essa ação — pontuação e crédito — estão no
`clientes/index.phtml` **sem condição nenhuma em volta**: quem vê a lista vê os
dois. E a lista exige `clientes/cadastro`, que **362** pessoas têm.

Guardar por `clientes/credito` tiraria o botão de **~194 pessoas que o usam
hoje**. É o D138 outra vez, e a decisão é sua. Por isso a guarda é a condição
**real** da tela, e não a que o nome sugere.

**A funcionalidade `clientes/credito` continua órfã** — declarada no `SessaoAcl`
e conferida em lugar nenhum. Fechar essa órfã é uma mudança de política, não uma
correção de defeito.

#### Três rótulos meus estavam errados

A triagem do D131, feita horas antes, classificou 17 ações como `ABERTA`. Lendo
uma a uma, **três não eram**:

- **`Grupos::ativarRemover`** é um **coto**: o corpo inteiro é
  `return $this->notFoundAction()->setTerminal(true);`. Eu a rotulei `ABERTA`
  porque minha regra de categorização só reconhecia `novoAction` e
  `editarAction` como cotos naquele controlador.
- **`Faturas::index`** *tem* guarda: `$grupoId == 3 || $grupoId == 4`, um id de
  grupo escrito no código — que é exatamente o **D124**. Não é privilégio, e por
  isso continua contada aqui, mas como `OUTRA-GUARDA`.
- **`Clientes::programaFidelidadeResgatarPontuacaoGerarCodigo`** é chamada pelo
  `mod-vendas.js`: é **PDV**, e cai no D138. A unidade ela já filtra.

Os três rótulos foram corrigidos no mapa. **Uma triagem feita por regra
sintática erra**, e o valor dela é reduzir o que precisa ser lido — não
substituir a leitura.

A lista congelada do D131 caiu de **62 para 56**, e a categoria `ABERTA` de
**9 para 0**.

---

### D157 — Editar o privilégio de um grupo apagava 689 ajustes individuais, sem aviso — CORRIGIDO em 09/09/2026

Achado ao ler a permissão para construir a décima sexta fatia. **Há duas camadas
de privilégio**, e a de cima destrói a de baixo em silêncio.

#### As duas camadas

| tabela | o que é | linhas |
|---|---|---:|
| `privilegios` | o **molde do grupo**: (grupo, funcionalidade) → ler/escrever | **139** |
| `privilegios_usuarios` | as linhas **operativas**, por pessoa | **15.495** |

Quem o `Acl` lê no login é **só a segunda**. `SessaoAcl` carrega
`PrivilegioUsuario::selecionarPorUsuario($usuario->getId())` e nada mais — a
tabela `privilegios` nunca é consultada para decidir um acesso.

Ela é um **gerador**. `PrivilegioService::insert()` e `update()` terminam em:

```php
$this->atualizarPermissoesUsuarios($grupo, $funcionalidade, $privilegio);
```

e esse método faz, nesta ordem:

1. `removerPermissoesUsuarios()` — **apaga todas** as linhas de
   `privilegios_usuarios` daquele par (grupo, funcionalidade);
2. cria uma linha nova para **cada usuário do grupo**, com os valores do molde.

#### O que isso apaga

Uma edição no grupo "Gerente unidade" reescreve as linhas de **105 pessoas**. E
o que se perde não é hipotético:

| | linhas |
|---|---:|
| iguais ao molde do grupo — reescrever não muda nada | 14.806 |
| **divergentes do molde** — alguém ajustou aquela pessoa à mão | **347** |
| **sem molde de grupo nenhum** — concessão que só existe por pessoa | **342** |
| **total em risco** | **689** |

As 347 somem na próxima vez que alguém tocar naquele par no grupo. As 342 somem
no dia em que alguém **criar** um molde para o par — `insert()` também chama
`atualizarPermissoesUsuarios()`, que começa apagando.

**689 decisões individuais** que a tela de grupo apaga sem perguntar e sem
avisar.

#### Por que isso importa agora

O **D148** corrigiu a tela que edita a camada de baixo — `permissoesSalvar` /
`permissoesRemover`, que não conferiam nada. Guardar aquela tela foi certo, mas
não muda o fato de que **o que ela grava é apagável de cima**.

Quem usa o sistema não tem como saber disso: as duas telas mostram números que
parecem independentes, e nenhuma diz que a outra sobrescreve.

#### A decisão, e o que ela pesou

Preservar ajustes individuais numa edição de grupo é **decisão de produto**, não
correção de defeito, então foi levada ao dono com a medida na mão. Havia três
respostas defensáveis:

1. **Manter como está.** O molde é a verdade; ajuste individual é temporário por
   desenho. Então a tela de grupo deveria **dizer** quantas linhas vai apagar.
2. **Preservar divergências.** A edição de grupo só toca quem está igual ao
   molde antigo. Mais próximo do que as 689 linhas sugerem que as pessoas
   esperam — e mais caro de explicar.
3. **Acabar com a camada de baixo como coisa editável.** Só o grupo concede, e a
   tela de permissão por usuário some. Simplifica o modelo e invalida 689
   decisões de uma vez.

**A escolhida foi a segunda**, e o que pesou foi a direção das divergências.
Decompondo as 347, em 110 pessoas distintas:

| direção | linhas |
|---|---:|
| usuário tem **menos** que o molde — alguém **tirou** | **225** |
| usuário tem **mais** que o molde — alguém **concedeu** | 122 |

Uma edição de grupo não apagava só um ajuste: ela **devolvia acesso que alguém
tinha removido de propósito**, em 225 casos. O erro caía para o lado de abrir.

E as 342 sem molde têm forma própria: **171 delas** — metade — são de uma
funcionalidade só, "Relatórios programa de indicação". Parece uma liberação
feita pessoa a pessoa que nunca ganhou molde de grupo, e que sumiria inteira no
dia em que alguém criasse um.

#### A regra nova, numa frase

**A propagação só toca a linha que estava em dia com o molde.** Os três casos:

| situação da linha | o que acontece |
|---|---|
| igual ao molde anterior | acompanha o molde novo |
| divergente | **não se toca** |
| inexistente | criada **só** quando não havia molde no par |

O último caso é o menos óbvio e o mais importante: havendo molde, a ausência de
linha é **remoção deliberada**, porque `UsuarioService::salvarPrivilegios()` cria
as linhas de todo mundo do grupo na criação do usuário e na troca de grupo. Quem
não tem é porque alguém apagou — e a propagação não desfaz isso.

`delete()` segue a mesma regra: apagar o molde leva junto só quem estava em dia.
A linha divergente não vinha do grupo, e apagar o grupo não é razão para
apagá-la.

#### O que a mudança exigiu na ordem do código

`update()` capturava o estado depois de `hydrate()`, e aí já era tarde: a
hidratação sobrescreve `ler` e `escrever` no próprio objeto, então não havia
mais como saber com o que as linhas precisavam bater. **Ler os dois campos antes
da hidratação é o que torna a preservação possível** — é uma mudança de três
linhas cuja ausência tornaria o resto inútil.

E o formulário deixa **trocar o par**: grupo e funcionalidade são listas
suspensas. Quando ele muda, isto é uma mudança e não uma alteração — não há
molde anterior no par novo, e a propagação se comporta como no `insert()`.

#### Provado por teste

`tests/Caracterizacao/PrivilegioPropagacaoTest.php`, quatro casos: a linha em dia
acompanha, a divergente sobrevive, a apagada não é recriada, e o `delete` leva
só quem estava em dia.

#### Um detalhe menor, corrigido junto

O método antigo fazia `persist()` **e `flush()` dentro do laço** — 105 gravações
separadas para uma edição de grupo, onde uma basta. O método novo acumula e faz
um `flush` só, e nenhum quando não há o que mudar.

---

## Achados de 09/09/2026 — lendo os contratos de escrita do PDV para construir a tela

Os nove abaixo saíram de um mesmo exercício: escrever a tela do PDV da Fase 3
obriga a saber, campo a campo, o que cada endpoint de escrita faz. **Um décimo
achado — o troco órfão — foi corrigido no mesmo dia** e está relatado em
`docs/f2-fatia-pdv.md`, seção 9; estes ficaram porque cada um é decisão, e não
conserto óbvio.

Nenhum é regressão. Todos existem desde antes da API nova: são guardas que a tela
velha fazia no navegador e que não subiram para o servidor quando o endpoint
nasceu. É a assimetria que o **D148** já tinha mostrado noutra área.

### D159 — `pagarDebitos` perdoa a dívida do cliente sem cobrá-la — CORRIGIDO em 09/09/2026, na API nova — CORRIGIDO no ERP em 06/10/2026, com o D380

`POST /adm/api/comandas/:id/finalizacao` aceita a bandeira `pagarDebitos`. Ela
faz `VendaPagamentoService::finalizar` (`:161-171`) quitar **todos** os débitos
abertos daquele cliente naquela unidade — e **não confere se a comanda os
cobre**.

O fluxo correto é lançar os débitos na comanda antes, por
`POST /adm/api/comandas/:id/debitos`, que soma o valor ao total
(`ComandasController:625`). Quem mandar a bandeira sem esse passo fecha uma venda
de R$ 30,00 e liquida R$ 800,00 de dívida antiga.

Não há guarda nenhuma: a bandeira é lida com `!empty()`, então qualquer valor não
vazio a liga.

**A tela nova não a oferece**, e isso está escrito no código dela — um botão
assim não entra antes da etapa que o justifica.

#### O conserto: a comanda tem de cobrar o que vai perdoar

`ComandasController::finalizacaoAction` passa a recusar com **422**, nomeando o
campo, quando `pagarDebitos` chega sem que os débitos tenham sido somados.

**A recusa mora no controller, e não no serviço** — é isso que a torna segura. O
serviço é o mesmo que a tela velha das 32 lojas usa
(`VendasPagamentosController:108`), e ela manda a bandeira a partir de um campo
do **navegador** (`mod-vendas.js:704`, preenchido em `:949`). Recusar lá
alcançaria o balcão; recusar aqui alcança só a API nova, que ainda não tem
consumidor em produção.

#### Como se confere sem coluna nova

A tabela `clientes_debitos` não tem onde registrar "qual venda quitou" — a coluna
`venda` é a que **criou** o débito. Não há, portanto, onde gravar que o passo 1
aconteceu.

Mas há um proxy exato: **o total da comanda é incremental**. Cada item soma o seu
valor e nada nunca recalcula o conjunto, então o que exceder a soma dos itens só
pode ter vindo de `adicionarDebitos`. Medido em 2026: **142.105 de 142.124**
vendas concluídas têm total exatamente igual à soma dos itens, e **nenhuma**
diverge para menos.

E o devido ainda está aberto nesse ponto — a baixa acontece depois, dentro do
serviço —, então `selecionarValorDebitosCliente()` devolve na finalização o mesmo
número que devolveu no passo 1. Os dois são comparáveis.

#### Duas hipóteses que eu testei e derrubei antes desta

Ficam registradas porque custaram medição e porque a próxima pessoa vai pensar
nas mesmas:

1. *"`total − itens > 0` marca o passo 1."* **Não marca.** Em dois anos, das
   1.137 vendas com excedente só 168 quitaram débito no dia, e 455 vendas que
   batiam exatamente quitaram. Falso positivo e falso negativo dos dois lados.
2. *"Só perdoar até `recebido − itens`."* **Recusaria demais.** Nos casos com
   marca dos dois passos, 37 de 168 seriam recusados — e não dá para separar o
   que foi quitado pela tela de devedores, que é outro caminho legítimo.

O que sobrou foi comparar o **excedente do total** com o devido, que é o que a
recusa faz.

#### Provado por curl, contra o cenário de fixtures

Cliente com R$ 50,00 em aberto, comanda de R$ 89,90 paga por inteiro:

| passo | resposta |
|---|---|
| finalizar com `pagarDebitos` sem somar | **422** `{"pagarDebitos": "os débitos não foram somados a esta comanda"}` |
| `POST …/debitos` | 200 — total vai a R$ 139,90 |
| pagar os R$ 50,00 restantes | 201 |
| finalizar com `pagarDebitos` | **200**, e o débito fica `status = 0` |

#### O que este conserto NÃO faz

Não alcança a tela velha, de propósito. E não há gate automático sobre a recusa
— nem sobre nenhuma das outras da mesma ação. Ver o **D170**.

#### A metade do ERP — CORRIGIDO em 06/10/2026, com o D380

O que este conserto deixou de fora de propósito em 09/09/2026 -- recusar no finalizar do ERP -- foi
decidido pelo dono em 06/10/2026 (pergunta 14, "Sim") e feito no item PDV-QUITAR-E-COMANDA:
`VendasPagamentosController::validarFinalizarVenda` recusa `pagarDebitos` sem a soma, com `sucesso: false`
e a frase. O risco que segurava a recusa longe do balcão acabou no D319: o "Quitar" soma o devido do
servidor, e a bandeira da tela só liga depois dele (ou, desde o D380, na comanda reaberta que já o cobre).
A conta é a deste débito, agora num lugar só (`ISVenda\Regra\RegraDosDebitosNaVenda`), e o finalizar da
API passou a usá-la sem mudar a resposta. A recusa da API ganhou caso em processo
(`D380QuitarComMarcaTest`): até aqui só o ensaio por HTTP a via (o D170).

### D160 — Receber em crédito não confere saldo, e a baixa dele não tem piso — CORRIGIDO em 10/09/2026, na API nova

Duas metades do mesmo buraco, nas formas `Crédito Cliente` (5) e
`Crédito Prog. Fidelidade` (6):

**Ao receber**, `ComandasController::adicionarPagamentoAction` (`:328-348`)
aceita qualquer valor em qualquer forma. A tela velha não aceitava:
`VendasPagamentosController` (`:306-308` e `:324-349`) somava os pagamentos já
lançados naquela forma e comparava com o saldo.

**Ao finalizar**, `VendaPagamentoService` (`:112-129`) subtrai de
`clientes_creditos.valor` e `.valor_pf` **sem piso**. O saldo do cliente fica
negativo, sem erro e sem registro de que ficou.

Enquanto isso não subir para o servidor, a API nova depende de o consumidor se
comportar — que é a definição de guarda que não guarda. A tela nova avisa, usando
o campo `saldo` de `GET /adm/api/formas-pagamento`; avisar não é impedir.

**Ver também o D161**, que é o mesmo *sem piso* noutro saldo. Ficam separados
porque a decisão é oposta: aqui o conserto é óbvio e só falta fazer; lá o dono
precisa escolher. E o **D166**, que é a outra falha do mesmo par de linhas.

#### O conserto, e ele fecha o D166 de graça

`adicionarPagamentoAction` passa a recusar com **422**, nomeando `valor`, quando
o pedido mais o já lançado naquela forma passa do disponível. É a guarda da tela
velha (`VendasPagamentosController::validarCreditosCliente`) copiada com os nomes
que a casa usa, e a comparação é **inclusiva** — pagar exatamente o saldo passa,
como lá.

**E ela torna o D166 inalcançável por este caminho.** Aquele defeito é
`selecionarCreditoCliente` terminando em `getOneOrNullResult()` e o serviço
chamando `setValor()` no resultado sem conferir: cliente sem linha de crédito
derrubava a finalização com erro fatal. Sem linha, o disponível aqui é **zero**,
e nenhum pagamento em crédito passa.

**O valor vem do corpo, e não de `Monetario::removeMask()`.** O corpo traz o
decimal do protocolo (`"89.90"`), e `removeMask` apagaria o ponto por ser
separador de milhar no formato brasileiro — 89,90 viraria 8990, e a comparação
aprovaria **cem vezes** o saldo. É a armadilha que o D49 e a regra 7 existem para
evitar, e ela mora exatamente aqui.

#### Provado no limite, por curl

Cliente com R$ 30,00 de crédito:

| pedido | resposta |
|---|---|
| R$ 20,00 | **201** |
| mais R$ 15,00 (somaria 35) | **422** — `{"valor": "disponível: 10.00"}` |
| mais R$ 10,00 (soma exata, 30) | **201** |
| cliente **sem linha de crédito** | **422** — `disponível: 0.00`, onde antes era 500 |

#### O que falta: o cenário de fixtures não alcança este caminho

**Nenhuma suíte pode exercitar esta guarda hoje.** `FormaPagamento::CREDITO_CLIENTE`
é o id **5**, e o cenário de fixtures só tem as formas 90001000 e 90001001 — a
prova acima precisou inserir a forma 5 e uma linha de crédito à mão, e removê-las
depois.

É o **D18** cobrando juro: enquanto a regra comparar id em vez de uma coluna de
tipo, todo caminho que dependa das constantes fica fora do alcance de um cenário
que numera as próprias linhas em 9000xxxx.

Duas saídas, e **a escolha é do dono** porque mexe no chão de 413 testes:

1. **Acrescentar as formas 5 e 6 ao `fixtures.sql`**, mais uma linha de crédito
   para o cliente da unidade de teste. Barato e imediato — mas põe ids de
   produção num cenário cuja convenção é a faixa 9000xxxx, e obriga a limpeza a
   conhecê-los para o arquivo continuar idempotente.
2. **Resolver o D18**: uma coluna de tipo em `formas_pagamentos`, e a regra
   passa a perguntar *"esta forma gasta crédito do cliente?"* em vez de *"o id é
   5?"*. É o certo, é mudança de schema, e schema se mexe no corte.

#### RESOLVIDO em 10/09/2026 pela saída 1, por decisão sua

> *"ponha as formas 5 e 6 no fixtures.sql"*

As duas linhas entraram com os ids **literais**, 5 e 6. Não havia escolha:
numeradas em 9000xxxx o cenário teria as *formas* e não teria a *regra* —
`ComandasController` nunca reconheceria as linhas como crédito, e tudo passaria
como se fosse dinheiro.

**A segunda metade da saída 1 não foi necessária**: a linha de crédito já
existia. O cliente 90001004 tem **100,00** em `valor` e **zero** em `valor_pf`, e
esse par desigual vale mais que dois números iguais — é o que torna o caso 16
possível.

**A limpeza conhece os dois ids, e apaga por nome**, exatamente como o cliente
avulso de id 1 já era tratado:

```sql
DELETE FROM formas_pagamentos
 WHERE id IN (5, 6)
   AND nome IN ('Credito Cliente (teste)', 'Credito Fidelidade (teste)');
```

Num banco com dump anonimizado carregado os ids 5 e 6 **já existem**, com os
nomes de verdade e com vendas de produção penduradas neles. O nome com sufixo é
o que permite ao arquivo apagar só o que ele mesmo criou — e o `WHERE NOT EXISTS`
do `INSERT` cede a vez à linha real.

**Falsificado, e não suposto.** Renomeei a forma 5 para o nome de produção no
banco de contrato e rodei a limpeza e os dois `INSERT` verbatim, lidos do próprio
arquivo com `sed`. A linha real sobreviveu aos dois, sem alteração.

#### A guarda deixou de ser verde por ausência de dado

`scripts/ensaiar-recusas-adm-api.php` ganhou quatro casos, e o ensaio foi de 16
para 20:

| # | o que monta | resposta |
|---|---|---|
| 15 | saldo 100,00, pede **150,00** na forma 5 | 422 `{"valor": "disponível: 100.00"}` |
| 16 | `valor_pf` **zero**, pede 10,00 na forma **6** | 422 — e é o único que distingue uma forma de crédito da outra |
| 17 | cliente **sem linha** de crédito, forma 5 | 422 — o D166, cercado |
| 18 | **controle**: pede exatamente 100,00 na forma 5 | **201** |

**O 18 existe porque os três antes dele não bastam.** Um ensaio só de recusas não
distingue *"a guarda funciona"* de *"a guarda recusa tudo"*: um `erroValidacao`
na primeira linha da ação deixaria 15, 16 e 17 verdes. O conferidor ganhou um
ramo `controle` para isso — `mb_stripos("", "")` devolve `false`, então um caso
que espera passar não podia reusar o critério da mensagem.

**O caso 16 foi falsificado**, e é o mais importante deles: com `valor_pf` posto
em 500,00 no banco de contrato, ele virou **201 ERRADA** enquanto o 15 seguiu
422. Isso prova que as duas linhas do mapa `$saldos` apontam para colunas
diferentes — se ambas lessem `getValor()`, os 10,00 do caso 16 caberiam nos
100,00 e ninguém veria.

#### E o ensaio ganhou o portão de cenário que lhe faltava

Os casos 15 a 18 exigem linha que só existe desde hoje. Num banco de contrato
montado antes, o pedido morre no `find()` e os quatro saem vermelhos acusando a
falta da mensagem *"não tem esse crédito disponível"* — **apontando para a guarda
quando o defeito é o banco**. Vermelho com diagnóstico errado custa mais que
vermelho nenhum: manda procurar no lugar errado.

`rodar-contrato.sh` e `CaracterizacaoTestCase` já comparavam a marca do
`fixtures.sql` com a gravada em `_fixtures_versao`; o ensaio era o único
consumidor do banco de contrato sem esse portão. Agora tem, e ele foi falsificado
nos dois sentidos: com a marca certa passa; com uma marca falsa sai com código 2
e a instrução de remontar.

### D161 — A finalização baixa estoque sem conferir saldo, e ele fica negativo — CORRIGIDO em 09/09/2026, e não como eu tinha proposto — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: decidir e aplicar em produção o destino dos negativos gravados: 119 de pac e lavavip, 147 de mercadoria (zerar, inativar ou contar os 31 parados).

`VendaPagamentoService::finalizar` (`:209-216`) percorre os itens e faz
`setQuantidade(getQuantidade() - $item->getQuantidade())` para todo produto cujo
tipo não seja `ser`. **Não há comparação com o saldo em estoque.**

Vender dois de um produto com um em estoque deixa a coluna em `-1`, sem recusa e
sem aviso. Como a baixa acontece dentro da finalização, o operador não tem como
saber antes de fechar — e depois de fechar não há desfazer pela API.

A decisão é do dono e não é óbvia: numa barbearia, recusar a venda de uma pomada
porque o estoque está desencontrado pode ser pior que registrar o negativo. O que
não pode é o sistema não dizer qual dos dois escolheu.

**Ver também o D160**, o mesmo *sem piso* no crédito do cliente. Lá o saldo é
dinheiro de alguém, e alguém reclama; aqui é contagem de coisa física, que já
nasce desencontrada — por isso um é conserto e o outro é escolha.

#### O enunciado acima estava errado em dois pontos, e a medição derrubou os dois

**Primeiro: "recusar pararia o balcão" é falso, porque a recusa já existe.** Ela
está um passo antes — `VendasProdutosController::validarQuantidadeEmEstoque`
(:133-155), chamada no **lançamento do item**, não na finalização. E não parou
nada: nos últimos 90 dias, de 310 produtos `probar` vendidos, **zero** esbarraram
nela.

**Segundo: parte dos 570 negativos não era estoque desencontrado — era conta que
nunca deveria ter existido.** A baixa testava `!= "ser"`, por omissão, e varria
para dentro do estoque duas coisas que não são mercadoria:

| tipo | negativos | movimentações em `produtos_estoque` |
|---|---:|---:|
| `lavavip` (é **serviço** no resto do sistema) | 4 de 21 | **0** em 9 anos |
| `pac` (é **pacote**, feixe de sessões) | 115 de 238 | **0** em 9 anos |

A prova é aritmética, não interpretativa: em **20 de 20** produtos `lavavip` a
`quantidade` é o negativo exato do que foi vendido, e em `pac` batem **126 de
145**. A coluna era um contador de vendas com o sinal trocado. Ninguém nunca deu
entrada em nenhum dos dois, e nenhuma tela lê a coluna para eles —
`listagemIndexProdutos` filtra `tipo IN ('probar','proemp','proins')`, e a
listagem de pacotes nem seleciona o campo.

#### O conserto, em três partes

**1. A regra ganhou nome, num lugar só.** `Produto::movimentaEstoque()` responde
quem é mercadoria: `pro`, `probar`, `proemp`, `proins`. A baixa
(`VendaPagamentoService`) e a devolução no cancelamento (`VendaService`) leem
dali — antes as duas repetiam `!= "ser"` em classes distantes.

**Os dois sítios mudaram no mesmo commit, e isso não era opcional.** Assimetria
aqui não empata: devolver estoque de um tipo que nunca foi debitado **inventa
mercadoria**. E não é hipótese — o dado já tem a digital: 18 pacotes estão com
quantidade positiva e zero movimentações no razão, e não há outro caminho para
isso senão o cancelamento somando o que a finalização não subtraiu.

**2. A API nova ganhou a guarda que o ERP já tinha.** `adicionarItemAction`
recusa com 422 o lançamento de item sem estoque — a API estava **mais frouxa**
que a tela velha, que é o mesmo defeito espelhado do que a casa costuma vigiar.
A regra é cópia exata da antiga, inclusive na parte que parece errada (ver
**D169**).

**3. A finalização continua sem piso, de propósito.** Ali o dinheiro já foi
cobrado; recusar naquele ponto transformaria estoque desencontrado numa venda
travada com o cliente na frente.

#### O que ficou de fora

Os **119** negativos de `pac` e `lavavip` já gravados continuam lá: o conserto
congela a coluna, não a limpa. Zerá-los é mexer em dado de produção, e é decisão
do dono — como os 147 negativos de mercadoria de verdade, que são estoque
desencontrado e não artefato.

E dois dos 21 `lavavip` são mercadoria mal tipada. Os dois estão **inativos**, e
há um único `lavavip` ativo em toda a base, sem venda desde 2022. Retipá-los é
conserto de dado.

#### Os 147, medidos em 11/09/2026 antes de decidir o conserto — e é UM

Por decisão sua de 11/09/2026, medi os 147 antes de tocar em nada. A frase acima
— "estoque desencontrado e não artefato" — vale para o conjunto, mas o conjunto
quase todo é história parada, e não diferença que alguém vá contar na prateleira.

**Duas perguntas separam o que dá para agir do que não dá.** A primeira é se o
estoque daquele produto foi de fato usado: produto com zero movimento em
`produtos_estoque` não tem divergência nenhuma, tem venda contando para baixo a
partir do zero — o mesmo artefato que os 119, com outro tipo. A segunda é se o
produto ainda vende.

| quanto o estoque foi usado | produtos | déficit | pior |
|---|---:|---:|---:|
| 0 movimentos — nunca usado | 54 | -741 | -424 |
| 1 a 2 — registrado e abandonado | 20 | -132 | -39 |
| 3 a 10 — uso esporádico | 41 | -465 | -94 |
| **11 ou mais — gerido de verdade** | **32** | **-851** | **-474** |

E cruzando os 32 geridos de verdade com a última venda (referência: 2026-08-16,
a última venda real da cópia):

| | produtos | déficit |
|---|---:|---:|
| parados há mais de um ano | 31 | -850 |
| **vendidos no último ano** | **1** | **-1** |

**É um produto.** O 1571, `SHAMPOO BARBA WASH 140ml`, em Florianópolis –
Ingleses: saldo **-1**, 39 entradas e 46 saídas registradas, última venda em
12/11/2025. É o único dos 147 que é ao mesmo tempo gerido de verdade e vivo — e a
diferença é de **uma unidade**.

Os outros dois que venderam no último ano ilustram por que a contagem de
movimentos importa: `8884` (Cerveja budweiser paga, Curitiba – Água Verde, -2) e
`9265` (Cera XTROM Matte Wax, Chapecó, -2) têm **uma** entrada e **zero** saídas
cada. Foram registrados uma vez e abandonados; o negativo deles é venda descendo
do zero, não prateleira a menos.

Nenhum dos 147 vendeu nos **últimos 90 dias**. A venda mais recente entre os 92
com alguma entrada é de 10/09/2024. **A divergência está congelada, não
sangrando** — o que era esperado, porque a guarda deste débito impede novos
negativos pela API, e a tela velha continua sem recusar por decisão sua (D169).

**O que isso muda na decisão:** não são 147 contagens físicas. São uma diferença
de uma unidade num produto vivo, 31 produtos ativos no catálogo que ninguém vende
há mais de um ano — higiene de catálogo antes de inventário —, e 115 produtos
cujo saldo nunca significou nada, que pedem o mesmo tratamento dos 119: zerar ou
deixar, e não contar.

**Continua decisão sua**, e segue não medido: se a contagem física vale a pena nos
31 parados, ou se o certo é inativá-los no catálogo e zerar o saldo junto. Isso
depende de saber se aquele produto ainda existe na loja, e isso nenhum número meu
responde.

Provado por `PagamentoTrocoDebitoTest::testLavavipNaoMovimentaEstoque` e
`::testPacoteNaoMovimentaEstoque`, nos dois bancos — e falsificados: com o
`!= "ser"` de volta, os dois ficam vermelhos.

### D162 — O endpoint de pagamento não tem três recusas que a tela velha tinha — CORRIGIDO em 10/09/2026 — PARCIAL, lido em 05/10/2026 — PREPARADO em 07/11/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decidir a idempotência do POST de pagamento: o que é pedido idêntico, em que janela; decisão comum às escritas de dinheiro (D285).

`POST /adm/api/comandas/:id/pagamentos` valida `formaPagamentoId` (existe) e
`valor` (decimal com ponto). Só. Faltam três recusas que existem no ERP:

| o que passa | o que a tela velha fazia | onde |
|---|---|---|
| **forma desativada** | as listas suspensas filtram `status = true` | `FormaPagamentoRepository:9-30` |
| **valor zero em qualquer forma** | zero só em Permuta e Pacote, e **obrigatório** em Pacote | `VendasPagamentosController:303, 309-311` |
| **pagamento em comanda sem item** | recusava venda sem produtos | `VendasPagamentosController:316-322` |

E **não há idempotência**: dois `POST` idênticos criam duas linhas e somam duas
vezes em `valor_recebido`. Um duplo clique cobra em dobro, e a diferença vira
troco — que a finalização depois pede para onde mandar.

#### As três entraram; a idempotência não

As três recusas são cópia da tela velha, e cada uma virou caso do ensaio
(12, 13 e 14):

| o que agora recusa | código | campo |
|---|---|---|
| pagamento em comanda sem item | **409** | — |
| valor zero fora de Permuta e Pacote | **422** | `valor` |
| forma desativada | **422** | `formaPagamentoId` |

**A regra do Pacote entrou junto**: ele é a única forma que *só* aceita zero, e
recusa com 422 quando vem valor. Isso obrigou a nomear o id 9 —
`FormaPagamento::PACOTE` —, pelo mesmo motivo dos três nomes do **D18**: a regra
já comparava esse número solto em `VendasPagamentosController:303,309-311`, e
repetir `== 9` num controller novo seria plantar hoje o que o **D124** levou
meses para arrancar.

**A recusa da forma desativada é mais estrita que o controller velho**, e não que
o sistema: lá ela não existe porque a tela nunca oferece uma forma inativa.
Nenhum fluxo que funciona hoje passa por ali, e o catálogo que esta própria API
publica (`GET /adm/api/formas-pagamento`) já só devolve as ativas — a recusa
apenas torna o endpoint coerente com o próprio catálogo.

**A idempotência ficou de fora, e é decisão.** Recusar o segundo `POST` idêntico
exige escolher o que "idêntico" significa — mesma forma e mesmo valor, dentro de
que janela? — e um duplo clique legítimo existe: dois pagamentos iguais em
dinheiro, um do cliente e um do acompanhante. A tela velha também não impede.

#### A idempotência entrou: um número por confirmação (07/11/2026, a fatia 103)

Pela resposta 87 do dono (12/10/2026, a opção (a), "Um número por confirmação": *"A tela gera um número ao confirmar e o
repete se o pedido falhar e o operador tentar de novo; o servidor responde ao número repetido o que respondeu da primeira
vez, sem gravar de novo. Os dois pagamentos de verdade são duas confirmações e passam."*). As três escritas de dinheiro da
API -- o pagamento da comanda, a compra e a devolução de crédito -- aceitam o número no cabeçalho `Idempotency-Key`, e a
tela nova o manda: o `Receber` da comanda e a fidelidade do cliente geram um número ao confirmar
(`frontend/src/api/confirmacao.ts`, 32 hexadecimais por `crypto.getRandomValues`), o repetem enquanto o pedido é o mesmo
(o corpo sem a senha) e o liberam quando dá certo. O servidor (`ISBase\Controller\EscritaConfirmada`,
`ISBase\Service\ConfirmacoesDaEscrita`, `ISBase\Regra\RegraDaConfirmacao`) grava o número numa tabela nova,
`confirmacoes` (quem, o número, a unidade, a escrita, o sha256 do pedido sem a senha, e a primeira resposta), ANTES da
escrita e na mesma transação, por um ponto de salvamento; ao número repetido devolve a primeira resposta (o mesmo código e
corpo, com `Idempotent-Replayed: true`), antes das recusas; com outro pedido, `422` `confirmacao_de_outro_pedido`; o
número que não serve, `400`. Só o que deu certo fica: a recusa volta ao ponto de salvamento sem o número, também a que vem
de dentro do serviço. Dois pedidos com o mesmo número ao mesmo tempo se enfileiram no índice único (`usuario`, `chave`): o
segundo espera e devolve a resposta do primeiro, sem gravar; se o primeiro desiste, o segundo grava. As decisões técnicas
(o cabeçalho, o 2xx que se guarda, a resposta antes das recusas, a janela que fica) estão na regra 12 de
`docs/convencao-api.md`.

**O esquema** (só acrescenta; a resposta 83 (a)): a tabela no `mysql.sql`, no `postgres/schema.sql` do gerador (com o
inquilino, o RLS ligado e forçado e a política), na carga (`scripts/migrar-dados.sh`, regerado), em
`scripts/migracao-confirmacoes.sql` no laço do passo 0 de `docs/procedimento-de-corte.md` e no do ensaio geral, e nas duas
cópias locais desde 07/11/2026 às 04:49 (o MySQL pela migração, duas vezes, o `SHOW CREATE TABLE` igual ao do `mysql.sql`;
o PostgreSQL por `postgres/migracao-confirmacoes.sql`, duas vezes, o retrato de 18 linhas igual ao do `schema.sql`) --
ensaiadas antes em quatro bancos descartáveis, apagados no fim. O `conferir-passo-zero-do-corte.php` sai 0 com ela.

**Provado** (`UmNumeroPorConfirmacaoTest`, 15 casos, e `RegraDaConfirmacaoTest`, 4, verdes no MySQL e no PostgreSQL das
fixtures): o reenvio grava uma vez e recebe a primeira resposta; dois pagamentos iguais de verdade passam; sem número, como
antes; o número com outro pedido e o que não serve são recusados sem gravar; a recusa (de fora e de dentro da escrita) não
guarda o número, e a nova tentativa grava; a resposta repetida vem antes do `409` da comanda já finalizada; o número é de
quem o mandou; sem a tabela, a escrita segue sem ele; a compra repetida cria uma venda só; a devolução repetida, com a
senha digitada de novo, sai uma vez e a senha não fica no registro; e a CORRIDA, com outro processo segurando o mesmo
número 1,5 s (o pedido daqui espera no índice e devolve a resposta do outro sem lançar nada; e, se o outro desiste,
grava). Pelo HTTP, os casos 239 a 242 do `ensaiar-recusas-adm-api.php`, nos dois bancos de contrato. Na tela, 9 casos
(`confirmacao.teste.ts`, o `Receber` e a fidelidade com a rede caída). Mutantes, cada um sozinho e devolvido pelo sha1,
vermelhos nos dois bancos: a porta sem a conferência do número (as três); o serviço guardando a recusa; o serviço sem a
chave repetida (a corrida cai); a resposta guardada sem conferir o pedido; a senha no que identifica o pedido; e, na
tela, sete (o número não mandado, não repetido, não liberado, o mesmo para outro pedido, o cabeçalho que não sai).

**O que fica:** a tabela em PRODUÇÃO, no passo 0 do corte (até lá, lá, o número é ignorado e as escritas gravam como
antes, sem erro); quanto tempo guardar as linhas (o **D735**, a pergunta ao dono); os nomes do cliente e do operador
dentro das respostas guardadas, que a anonimização da cópia não limpa (o **D736**, a pergunta ao dono); e o ERP antigo,
que não manda número. **O recorte na tela nova NÃO é do dono:** a resposta 87 (a) diz só "a tela gera um número ao
confirmar", e o contexto da pergunta põe a tela antiga dentro do problema ("a tela antiga também não impede"); quem
limitou a fatia à tela nova foram a triagem de 07/11/2026 e as notas da fatia ("A tela nova gera o número"). A pergunta
foi ao lote: "TELAS-RESTANTES [vendas] (07/11/2026) -- O número da confirmação também no ERP antigo? (D162, D285)". Até a
resposta, pelo ERP antigo, o pagamento do PDV, a compra e a devolução de crédito repetidos continuam gravando duas vezes (a
quitação, não: o `ja_quitado` sob a trava, desde 26/09/2026).

**A correção da verificação (07/11/2026)** está no D285, que tem o corpo comum às escritas de dinheiro: a quitação do
fiado entrou como a quarta, e os casos da ordem na compra e na devolução.

### D163 — Tirar item e estornar pagamento alcançam venda já fechada — CORRIGIDO em 10/09/2026

`removerItemAction` e `removerPagamentoAction` **não leem o estado da comanda**.
Os vizinhos leem: `adicionarItemAction` (`:204`) e `adicionarPagamentoAction`
recusam com 409 quando `status !== 3`.

Ou seja: `DELETE /adm/api/comandas/:id/itens/:item` numa venda **concluída**
altera o total de uma venda fechada, e ainda apaga os pagamentos dela — mexendo
num número que já entrou no caixa e no relatório.

A tela nova não oferece os botões fora do estado `aberta`, mas isso é cortesia do
consumidor, não guarda do servidor. Contar com a guarda do vizinho é como não ter
guarda.

#### O conserto

As duas ações passam a ler o estado, no mesmo ponto e com as mesmas palavras das
três irmãs: **409**, *"Só é possível remover itens de uma comanda aberta."* e
*"Só é possível estornar pagamento de uma comanda aberta."*

Provado por curl numa comanda finalizada, e virou os casos 15 e 16 do ensaio de
recusas — que por isso deixou de ser só da finalização e passou a cobrir a
escrita da comanda inteira. **16 de 16.**

### D164 — Fechar comanda sem caixa aberto quebra com 500 em vez de recusar — CORRIGIDO em 10/09/2026, na API nova

`VendaPagamentoService::finalizar` (`:131-132`) faz
`$venda->setCaixa(...selecionarPorUsuario(...))` e em seguida
`$venda->setUsuario($venda->getCaixa()->getUsuario())`. Sem caixa aberto, o
primeiro grava nulo e o segundo chama método em nulo.

O erro do caixa de **outro dia** tem recusa própria e correta (409, com a data,
do **D28**). O de caixa **nenhum** não tem: vira erro fatal.

É recusa que falta, e não conserto de cálculo — 409 com a mensagem certa,
espelhando o irmão.

#### O conserto

`finalizarAction` passa a perguntar primeiro se **há** caixa, e só depois se ele
é de hoje. As duas se excluem — sem caixa, `caixaDeDataAnterior` devolve nulo e
não recusaria nada —, mas a ordem faz a ação ler como se pensa: primeiro a
existência, depois a data.

Como no **D159**, a recusa mora no **controller**, e não no serviço: o serviço é
o mesmo que a tela velha das 32 lojas usa.

Provado pelo caso 8 de `ensaiar-recusas-adm-api.php`, e **falsificado**: com a
guarda arrancada, o mesmo cenário responde **500 (sem erro no corpo)** — o erro
fatal que motivou este débito. O ensaio nomeia as três afirmações quebradas e sai
com 1.

### D165 — A autorização de fiado sobrevive ao fiado que a motivou — CORRIGIDO na API nova em 10/09/2026 — PARCIAL, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: a tela velha (`VendasPagamentosController:216-217`) grava a autorização e nada a limpa; fechá-la é decisão sua, e o tamanho se mede no servidor.

`ComandasController` grava `setAutorizadoPor` e `setAutorizadoEm` (`:477-481`) e
**nada nunca limpa esses dois campos**.

O `fiado` da resposta é derivado (`recebido < total`), então ele some sozinho
quando entra pagamento que cubra tudo — mas a autorização continua gravada. Se
depois disso alguém lançar mais um item, os pagamentos são apagados
(`VendaProdutoService`), a comanda volta a ser fiada, e volta **já autorizada**,
sem que ninguém tenha autorizado o valor novo.

É o segundo tempo do **D86** com uma porta lateral: a autorização vale para um
valor, e passa a valer para outro maior.

**Ver também o D86**, a outra metade. Lá a autorização não existia de verdade —
era aviso no navegador, e o servidor não exigia registro. Aqui ela existe demais.
Um se conserta exigindo o registro, o que a API nova já faz; o outro limpando os
dois campos quando o fiado deixa de existir.

#### CORRIGIDO na API nova em 10/09/2026 — e não era preciso tocar no serviço

O registro dizia que o conserto era *"limpando os dois campos quando o fiado
deixa de existir"*, e isso parecia exigir mexer em `VendaProdutoService`, que as
32 lojas usam. **Não exigia.** A limpeza cabe no controller da API nova, porque é
ele que decide o que fazer depois de o serviço mexer no dinheiro.

`Venda::limparAutorizacao()` anula os dois campos. O método precisou existir
porque **não havia como zerá-los**: `setAutorizadoEm()` não aceita argumento, de
propósito, para ninguém poder plantar a hora de fora. A ausência de uma porta de
saída *era* o defeito.

**Os dois juntos, e nunca um só:** `fiadoSemAutorizacao()` lê apenas
`autorizadoPor`, então limpar só o instante não desautorizaria nada — e deixaria
um rastro que mente, autorizado por alguém em instante nenhum.

#### A pergunta que decide onde chamar: "isto pode AUMENTAR o fiado?"

Quatro ações mexem no dinheiro da comanda, e **três** limpam:

| ação | efeito | aumenta o fiado? |
|---|---|---|
| acrescentar item | total sobe **e** os pagamentos são apagados | **sim** |
| remover item | total cai, mas os pagamentos são apagados | **sim** |
| estornar pagamento | recebido cai | **sim** |
| lançar pagamento | recebido sobe, total igual | **não** |

**Remover item parece inofensivo e não é:** total 100 com 90 recebido dá 10 de
fiado; tire um item de 20 e sobra total 80 com recebido **zero** — 80 de fiado,
oito vezes o autorizado.

E **lançar pagamento fica de fora de propósito**.
`VendaPagamentoService::adicionar` soma ao recebido e não toca no total, e valor
zero só passa em Permuta e Pacote: o fiado nunca sobe. Limpar ali obrigaria o
gerente a autorizar de novo uma conta que *diminuiu* — e recusa que atrapalha sem
proteger ensina a contornar a recusa.

*08/10/2026, a verificação do item PDV-QUITAR-SEGUNDA-RODADA:* a tabela tinha uma quinta ação que aumenta o
fiado e não limpava -- **somar os débitos** (`POST /adm/api/comandas/:id/debitos`): o total sobe pelo
devido, e o fiado de 50,00 autorizado virava o de 100,00, ainda autorizado. `adicionarDebitosAction` passou
a chamar `desautorizarFiado` quando soma (e não quando o total já cobre, e não há o que somar), e
`D380QuitarComMarcaTest` tem os dois casos (`testSomarOsDebitosPelaApiRevogaAAutorizacaoDoFiado` e o
controle). A API nova é a única que tinha o caminho; o "Quitar" do ERP é a mesma escrita na tela velha, e
fica com o resto dela, abaixo.

#### O portão, e a extensão que ele exigiu

O **caso 23** de `ensaiar-recusas-adm-api.php` é o primeiro que precisa de um
pedido **bem-sucedido** antes do medido, porque a recusa só existe depois de a API
aceitar um lançamento. Escrever esse efeito no banco à mão mediria a minha
suposição sobre o controller, e não o controller — então o laço ganhou um passo
`preparar`, e a falha dele **interrompe** o ensaio em vez de virar um vermelho no
caso, que apontaria para o lugar errado.

O caminho que o caso monta:

1. comanda de 50,00 **paga por inteiro** e com autorização gravada — o estado
   real de quem foi fiado, teve a autorização do gerente e depois pagou;
2. o preparo lança um serviço de 30,00 → os pagamentos são apagados, o total vai
   a 80,00 e o recebido a **zero**: fiada outra vez, por 80,00;
3. a finalização tem de recusar, com o 409 do D86.

**Falsificado:** removida a chamada do lançamento de item, o caso 23 responde
**200** — a venda fecha com 80,00 de crédito concedido que ninguém concedeu.

É o que separa este caso do caso 7: lá o fiado nunca foi autorizado; aqui foi,
para outro valor.

#### O que continua em aberto

A tela velha. `VendasPagamentosController:216-217` grava a autorização do mesmo
jeito e nada a limpa — e limpar no serviço compartilhado mudaria o PDV das 32
unidades. É o mesmo recorte do **D86**, que a própria entidade escreve: a API nova
não tem consumidor, então pode exigir desde o primeiro dia.

**Fechar a tela velha é decisão sua.** E o tamanho foi medido — com um resultado
que **não serve para dimensionar nada**, e isso vale dito:

| medida em `franquiabv_dev` | |
|---|---|
| vendas com `autorizado_por` preenchido | **0** |
| venda mais recente da cópia | **2026-08-16** |
| o registro na tela velha nasceu em | **02/09/2026** (D86) |

A coluna nasceu **dezessete dias depois** do último dado desta cópia, e a
anonimização não a toca — conferido em `scripts/anonimizar.sql.modelo`. O zero é a
idade do dump, e não o desuso da função: **não se conclui nada sobre produção a
partir dele**. Quem for fechar a tela velha precisa medir no servidor, e não aqui.

Há ainda uma segunda razão para o zero ser pequeno mesmo em produção:
`VendasPagamentosController:213` só registra quando o pedido traz `venda_id`, e o
navegador do operador pode estar servindo o script anterior em cache. A
autorização é concedida de qualquer jeito — de propósito, para não derrubar o PDV
das 32 unidades —, e nesse caso não fica registrada.

### D166 — Cliente sem linha de saldo derruba a finalização em crédito — CORRIGIDO em 03/10/2026, no serviço e nas duas portas (o D160 só fechou o lançamento pela API; a comanda herdada ainda chegava)

`ClienteCreditoRepository::selecionarCreditoCliente` termina em
`getOneOrNullResult()` — devolve **nulo** quando o par (cliente, unidade) não tem
linha. `VendaPagamentoService:118` chama `$credioAtual->setValor(...)` sem
conferir.

Então: cliente sem linha em `clientes_creditos` daquela unidade + pagamento na
forma `Crédito Cliente` = erro fatal ao finalizar, com a venda já parcialmente
escrita.

Lido no código, **não reproduzido** quando foi registrado: o cenário de fixtures
não tem forma de id 5, e as constantes do **D18** comparam id.

#### Fechado pelo conserto do D160, e sem tocar nesta linha

A guarda nova de `adicionarPagamentoAction` recusa o pagamento quando o
disponível não cobre o pedido — e **cliente sem linha de crédito tem disponível
zero**. Nenhum pagamento em crédito passa, então a linha 118 do serviço deixa de
ser alcançável por este caminho.

Provado por curl: cliente sem linha responde **422** com `disponível: 0.00`, onde
antes a finalização quebrava com 500 e a venda já parcialmente escrita.

**A linha continua desprotegida em si.** `VendaPagamentoService:118` ainda chama
`setValor()` sem conferir nulo, e a tela velha continua podendo alcançá-la — ela
tem a própria guarda no navegador, que é do lado errado. Fechar a linha exige
mexer no serviço que as 32 lojas usam, e por isso não foi feito de passagem.

**A cerca ganhou portão em 10/09/2026.** A frase acima — *"lido no código, não
reproduzido: o cenário de fixtures não tem forma de id 5"* — deixou de valer: as
formas 5 e 6 entraram no `fixtures.sql` (ver o D160), e o **caso 17** de
`ensaiar-recusas-adm-api.php` monta exatamente este estado: comanda do cliente
90001000, que não tem linha em `clientes_creditos`, pagando na forma 5. Sai 422
com `valor` em `campos`. Enquanto esse caso for verde, a linha 118 continua
inalcançável por este caminho — e no dia em que alguém remover a guarda, é ele
que acusa, e não um 500 em produção.

#### CORRIGIDO em 03/10/2026: a linha do serviço, e o caminho pela tela que ninguém tinha visto

**O conserto.** `VendaPagamentoService::creditoSemLinha()` pergunta se a venda tem pagamento na forma
5 ou 6 e se o cliente NÃO tem a linha do par (cliente, unidade da venda) -- a mesma linha que o
`finalizar` debita logo abaixo. O `finalizar` recusa (`false`) antes de qualquer escrita, logo depois
do caixa de outro dia (o D28), e as duas portas dizem por quê, perguntando ao mesmo método: a tela
velha, em `validarFinalizarVenda`, com *"O cliente não tem crédito nesta unidade para o pagamento em
crédito desta venda. Remova esse pagamento e escolha outra forma."*; a API nova, com 409 e a mesma
frase para a comanda, antes de chamar o serviço, como o caixa. O saldo não é conferido aqui: a baixa
sem piso é o D160, e quem a recusa é o lançamento do pagamento.

**A frase acima -- "a linha 118 continua inalcançável por este caminho" -- era verdade só pela
metade.** Havia um caminho pela própria tela, sem POST escrito à mão: `VendaService::comandaAbrir`
reaproveita a venda em andamento do operador para a comanda de OUTRO cliente, trocando o cliente e
deixando os pagamentos. O pagamento em crédito, conferido no lançamento para o primeiro cliente,
chegava ao finalizar do segundo -- e é por aí que a API também chegava, porque a guarda do D160
confere o recebimento, e este pagamento entrou antes. Provado em processo, nas duas portas. O que
mais esse reaproveitamento carrega é o **D384**.

**Provado:** `D166CreditoSemLinhaTest`, 6 casos verdes no MySQL e no PostgreSQL das fixtures. Contra
o código de antes, os **cinco** casos de recusa morrem em *"Call to a member function setValor() on
null"* (e `setValorPf()`, na forma da fidelidade); o controle -- o cliente com R$ 100,00 de linha paga
R$ 50,00 e fica com R$ 50,00 -- passava e continua passando. **Os dois da comanda herdada entraram
depois da primeira rodada contra o código antigo** (que tinha 4 casos, e não os rodou), e a
verificação do conserto os rodou à parte, no mesmo dia: os dois controllers como no HEAD e o serviço
sem a recusa do `finalizar`, nos dois bancos -- 5 erros, os cinco em `VendaPagamentoService:144`/`151`
(a comanda pela tela via `VendasPagamentosController:126`, pela API via `ComandasController:1080`), e o
controle verde. Cada recusa compara o retrato inteiro (a venda,
as linhas de crédito do cliente, o caixa, os débitos, os pontos e os cupons) antes e depois. Quatro
mutantes, cada um vermelho: a recusa do serviço arrancada (os 2 casos do serviço), a frase da tela
velha arrancada (os 2 da tela), a recusa da API arrancada (o caso da API recebe o 409 genérico) e a
pergunta invertida (os 6, o controle incluído).

*Nota de 06/10/2026 (o D384).* `VendaService::comandaAbrir` passou a recusar a comanda de outro cliente
enquanto a venda em andamento tiver pagamento, e o caminho pela tela que montava a comanda herdada fechou.
Os dois casos dela no `D166CreditoSemLinhaTest` passaram a gravá-la como `comandaAbrir` gravava (o cliente
trocado e o `status` 3): é o estado das que já existem, e a guarda deste débito continua cobrindo-as.

### D167 — Sobraram linhas de fixture dentro da cópia anonimizada de produção — RÉGUAS FILTRADAS e PORTÃO POSTO em 11/09/2026; limpar a faixa continua decisão — PARCIAL, lido em 05/10/2026 — ANOTADO em 12/10/2026: nenhuma limpeza da cópia agora (a resposta 84)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: restam 54 das 117 linhas de fixture, em 22 tabelas da cópia, e o AUTO_INCREMENT envenenado em 49 das 84 tabelas, que nenhuma saída conserta.

Medido em 09/09/2026 em `franquiabv_dev`: **5 vendas, 6 clientes, 3 unidades e 2
formas de pagamento** com id `>= 90000000` — a faixa reservada às fixtures.

O banco é a cópia anonimizada de produção, e é sobre ele que rodam as medições
que dimensionam as fases. Linha de fixture ali é ruído numa régua: uma contagem
de "vendas em produção" passa a incluir vendas que produção nunca teve.

A contaminação é pequena e não invalidou as medições de hoje — foi conferido caso
a caso que as sete vendas com troco defasado eram **todas** reais, de 2018 a 2023,
nenhuma na faixa de fixture. Mas a próxima medição não vai lembrar de conferir.

Duas saídas: limpar a faixa `>= 90000000` de `franquiabv_dev`, ou fazer os
roteiros de medição excluí-la por padrão. A segunda é mais barata e sobrevive à
próxima carga.

#### Em 11/09/2026: a contagem estava subestimada, e o erro não era proporcional

Ao tomar o caminho barato, medi de novo — e os dois números deste débito estavam
errados para baixo.

**A contaminação é de 117 linhas em 23 tabelas**, e não de 16 em 4. A medição de
09/09 olhou quatro tabelas; varrendo **toda** coluna `id` do esquema:

| tabela | linhas na faixa | |
|---|---:|---|
| `funcionalidades` | 63 | **metade da tabela** — 63 de 126 |
| `clientes`, `vendas` | 6 cada | a de vendas eram 5 dois dias antes |
| `produtos` | 5 | |
| `usuarios` | 4 | |
| `agendas`, `caixas`, `unidades` | 3 cada | |
| 9 tabelas | 2 cada | créditos, cupons, formas, grupos, indicações… |
| 6 tabelas | 1 cada | banners, bairros, cidades, estados… |

As vendas de fixture passaram de **5 para 6** entre 09/09 e 11/09: a venda
`90007000` nasceu em 09/09/2026 às 23:52, depois da medição. A contaminação
cresce sozinha, e era exatamente isso que a frase *"a próxima medição não vai
lembrar de conferir"* previa.

**E o erro não é proporcional ao tamanho da contaminação.** Este débito descreveu
o risco como "ruído numa régua" — contar algumas linhas a mais. Contar a mais dá
erro proporcional, e 6 vendas em meio milhão não movem nada. **Ancorar uma janela
de tempo num `MAX(data_criacao)` é outra coisa:** uma linha com data à frente
desloca a janela inteira.

Foi o que estava acontecendo em `medir-comandas-abandonadas.sql`. A venda
`90007000` (2026-09-09) ancorava os "últimos 12 meses" **24 dias à frente** da
última venda real (2026-08-16):

| | comandas abandonadas, 12 meses |
|---|---:|
| como o roteiro imprimia | 3.155 |
| com a âncora em dado real | **3.332** |
| diferença | **177 (5,3%)** |

Cinco por cento de erro numa régua, causados por **uma** linha entre meio milhão.

#### O caminho barato, feito — e um portão para a próxima

**Conferido arquivo por arquivo qual roteiro mede o quê**, porque filtro que
protege contra nada é ruído. Dos 15 roteiros de medição, os que declaram
`franquia_producao` ou a réplica do BVIP rodam onde linha de fixture **não
existe** — e dois deles já avisam, no cabeçalho, que a cópia anonimizada não pode
responder a pergunta que eles fazem: `medir-telefones-compartilhados.sql` porque
*"a anonimização deriva o telefone de `MOD(id, 10000)`"* (medido: 239.232
clientes ativos dividem 10.003 telefones), e `medir-senhas-producao.sql` porque a
anonimização faz `salt = NULL` e troca a senha por um hash fixo. Nesses, o filtro
não entra.

Sobravam dois lugares sobre a cópia, e os dois foram corrigidos:

- **`medir-comandas-abandonadas.sql`** — três consultas, e a âncora. É a que
  estava errada por 5,3%.
- **`medir-exposicao-parada.sql`** — a sexta consulta, sobre `agendas`. As quatro
  âncoras sobre `vendas` no mesmo arquivo já excluíam a faixa; esta não. Hoje não
  muda número (as 3 agendas de fixture são de 2026-01-01, antes do máximo real),
  mas é a mesma forma que custou os 5,3%.

**E o portão, que é a parte que lembra:**
`scripts/conferir-ancora-de-medicao.php` reprova quando um `.sql` que mede a
cópia ancora uma janela num `MAX()` sem excluir a faixa. Âncora, para ele, é
`(SELECT` e `MAX(` na mesma linha — o que distingue as 6 âncoras reais das 5
agregações de projeção no mesmo arquivo. Falsificado: tirei o filtro da âncora de
`agendas` e ele apontou a linha exata. Roda em `conferir-tudo.sh` e no CI.

O que ele **não** vê está escrito nele: âncora em `MIN(`, variável calculada
linhas antes, consulta montada em string num `.php`. Portão textual erra assim; o
que ele garante é que a forma que já produziu 5,3% não volte em silêncio.

#### O que continua decisão sua

**Limpar a faixa `>= 90000000` de `franquiabv_dev`** — a primeira saída. Apagar
117 linhas em 23 tabelas é operação destrutiva num banco que ninguém remonta, e
as fixtures foram carregadas ali de propósito em algum momento: a suíte de
caracterização precisa delas para rodar contra dado real. Limpar significa
escolher rodar a caracterização só nos bancos montados (`franquiabv_cilocal`,
`franquiabv_pgtestes`) e perder a execução sobre a cópia.

Enquanto essa decisão não vier, as réguas estão filtradas e o portão vigia as
novas — que era o objetivo do caminho barato.

#### Em 15/09/2026: 63 das 117 linhas têm causa e consequência com endereço

Medindo `funcionalidades` para o D29, a faixa apareceu de perto: são **126 linhas
para 63 nomes**, e os 63 grupos duplicados são todos mistos — uma linha de
produção (ids 1..68) e uma de fixture (ids 90002000..90002062), um para um, byte
a byte. A faixa de produção sozinha: 63 linhas, 63 nomes distintos, **zero**
duplicatas.

**A consequência não é estética.** `ISConfiguracao\Permissions\Acl` registra cada
funcionalidade como recurso da ACL **pelo nome**, e o Laminas lança na segunda
(`Resource id '<nome>' already exists in the ACL`), sem `try`/`catch` em nenhum
ponto do login. Alimentei a biblioteca com os 126 nomes reais: estourou no 64º.
Com os 63 de produção, montou inteira. **É por isso que não se entra no ERP
apontado para o `franquiabv_dev`** — e o erro não diz "duplicata", diz exceção
não tratada.

**A causa é uma exceção à convenção do próprio arquivo de fixtures.** Ele diz,
na linha 295, sobre `grupos`: *"Condicional, como o cliente 1 e a área 3: quando
as fixtures entram num banco que já tem produção, a linha real fica."* São nove
`WHERE NOT EXISTS` no arquivo. Mas o bloco de `funcionalidades` (linha 208)
insere os 63 nomes **incondicionalmente**, precedido de um `DELETE FROM
funcionalidades WHERE id >= 90001000` que jamais alcança as linhas de produção.

Isso abre uma **segunda saída**, que não existia quando este débito foi escrito e
que não é destrutiva: em vez de apagar linhas do banco, fazer a fixture parar de
duplicar. O custo está medido e não é trivial — a faixa `90002xxx` está amarrada
em cinco lugares fora do `fixtures.sql`
(`scripts/ensaiar-recusas-adm-api.php:222`, `scripts/medir-telas-no-postgres.sh`
97 e 128, `.github/workflows/ci.yml:647` e
`tests/Caracterizacao/PrivilegioPropagacaoTest.php:44`), e reusar os ids de
produção faz os cinco deixarem de achar o que procuram.

As duas saídas continuam sendo decisão sua. O que mudou é que agora há duas, e as
63 linhas têm causa nomeada.

#### A segunda saída foi tomada no mesmo dia — 63 das 117 já saíram

Decisão do dono, em 15/09/2026: consertar a fixture, e não apagar linhas. O bloco
de `funcionalidades` passou a ceder pelo **nome**, mantendo os ids `90002xxx`
onde não há produção — o detalhe que torna a saída barata, e que o D29 tinha
descartado por uma premissa falsa (ver a correção lá).

O `DELETE FROM funcionalidades WHERE id >= 90001000` que já existia na linha 206
fez o resto: **a recarga do arquivo limpou a cópia sozinha**, nos dois bancos.
De 126 linhas para 63, nos dois; zero na faixa de cenário; os 15.495 privilégios
de produção intactos. Nenhuma operação destrutiva foi inventada para isso — a que
limpou já estava escrita no arquivo, apontando para a faixa certa.

**Restam 54 das 117**, em 22 tabelas. E o ganho não foi só de contagem: o ERP
voltou a montar a ACL, o PostgreSQL da cópia deixou de divergir do MySQL nas
concessões do gerente de fixture (eram 7 contra 8), e `funcionalidades.nome`
pôde finalmente ganhar restrição de unicidade no schema.

#### O que este débito ganhou de trabalho novo, e não está feito

**O contador `AUTO_INCREMENT` está envenenado em 49 das 84 tabelas** da cópia —
`clientes` em 90009713, `agendas` em 90010092, `vendas` acima de 990000000.
Qualquer linha que o ERP criar nessa cópia **nasce dentro da faixa de cenário**,
e a próxima carga do `fixtures.sql` a apaga como se fosse fixture. Apagar linhas
não abaixa o contador: o InnoDB 8.0 o persiste, e só um `ALTER TABLE …
AUTO_INCREMENT=` o baixa.

Isto não estava registrado em lugar nenhum, nenhuma das saídas discutidas o
conserta, e ele vale para as 22 tabelas que ainda têm cenário dentro — mais as
que já foram limpas e ficaram com o contador lá em cima.

**Decidido pelo dono em 12/10/2026 (a resposta 84, opção c, e não a a recomendada):** nenhuma limpeza da cópia local
agora -- nem as 54 linhas de teste em 22 tabelas, nem a faixa dos ids das fixtures nos `AUTO_INCREMENT`. As réguas
seguem filtradas e o portão (`conferir-ancora-de-medicao.php`) vigia as novas; o que nasce na cópia continua nascendo na
faixa que as medidas descartam. Os testes não rodam sobre a cópia desde 21/09/2026. Adiado por decisão, e não esquecido.

### D168 — O PDV precisa de dois catálogos que o operador de caixa não pode ler — CORRIGIDO em 09/09/2026

Achado em 09/09/2026, abrindo a tela nova no navegador — e não lendo código. É a
diferença que importa: o endpoint respondia 403 desde sempre, e nenhuma medição
tinha reparado porque todas entravam com um usuário que podia.

A tela do PDV monta duas listas suspensas — **o que foi vendido** e **por quem**
— a partir de `GET /adm/api/produtos` e `GET /adm/api/usuarios`. As duas são
guardadas pelo módulo `configuracoes`:

- `ProdutosController::indexAction` exige `configuracoes`/`produtos`/`ler`;
- `UsuariosController::indexAction` exige `configuracoes`/`usuarios`/`ler`.

**Quem opera o PDV não tem esses privilégios.** Medido em `franquiabv_dev`,
entre os usuários ativos com `ler` em `Comandas nova`:

| grupo | operam o PDV | veem produtos | veem usuários |
|---|---:|---:|---:|
| Gerente unidade | 104 | 103 | 103 |
| **Colaborador caixa** | **60** | **6** | **0** |
| **Líder** | **4** | **0** | **0** |
| Gerente | 2 | 2 | 2 |

Ou seja: **os 60 do caixa não enxergam colaborador nenhum**, e 54 deles não
enxergam produto nenhum. São exatamente as pessoas para quem a tela existe.

O cenário de fixtures modela isso corretamente — `gerente.teste` tem
`Comandas nova` e não tem os dois de configuração —, e foi por ele que o defeito
apareceu: as duas listas vieram vazias na tela.

#### A tela velha não passa por essa guarda

`VendaIndex.php:17-19` monta as mesmas listas chamando `popularCombobox()`
direto, **sem conferência de ACL nenhuma**. A guarda ali é a própria tela do
PDV. Então o dado já é visível a essas pessoas hoje; o que está estrito demais é
a cópia em API.

#### Mas copiar a guarda velha vazaria mais do que a tela velha mostra

`UsuarioJson::daListagem` devolve `email`, `grupo.nome`, `imagem`,
`totalAcessos`, `ultimoAcesso` e `nps`. A lista suspensa da tela velha mostra
**nome**. Abrir `/adm/api/usuarios` para o caixa não seria reproduzir o que ele
já vê: seria dar-lhe o e-mail, a contagem de acessos e a nota de NPS dos
colegas.

#### A decisão do dono: **alargar as guardas**

Havia duas saídas — endereços próprios para o PDV, ou alargar os que existem.
O dono escolheu a segunda, em 09/09/2026, para não abrir um terceiro e um quarto
endereço para o mesmo assunto.

**As duas listagens passaram a aceitar duas portas.** `configuracoes/<a sua>/ler`
continua valendo, e agora `vendas/comandasNova/ler` também abre:

```php
if (!$this->getAcesso('ler') && !$this->getAcessoDe('vendas', 'comandasNova', 'ler')) {
```

**A escrita não mudou.** Só as duas ações de listagem foram alargadas: quem lança
item pode ver o que se vende e quem atende; não pode cadastrar produto nem
usuário.

#### Quem entra pela porta do PDV vê MENOS

Alargar a guarda sem encolher o recurso teria dado a 64 pessoas o e-mail, a
contagem de acessos e a nota de NPS dos colegas — o que a tela velha nunca deu.
Então `UsuarioJson` ganhou uma segunda forma, e o controller escolhe:

| quem pede | o que recebe |
|---|---|
| `configuracoes/usuarios/ler` | `daListagem()` — id, nome, e-mail, grupo, imagem, totalAcessos, ultimoAcesso, nps |
| só `vendas/comandasNova/ler` | `daListagemMinima()` — **id, nome, estado** |

O catálogo de produtos não precisou encolher: ele já devolve o que a tela de
configuração devolve, e nada ali é dado pessoal.

#### E o roteiro que cobra recusa passou a olhar

`ensaiar-permissao-adm-api.php` entra com um usuário sem privilégio nenhum e
exige 403 em toda rota. Ele visitava 36 endereços e **nenhum deles era
catálogo** — nem produtos, nem usuários, nem o `formas-pagamento` criado no
mesmo dia. Alargar guarda sem pôr a rota no instrumento que cobra recusa é
alargar no escuro.

Os três entraram. Medido depois da mudança: **39 de 39 recusadas**, com os três
novos entre elas.

#### E a tela deixou de engolir o erro

Onde havia uma lista suspensa vazia e silenciosa, passou a se ler *"sem permissão
para ver produtos — fale com o gerente."* O conserto da guarda tornou a mensagem
desnecessária neste caso, mas ela fica: a próxima falha de catálogo — sessão
caída, parâmetro errado, rede fora — não vai mais parecer *a barbearia não tem
produtos*.

**Relido em 20/10/2026 (a correção da fatia 20 da fila das telas que faltam,
ETIQUETAS-SEM-ETIQUETA).** A lista ganhou o filtro `etiqueta=sem|<id>`, e a segunda
porta o recebia: quem só tem `vendas/comandasNova/ler` não vê a etiqueta de serviço
nenhum (o `verAction` a guarda com o ler), mas, filtrando por cada número, saberia
como os serviços da unidade se agrupam. O filtro passou a ser só da primeira porta:
pela segunda, é 403, antes da lista fechada (`CatalogoPorEtiquetaPelaApiTest::
testPelaPortaDoPdvOFiltroE403`, nos dois bancos). A lista sem filtro continua
aberta ao PDV, como este débito decidiu.

---

### D169 — O sistema tem três respostas diferentes para "o que tem estoque" — MEDIDO em 10/09/2026, e fechado na API nova — FECHADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como medido, e o corpo diz que ele está fechado, sem nada a fazer. O corpo: "Feito o item 2, e não o item 1. O efeito é exatamente o que a decisão diz".

Apareceu ao consertar o **D161**, e o conserto mexeu em uma só das três. Ficam
registradas juntas porque separadas ninguém percebe que discordam:

| onde | o que decide | tipos |
|---|---|---|
| `Produto::movimentaEstoque()` | quem **movimenta** a coluna, na venda e no cancelamento | `pro`, `probar`, `proemp`, `proins` |
| `VendasProdutosController:136` | quem é **recusado** por falta de estoque ao lançar item | `pro`, `probar`, `proemp` |
| `ProdutoRepository::listagemIndexProdutos` | quem **aparece** na tela de estoque | `probar`, `proemp`, `proins` |

Cada lista é diferente das outras duas, e as diferenças têm consequência:

- **`proins` movimenta estoque e nunca é recusado.** São 4 produtos na base. Um
  insumo pode ir a negativo sem que nada avise, porque a guarda de lançamento não
  o vê.
- **`pro` é recusado e listado por ninguém** — não há uma única linha com esse
  tipo no banco. É tipo morto, herdado.

O D161 **copiou** a segunda lista para a API nova em vez de unificar, e a decisão
foi deliberada: a regra da casa é que a API nova não pode ficar mais estrita que
o ERP, e unificar mudaria comportamento de produção de passagem. Unificar as três
é decisão do dono, e a pergunta é qual das três está certa.

#### A pergunta tinha resposta, e ela estava na data dos produtos — 10/09/2026

**Não são três listas divergentes. É uma mudança feita pela metade em 2024.**

Medido em `franquiabv_dev`, a data do produto mais antigo de cada tipo:

| tipo | primeiro produto | produtos | negativos |
|---|---|---|---|
| `ser` | **2016-05-29** | 3477 | 0 |
| `probar` | **2016-05-29** | 2345 | 184 |
| `proemp` | **2016-05-29** | 3422 | 266 |
| `lavavip` | 2018-05-18 | 21 | 4 |
| `pac` | 2022-11-27 | 238 | 115 |
| `proins` | **2024-04-17** | 4 | 1 |
| `pro` | *nunca existiu* | **0** | — |

`probar` e `proemp` nasceram no **primeiro dia da base**. `proins` é **oito anos
mais novo**. E quem o criou em 2024 ensinou o sistema sobre ele em **seis**
lugares e esqueceu **três**:

| aprendeu em 2024 | ficou em 2016 |
|---|---|
| a baixa da venda (`TIPOS_COM_ESTOQUE`) | a recusa por falta de estoque (`VendasProdutosController:136`) |
| a devolução no cancelamento | a coluna de saldo na tela (`produtos/index.phtml:143`) |
| a listagem da tela de estoque | o botão de estoque na tela (`:151`) |
| a guarda da tela de movimentação (`ProdutosController:148`) | |
| a clonagem de catálogo (`ProdutoRepository:372`) | |
| o catálogo da API nova (`ProdutoJson::CATALOGOS`) | |

**E `pro` nunca existiu**: zero produtos e zero linhas de venda em dez anos de
base. A lista de três era a lista de 2016 — com um tipo morto dentro.

Então a pergunta *"qual das três está certa?"* não é de gosto. A lista certa é a
que seis lugares já usam: **`probar`, `proemp`, `proins`**. As outras duas são
uma lista velha e uma tela que não foi atualizada.

#### O que a divergência custou, medido e não suposto

O produto **9336** (`proins`, ativo, unidade 21) está com **`quantidade = -3`**,
tem **zero** linhas em `produtos_estoque` e exatamente **3 unidades vendidas**
(2025-12-16, 2025-12-23, 2026-01-30). O negativo é o vendido, sem nada do outro
lado — não havia recusa para impedir, e não há número na tela para alguém ver.

E o caminho é alcançável: a busca de produto do carrinho do PDV chama
`autoComplite` com `insumo:"nao"` e **sem filtro de tipo**
(`public/layout/scripts/mod-vendas.js:110-115`), então os quatro `proins`
aparecem para o operador como qualquer outro produto.

**A escala, para o defeito não ser vendido maior do que é:** 4 produtos, 1
negativo, 3 unidades, 7 linhas de venda em 12 meses.

#### A coluna `insumo` NÃO é uma quarta definição de estoque

Vale escrito porque parece que é. `produtos.insumo` é booleana e existe ao lado
do tipo `proins`. Varrido: **nenhum** ponto que mexe em `produtos.quantidade` a
lê. O único efeito operacional dela é **esconder o produto do carrinho do PDV**.

E os dois "insumos" são **disjuntos e invertidos**: os 4 produtos do tipo
`proins` têm `insumo = 0`, e os 50 marcados `insumo = 1` são de outros tipos —
42 `probar` e 8 `proemp`. O filtro `insumo` do `autoComplite` aceita `sim`/`nao`,
e **nenhum chamador passa `sim`**.

#### O que foi feito, e o que continua sendo decisão sua

**Feito**, sem tocar no que as 32 lojas usam:

- `Produto::tipoMovimentaEstoque(?string)` — a mesma regra, para quem tem o tipo
  como texto. As recusas perguntam a um array vindo de `pesquisarQuantidade()`,
  e era por não terem como ler a regra que a reescreviam à mão. **Era assim que
  o D169 nascia, e é assim que ele voltaria.**
- `ComandasController:298` passou a perguntar a ela. A API nova **recusa
  exatamente o que a baixa debita** — a primeira vez que as duas coisas
  concordam em qualquer lugar do sistema.
- Três testes pregam a lista tipo a tipo e provam que as duas portas da regra
  concordam; o **caso 21** de `ensaiar-recusas-adm-api.php` monta um `proins`
  com saldo zero e exige o 422.
- **Falsificado:** com a lista de 2016 de volta, o caso 21 responde **201** — o
  insumo entra na comanda. Com a regra da entidade, 422.

É mais estrita que o *controller* velho, e não que o *sistema* — o mesmo
fundamento do D162. E nada em produção passa por aqui: a tela nova não está no ar.

**Decisão sua**, porque muda o comportamento das 32 lojas:

1. **A recusa do ERP** (`VendasProdutosController:136`) passar a incluir
   `proins`. Efeito: 4 produtos, 7 linhas de venda por ano, 3 unidades. Hoje
   nada impede vender insumo que não existe.
2. **A tela de estoque mostrar o saldo do `proins` e oferecer o botão**
   (`produtos/index.phtml:143,151`). É puramente aditivo — uma célula vazia vira
   número, e o botão leva a uma tela cuja guarda **já aceita** `proins`. Sem
   isso, o -3 continua invisível para quem poderia repor.

#### DECIDIDO em 11/09/2026: a tela sim, a recusa não

> *"Só a tela, sem a recusa"*

Feito o item 2, e **não** o item 1. O efeito é exatamente o que a decisão diz:
nenhuma venda que passa hoje deixa de passar, e o saldo do insumo para de ser
invisível.

As duas expressões deixaram de repetir a lista de 2016 e passaram a perguntar a
`Produto::tipoMovimentaEstoque()` — a mesma porta que a baixa da venda, a
devolução do cancelamento e as guardas da API nova usam. Escrever uma quarta
lista ali seria plantar de novo o que este débito é.

**Conferido na tela, e falsificado na tela** — é view, e um `.phtml` só se prova
renderizando. Com um `proins` temporário de saldo 7 no cenário:

| | saldo na coluna | botão de Estoque |
|---|---|---|
| antes (`HEAD`) | *vazio* | **não** |
| depois | **7,00** | **sim** |

E os outros dois tipos seguem como estavam: `Cerveja Teste` (`proemp`) e
`Pomada Teste` (`probar`) continuam com saldo e botão. O portão automático desta
tela é o rastreador de `medir-telas-no-postgres.php`, que o CI roda em
`ci.yml:498` e que alcança `/mod-configuracoes/produtos` a partir da raiz do
módulo.

**O item 1 continua em aberto**, por decisão sua: a recusa por falta de estoque
segue sem `proins`, e o insumo continua podendo ser vendido sem saldo pela tela
velha. O que mudou é que agora alguém vê.

**O terceiro lugar de exibição foi fechado no mesmo dia.**
`ISConfiguracao\Controller\ProdutosController::pesquisarAction` monta a coluna de
saldo da *janela de pesquisa* de produtos (`#modal_produtos_servicos`, D152) com
o mesmo literal de dois tipos. É a mesma decisão — exibição, sem recusa —, então
entrou junto.

Essa janela é o caso mais exposto dos três: `ProdutoRepository::pesquisar()`
**não filtra tipo nenhum**. Ela lista serviço, pacote e mercadoria na mesma
tabela, e já selecionava `p.quantidade` para todos. Medido na unidade 48, com as
112 linhas que a janela devolve:

| tipo | linhas | saldo visível antes | depois |
|---|---:|---:|---:|
| `probar` | 33 | 33 | 33 |
| `proemp` | 6 | 6 | 6 |
| **`proins`** | **2** | **0** | **2** |
| `ser` | 71 | 0 | 0 |

Só o insumo mudou — os 71 serviços continuam em branco, que é o certo, e os dois
tipos que já apareciam aparecem igual. E o produto **9336** (`trident/halls`, o
que o débito nomeou), na unidade 21, deixou de sair vazio e passa a imprimir
`-3,00`: o saldo negativo que ninguém via.

**A medição passou pelo repositório e pela entidade de verdade**, e não por uma
reprodução da expressão: o `pesquisar()` real, o `tipoMovimentaEstoque()` real e
o `Monetario::applyMask()` real, sobre a cópia de produção. Não houve conferência
no navegador nesta terceira tela porque `pesquisar()` filtra por unidade e os
quatro `proins` ativos da base estão nas unidades 48, 39 e 21 — nenhum na unidade
de fixture, que é a única cujo login eu uso. O que ficou provado é a decisão da
célula sobre dado real; o que não ficou é o desenho da janela, que não mudou de
forma.

Com isso, `Produto::TIPOS_COM_ESTOQUE` ganhou a anotação do terceiro leitor: a
frase *"nenhuma tela lê a coluna para esses tipos"* estava certa sobre `pac` e
`lavavip` e incompleta sobre quem lê — e quem lê é a célula, que agora pergunta à
entidade.

Os 450 negativos de `probar` e `proemp` são **históricos**: só 2 tiveram venda
nos últimos 12 meses. Não é isto que os está criando.

---

### D170 — Nenhuma recusa da finalização tem portão automático — CORRIGIDO em 09/09/2026

Medido em 09/09/2026, ao acrescentar a quarta: `grep` pelas mensagens de recusa
de `ComandasController::finalizacaoAction` em `tests/` e `scripts/` devolve
**zero**. As quatro são provadas por `curl` à mão, e nada as vigia depois:

| recusa | origem |
|---|---|
| 409 — venda em fiado sem autorização de gerente | **D86** |
| 409 — o caixa aberto é de outro dia | **D28** |
| 422 — há troco e falta `destinoTroco` | contrato do endpoint |
| 422 — `pagarDebitos` sem os débitos somados | **D159**, hoje |

O `ensaiar-fluxo-adm-api.php` encadeia dezenove passos e prova que **as fatias
concordam sobre o mesmo dinheiro** — mas só pelo caminho feliz. Ele nunca leva
uma recusa.

Não é omissão pequena: são as quatro guardas que separam uma venda legítima de
uma que cria débito sem autorização, lança no caixa errado, entrega troco
inventado ou perdoa dívida sem cobrar. Todas dependem hoje de alguém lembrar de
testar à mão.

#### O conserto: `scripts/ensaiar-recusas-adm-api.php`

Um ensaio irmão dos outros dois. Monta o estado de cada recusa, faz **um** pedido
e afirma **três** coisas. Roda no CI, antes do ensaio de fluxo.

São **dezesseis** casos. Onze são da finalização — a lista acima contava só as
que nasceram de decisão registrada, mas a ação recusa em onze pontos, e o décimo
primeiro nasceu no dia seguinte com o conserto do **D164**. Os outros cinco são
de escrita da comanda, e vieram do **D162** e do **D163**: mesma família, mesmo
cenário, mesmo login e mesma limpeza, então um segundo roteiro só duplicaria as
três partes onde este já errou uma vez.

#### Por que o código HTTP não basta — e a prova disso

**Oito dos dezesseis casos respondem 409.** Um estado mal montado dispara uma recusa
*anterior* com o mesmo código, e um ensaio que conferisse só o número ficaria
verde sem ter medido o que diz medir. Por isso cada caso exige também o
`erro.codigo` **e um fragmento da mensagem**.

Afirmar mensagem é o que uma tela não deve fazer — `cliente.ts` diz isso: a tela
decide pelo código, nunca pelo texto. Mas é exatamente o que um ensaio precisa
para distinguir duas recusas que o protocolo não distingue.

**Isso foi medido, e não suposto.** Tornei a comanda do caso 9 fiada, para que
ela caísse na recusa do D86 em vez da do D28. A resposta voltou:

```
9. caixa aberto de data anterior (D28)    409 conflito   <-- ERRADA
   - a mensagem nao fala de "caixa aberto é do dia" --
     veio "Venda em fiado exige autorização de um gerente antes de ser "
```

O código HTTP batia. O `erro.codigo` batia. **Só a mensagem pegou.**

#### Falsificado também pelo lado da guarda

Arranquei a guarda do D86 do controller e rodei: o caso 7 passou de `409` para
`200 (sem erro no corpo)`, o ensaio nomeou as três afirmações quebradas e saiu
com 1. E revelou um segundo defeito, no próprio ensaio: sem a guarda a venda
**finaliza**, e a finalização escreve pontuação, pacotes, cupons e sorteios — a
limpeza conhecia três tabelas filhas e quebrou numa quarta. Ela passou a
descobrir as dez pela `information_schema`, em vez de listá-las à mão.

#### O que ele não cobre, e por quê

A décima segunda recusa — 409 *"Não foi possível finalizar a venda"*, quando o
serviço devolve `false` — é **inalcançável por construção**: o serviço só devolve
`false` pelo caixa de data anterior, e o caso 9 pega isso antes. Um caso para ela
nunca rodaria, e seria um verde sobre nada. Conferido: `finalizar()` devolve
`false` em **um** ponto só.

#### E o portão nasceu com três defeitos, achados por revisão adversarial

Um portão com defeito é pior que nenhum, então ficam registrados.

**1. Duas instâncias ao mesmo tempo finalizavam uma venda de verdade.** O ensaio
grava sempre na comanda 90007000 e mexe em duas linhas de fixture compartilhadas
— o caixa e a origem de um cliente. Rodando em paralelo consigo mesmo, a revisão
reproduziu chave duplicada, violação de chave estrangeira, e o pior: um caso
respondeu **200**, porque o estado que a outra instância deixou era o do caminho
feliz.

Isso importa além do vermelho. A finalização escreve `clientes.ultima_visita` e
companhia, e **`clientes` não é filha de `vendas`** — a limpeza não alcança esse
resíduo, e nem poderia. As fixtures criam aquele cliente deliberadamente sem
histórico de visita (**D113**), e o relatório de clientes filtra por esse campo:
a suíte seguinte mudaria de resultado sem que nada nela tivesse mudado.

Corrigido com `GET_LOCK` do MySQL, tempo limite **zero** — esperar seria pior que
recusar, porque duas execuções em fila mediriam cenários que a outra montou. A
trava vive na conexão, então o banco a solta sozinho se o processo morrer; um
arquivo de trava ficaria para trás e travaria a próxima execução, que é o modo de
falhar do `trap` que mordeu este projeto no mesmo dia.

**2. A descoberta das tabelas filhas só enxergava chave declarada.** O comentário
prometia descobrir as filhas em vez de listá-las, mas `KEY_COLUMN_USAGE` só
conhece quem tem chave estrangeira — e `clientes_validacoes_cadastrais.venda`
aponta para `vendas` **sem uma**, justamente a tabela que o caso 9 exercita. Sem
chave ela não aparece na busca *e* não barra o `DELETE`: o resíduo ficaria em
silêncio. A busca passou a unir os dois caminhos, chave declarada e coluna
chamada `venda`.

**3. A restauração escrevia um literal.** Ela devolvia a data do caixa como
`<hoje> 08:00:00`, que acerta por coincidir com o que as fixtures gravam. Mas
restaurar por literal é afirmar o que o cenário *deveria* ter, e não devolver o
que ele *tinha* — no dia em que a fixture mudasse a hora, o ensaio passaria a
corrigir o cenário em silêncio. Agora lê antes e devolve o valor lido.

E um quarto, menor: o `catch` pegava só `RuntimeException`, então exceção do
banco escapava e quem rodasse via um rastro de pilha em vez da explicação. Agora
pega `Throwable` — o erro mais provável ali é de banco, e não meu.

#### A ordem no CI, medida nos dois sentidos

O ensaio roda **antes** do de fluxo, que termina com o caixa da fixture fechado.
Na ordem certa, 10 de 10. Invertido, o caso 8 responde **200** e o ensaio sai com
1 — um caso só, porque os outros nove recusam antes de a lógica do caixa
importar.

#### E ELE NUNCA RODOU NO CI, até 11/09/2026 — descoberto pelo D61

Este ensaio está escrito no CI, no job `caracterizacao-postgres`, e **não
executava**. Ele roda contra PostgreSQL e estava escrito em SQL do MySQL: o
`GET_LOCK` da trava, o `DATABASE()`/`REFERENCED_TABLE_NAME` de `limpar()`, a crase
como citação de nome, e dois `ON DUPLICATE KEY UPDATE`. Morria no primeiro, com
saída 255, antes do caso 1 — e derrubava o job, o que impedia os passos seguintes
de rodar também.

Era **este** o passo que deixava o CI vermelho, medido no log do run
`34651184927`. Os 23 casos deste débito só foram exercitados contra PostgreSQL
**hoje**, e passaram — 23 de 23 — depois de os quatro construtos serem portados. A
história completa, com os números e o quinto construto (um booleano na restauração
do ensaio irmão), está no **D61**.

Isto não diminui o que este débito fez: as 23 recusas são reais e o ensaio as
mede. Diminui o que ele **afirmava sobre o CI** — e essa parte estava escrita aqui
sem nunca ter sido medida.

---

### D171 — A API nova aceita dar entrada de estoque em serviço, e a tela do ERP recusa — CORRIGIDO em 10/09/2026

Achado em 10/09/2026, varrendo o sistema para o **D169**. É o **espelho** dele: o
D169 é sobre quem *deveria* ser recusado por falta de estoque e não é; este é
sobre quem *deveria* nem ter estoque e pode receber.

`ProdutosController` (Adm), o catálogo da API nova, guarda o sub-recurso
`/adm/api/produtos/:id/estoque` com a sua própria lista:

```php
// module/ISConfiguracao/src/Controller/Adm/ProdutosController.php:45
private const TIPOS = ['ser', 'lavavip', 'probar', 'proemp', 'proins'];
```

e `:522` recusa com 404 apenas o que estiver **fora** dela. Então
`POST /adm/api/produtos/:id/estoque` aceita um produto do tipo **`ser`** ou
**`lavavip`** — serviço —, cria a linha em `produtos_estoque` e move
`produtos.quantidade` de um produto que, pela regra do próprio sistema
(`Produto::movimentaEstoque()`), **não tem estoque**.

A tela do ERP não aceita: `ISConfiguracao\Controller\ProdutosController:148`
exige `in_array($produto->getTipo(), ['probar', 'proemp', 'proins'])` antes de
abrir a movimentação.

**Por que a lista tem cinco tipos:** ela nasceu para o *catálogo* — dizer o que é
item de catálogo, por oposição a pacote (`pac`), que tem recurso próprio. Serve
bem para isso. O defeito é ela governar também o sub-recurso de estoque, que é
outra pergunta.

**Ao resolver:** o sub-recurso de estoque deve perguntar a
`Produto::tipoMovimentaEstoque()`, como a guarda de comanda passou a fazer no
D169 — e `TIPOS` continua sendo a lista do catálogo, que é o que ela é.

#### CORRIGIDO no mesmo dia, e a medição que faltava não deu zero

O registro acima dizia *"esperado zero, mas esperado não é medido"*. **Medido, e
não é zero** — é **uma** linha:

| tipo do produto hoje | linhas em `produtos_estoque` | primeira | última |
|---|---|---|---|
| `probar` | 25.742 | 2017-06-08 | 2026-08-15 |
| `proemp` | 7.829 | 2017-06-08 | 2026-08-15 |
| **`ser`** | **1** | **2018-04-24** | 2018-04-24 |
| `proins` | 1 | 2026-07-12 | 2026-07-12 |
| `lavavip` | **0** | — | — |

**E ela não veio desta rota.** É o produto **3333**: criado em 2018-04-24,
entrada de 2 unidades no mesmo dia, 3 unidades vendidas, e **alterado em
2020-02-27** — seis anos antes de esta rota existir. Foi *retipado* para serviço
depois de ter tido estoque, e o saldo ficou em 0: a venda seguinte, já como
`ser`, não debitou. A linha é artefato de retipagem, e não de API frouxa.

Vale a distinção porque ela muda o que se conserta: não há dado a limpar, só uma
porta a fechar.

#### O conserto

`produtoEstocavelDaRota()` embrulha `produtoDaRota()` e acrescenta **uma**
pergunta — `$produto->movimentaEstoque()`. As três rotas de estoque
(`estoqueAction`, `movimentarEstoqueAction`, `cancelarMovimentacaoAction`)
passaram a usá-lo; as duas de catálogo não mudaram, e `TIPOS` continua sendo a
lista do catálogo.

**A regra é a da entidade, e não uma quarta lista.** Foi repetir a lista à mão
que produziu o D169 — escrevê-la aqui de novo seria plantar hoje o que ontem
custou uma varredura inteira para achar.

#### O portão, e o que ele obrigou a consertar no caminho

O **caso 22** de `ensaiar-recusas-adm-api.php` é o primeiro que não é de comanda
— por isso o ensaio passou a se chamar *"as recusas de escrita da API nova"*. Ele
pede entrada de estoque no serviço 90001000 da fixture e exige **404** com
*"não tem estoque"*.

E ele cobrou duas coisas que não eram dele:

- **O gerente da fixture não tem `configuracoes/produtos`** — tem sete
  privilégios, todos de venda e agenda. Sem isso o caso receberia **403** e
  ficaria verde pelo motivo errado. O privilégio é concedido por id próprio na
  faixa do ensaio e revogado no `finally` — e **antes do login**, porque
  `SessaoAcl::setAcl()` monta a ACL na entrada.
- **`entrar()` chamava `exit`**, e em PHP `exit` não roda o `finally`. Enquanto
  nada era escrito antes do login isso não custava nada; com o privilégio, uma
  senha errada o deixaria para trás. Agora lança, como o laço de casos já fazia.

**Falsificado:** revertida a guarda, o caso 22 responde **201** — o serviço
recebe estoque.

#### E um portão que nasceu de eu mesmo tropeçar

Rodando o ensaio numa cópia local reusada, os casos **8 e 9** deram 200 e a saída
acusou a guarda do caixa. O defeito não era ali: `ensaiar-fluxo-adm-api.php`
termina fechando o caixa da fixture e abrindo o do dia seguinte, e aquela linha
não é filha da comanda — `limpar()` não a alcança, e nem deveria. No CI nunca
morde, porque o banco nasce e morre com o runner e a ordem está certa.

O ensaio passou a conferir, antes de medir, que o **único** caixa aberto do
gerente é o da fixture e é de hoje. Quando não é, ele nomeia o caixa que sobrou,
diz quem provavelmente o deixou e manda remontar. **Falsificado com o mecanismo
de verdade:** rodei o ensaio de fluxo e o de recusas na sequência errada, e o
portão acusou `caixa 90001151` em vez de dois vermelhos apontando para o lugar
errado.

---

### D172 — O SAC do aplicativo nunca funcionou: ele manda o e-mail de redefinir senha, e morre — CORRIGIDO em 11/09/2026, os TRES defeitos; os restos (o reset, o CORS e o UTF-8) CORRIGIDOS em 03/10/2026

Achado em 11/09/2026, ao cobrir o **D61** por decisão sua. Foi a decisão de
cobrir que o encontrou — e num único comando.

`POST /api/clientes/sac` abre chamado de SAC. Com os campos preenchidos e uma
unidade que tem e-mail, ele responde:

```
HTTP 500
(HTML da página de erro do ERP, não JSON)
```

**Medido por execução**, contra o banco de contrato, com
`nome`, `email`, `telefone`, `assunto`, `mensagem` e `unidade_id = 90001200`.

> **Correção de 11/09/2026:** a frase acima dizia "os cinco campos
> obrigatórios". São **três**: `nome`, `assunto` e `mensagem`, num único `!empty`
> em `AplicativoController:65`. `email` e `telefone` são lidos pelo **serviço**
> (`AplicativoService:51,54`) e não são exigidos em lugar nenhum; `unidade_id` é
> opcional. A documentação embutida do ERP (`ApiDocsController:519-524`) já
> estava certa — quem errou foi este débito. E a rota não restringe verbo: um
> **GET** também chega na ação e responde `{"sucesso":false}` com HTTP 200.

#### O motivo é uma palavra

```php
// module/ISApi/src/Service/AplicativoService.php:48
$mail = new Mail($this->smtp, $this->view, 'redefinir-senha');
$mail->setSubject('Redefinir sua senha')
```

O terceiro argumento é o **template**, e `redefinir-senha.phtml` lê `$usuario`
como **objeto** — `$usuario->getNome(true)` na linha 24 e
`$usuario->getTokenTrocarSenha()` na 31. O SAC passa cinco escalares e nenhum
`$usuario`, então a renderização morre dentro de `Mail::prepare()`, **antes** do
`send()`.

E `module/ISConfiguracao/view/mailer/sac.phtml` **existe**, tem 6.185 bytes, e lê
exatamente as cinco chaves que o serviço passava naquele momento — `$this->nome`,
`$this->telefone`, `$this->email`, `$this->assunto`, `$this->mensagem`. Está
órfão: `grep -rn "'sac'" module/ --include=*.php` acha a rota e a documentação, e
**nenhuma** construção de `Mail`. Os dois únicos `new Mail(` do sistema usam
`'redefinir-senha'`.

Ou seja: o template certo foi escrito, e ninguém o ligou.

#### Por que ninguém viu

O caso vazio responde `{"sucesso": false}` com HTTP 200 — `POST` sem campos não
entra no `if` que chama o serviço, então sai o `false` inicial. É esse caso que o
snapshot `sac-sem-dados.json` congela, e ele é **saudável**. O caminho que morre é
o que tem dados, e era exatamente o que o D61 dizia não estar coberto.

#### O que ficou pronto, e o que falta

**Pronto:** a guarda de `ContratoTestCase::assertContrato` que recusa congelar
corpo que não é JSON. Sem ela, um teste do caso com dados gravaria
`{"status":500,"estrutura":"null"}` e passaria para sempre — ver o registro dela
no próprio método.

**Faltava, e era mudança de comportamento:** trocar o template e o assunto na
linha 48-49. Não fiz na hora porque decidir que o e-mail passe a sair é decisão de
quem opera — e foi bom não ter feito, porque a decisão trouxe de volta um segundo
defeito que o 500 escondia. Ver as duas seções abaixo.

#### E o destinatário está errado — achado em 11/09/2026, pela correção do dono

> *"O SAC é utilizado no app (android/ios) para entrar em contato com a
> franqueadora, apenas o aplicativo chama essa função, o erp não."*

Eu havia escrito acima que "o SAC manda e-mail para a unidade", e tratei isso como
o projeto da função. Não é: a função existe para o cliente **falar com a
franqueadora**. E o código, lido com essa informação na mão, faz as duas coisas —
na ordem errada (`AplicativoController:66-74`):

```php
$email = 'contato@barbeariavip.com.br';          // a franqueadora, o padrão

if (!empty($request['unidade_id'])) {
    $unidade = $this->em->getReference(Unidade::class, $request['unidade_id']);

    if (!empty($unidade) && !empty($unidade->getEmail())) {
        $email = $unidade->getEmail();           // e a unidade SOBRESCREVE
    }
}
```

O padrão é a franqueadora; a unidade o sobrescreve sempre que o app mandar
`unidade_id` e aquela unidade tiver e-mail. **Medido: as 32 unidades ativas têm
e-mail — todas.** Então o endereço da franqueadora é, na prática, código morto: a
mensagem que o cliente escreve para a franqueadora seria entregue **à franquia**,
inclusive quando a mensagem for sobre a franquia.

São **dois** defeitos, e o 500 esconde o segundo. Consertar só o template —
o que este débito chamava de "uma linha" — poria no ar uma função que entrega
reclamação ao reclamado.

**Não medido:** se alguma unidade em produção já reclamou de SAC que não chega. A
resposta é 500, então o aplicativo mostra erro ao cliente; ninguém perde chamado
em silêncio.

#### CORRIGIDO em 11/09/2026 — os dois defeitos conhecidos, e um terceiro que eles escondiam

> *"sempre a franqueadora, a unidade não precisa receber"*

**O roteamento.** A sobrescrita pela unidade saiu, e o endereço virou a constante
`AplicativoController::SAC_DESTINO`. `unidade_id` continua aceito e passa a ser
ignorado — era o único uso dele naquela ação —, então o aplicativo não precisa de
versão nova.

**O template.** `'redefinir-senha'` virou `'sac'`, e o assunto virou `Novo contato
pelo aplicativo`.

**PROVADO POR EXECUÇÃO, e não por leitura.** Dois POST contra o endpoint real, com
a caixa do Mailpit medida antes (zero) e depois:

| | |
|---|---|
| resposta | `HTTP 200` · `application/json` · `{"sucesso":true}` |
| mensagens na caixa | 0 antes → 1 por POST |
| **destinatário** | **`contato@barbeariavip.com.br`** |
| `Reply-To` | o e-mail do cliente — a franqueadora responde direto a quem escreveu |
| assunto | `Novo contato pelo aplicativo` |
| corpo | 6.568 bytes, os cinco campos presentes |

E `unidade_id=1` foi enviado no primeiro POST e corretamente **ignorado** — é a
prova de que a sobrescrita morreu, e não apenas que o caminho padrão funciona.

**A caixa foi devolvida a zero** depois das duas medições, pelo mesmo princípio
dos ensaios: quem escreve mede o resíduo e o restaura.

#### E havia um TERCEIRO defeito, que só apareceu porque os outros dois saíram

Com os dois primeiros consertados, sobrou um caminho quebrado — e ele é o caminho
que a **documentação do próprio ERP** declara válido. Medido por execução: um POST
com apenas os três campos obrigatórios (`nome`, `assunto`, `mensagem`) respondia

```
HTTP 200  ·  text/html  ·  541 bytes
Warning: Undefined array key "email" ... on line 83
Warning: Undefined array key "email" ... on line 85
Warning: Undefined array key "telefone" ... on line 86
<h1>An error occurred</h1>
```

**Duzentos, não quinhentos** — e é isso que torna o defeito grave. Os *Warnings*
foram impressos na saída **antes** de o framework poder dizer 500, então o status
ficou 200. É a família do **D100** (*"Sete lugares imprimiam antes da resposta, e
cada um podia transformar um 500 em 200"*), e um aplicativo que confere
`status == 200` trataria falha total como sucesso: nenhum e-mail saiu, e o cliente
veria confirmação.

A causa: `Mail::prepare()` chama `setReplyTo()` **incondicionalmente** (`Mail.php:78`),
e medido — `Message::setReplyTo(null)` **lança** (*"addReplyTo expects a string …
received NULL"*) e string vazia também (*"Invalid address format"*). Mas `email`
**não é campo obrigatório**: o controlador exige três, e `ApiDocsController:519-524`
diz o mesmo.

**Corrigido:** sem o e-mail de quem escreveu, o `Reply-To` vai para o próprio
destinatário — responder cai na mesma caixa, é inofensivo, e o chamado chega. E
`email`/`telefone` passam ao template com `?? ''` em vez de estourarem.

| requisição | antes | agora |
|---|---|---|
| três campos (o mínimo documentado) | `200` + HTML de erro, nada enviado | `200` `{"sucesso":true}`, `Reply-To` = franqueadora |
| cinco campos | `500` + HTML | `200` `{"sucesso":true}`, `Reply-To` = o cliente |

Os dois provados por execução, com a caixa medida.

#### O que a prova por bytes desmentiu, e o que ela confirmou

Ao ler o corpo entregue por uma API que devolve texto, eu **quase registrei um
defeito que não existe**. A leitura crua mostrava `JoÃ£o ConceiÃ§Ã£o` — mojibake
clássico. Era artefato da minha própria decodificação. Os **bytes** da mensagem
entregue dizem outra coisa:

```
4a 6f e3 6f   20 43 6f 6e 63 65 69 e7 e3 6f     →  J o ã o   C o n c e i ç ã o
```

Um byte por acento (`e3` = ã, `e7` = ç): Windows-1252 legítimo. A conversão do
template **funciona**, e cliente de e-mail que honre o `<meta charset=iso-8859-1>`
do documento mostra o nome certo. *Número que não vale não se conta como se
valesse* — e texto decodificado duas vezes não é o texto que chegou.

O que a mesma medição **confirmou**, em bytes:

| o que o cliente digita | o que chega | |
|---|---|---|
| acento português (`ã ç é`) | `e3 e7 e9` | correto |
| travessão `—` | `97` | correto (o HTML5 manda tratar `iso-8859-1` como Windows-1252) |
| **emoji `😡`** | **`3f`, um `?`** | **perdido, e sem volta** |

O emoji não sobrevive porque Windows-1252 não tem o caractere, e
`mb_convert_encoding` o substitui por `?`. Quem escreve SAC pelo celular usa
emoji. A reclamação chega legível; o tom dela, não todo. É defeito do desenho do
template, igual em `redefinir-senha.phtml`, e **não** regressão deste conserto —
fica registrado porque foi medido, não suposto.

#### A UNIDADE VOLTOU A SER DITA — segunda decisão sua, no mesmo dia

> *"sim, acrescente a unidade no corpo do e-mail"*

Eu havia deixado isto como pergunta: tirada a sobrescrita, `unidade_id` chegava na
requisição e era **descartado**, e com o e-mail indo para a franqueadora isso
passou a importar mais, não menos — quem recebe não sabia de que loja falava a
reclamação. Decidido, está no corpo.

A unidade é **dita** e não **decide**. São os dois lados da mesma decisão, e o
ensaio afirma os dois no mesmo caso: a mensagem vai para a franqueadora *mesmo*
com `unidade_id` de uma unidade que tem e-mail, **e** o nome daquela unidade
aparece no corpo. **Falsificado:** com o campo saindo vazio, o caso acusa *"o corpo
entregue não traz o campo `unidade` do chamado"* e sai com 1.

**`find()` e não `getReference()`**, e numa rota pública a diferença é um 500:
`getReference` devolve proxy e só estoura ao ler o nome, com
`EntityNotFoundException` — `unidade_id=999999` de qualquer estranho viraria erro
de servidor. Medido, com os quatro casos:

| `unidade_id` | o corpo diz |
|---|---|
| `48` | `Brasil - SC - São José - Shopping Itaguaçu` |
| ausente | `(não informado)` |
| `999999` | `(unidade 999999 não encontrada)` |
| `unidade_id[]=48` | `(não informado)` |

Os quatro com `HTTP 200` e `application/json` — o caso do array importa porque
`(int)` de um array emite *Warning*, e *Warning* impresso antes da resposta
transforma o JSON em HTML com status 200 (a armadilha do **D100**, que já tinha
aparecido duas vezes neste mesmo endpoint hoje).

**Não é exposição nova, e isso foi medido antes de escrever.**
`GET /api/agendas/getUnidades` é rota **pública** e devolve id, país, estado,
cidade, bairro, **endereço** e telefone das 28 unidades ativas, sem sessão. A
identidade da unidade já era pública inteira; o nome não acrescenta nada.

**E no PostgreSQL há uma garantia a mais, de graça:** `unidades` tem
`FORCE ROW LEVEL SECURITY` (`postgres/schema.sql:2206-2207`), então a unidade de
**outro inquilino** volta nula — o chamado nunca nomeia loja que não é da
instalação que o recebeu. Conferido por execução: o ensaio passa no PostgreSQL, e
para a unidade `90001200` ser nomeada ali a política teve de casar.

Os bytes entregues conferidos um a um: `53 e3 6f` para `São`, `e9` para `é`, `e7`
para `ç` — Windows-1252 de um byte por acento, igual aos outros cinco campos, e
escapado com `htmlspecialchars` como eles.

#### Tres coisas que ficaram de fora

**1. SMTP caído continua virando 500.** Não há `try/catch` em nenhum ponto entre a
ação e o `send()` — conferido no controlador, no serviço, no `Mail`, no
`CrudController` e no `public/index.php`. O desenho é o mesmo do reset de senha,
que está em produção assim. Envolver em `try/catch` para devolver
`{"sucesso":false}` faria o cliente parar de ver erro **e o chamado desaparecer
sem rastro**, porque não há log de aplicação neste caminho. Trocar um defeito
visível por um silencioso é pior; fica como está, e registrado.

**2. O assunto é fixo, e de propósito.** Interpolar `$data['assunto']` no cabeçalho
poria texto de estranho num lugar que `Message::setSubject` valida **lançando** —
medido: assunto pré-convertido para Windows-1252 dispara *"Subject value must be
composed of printable US-ASCII or UTF-8 characters"*, e o 500 voltaria num caminho
que parecia consertado. O assunto do cliente vai no corpo.

**3. O envio é SÍNCRONO, e isso prende um trabalhador do PHP.** Este é o último fio
solto da revisão adversarial, e é o único achado cujo **mecanismo** nenhum cético
conseguiu derrubar — só o cenário que ele descrevia. O `send()` acontece dentro da
requisição: enquanto o SMTP não responde, o processo que atende aquele POST fica
ocupado. O teto é `max_execution_time = 300` (`docker/php/php.ini:16`), e não
infinito como o achado supunha; por isso ele foi refutado como enunciado.

Mas a propriedade fica: um MTA lento numa rota **pública** que aceita 10 requisições
por minuto por IP é um caminho para ocupar trabalhadores sem credencial nenhuma. Não
é regressão — o reset de senha tem o mesmo desenho e está em produção assim —, e
consertar é pôr o envio numa fila, que é mudança de arquitetura e não de linha.
Registrado porque foi medido, não porque precise ser feito agora.

Com isto, os **28** achados daquela revisão estão todos tratados ou refutados: nove
sobreviveram à refutação, e os nove foram consertados ou registrados aqui.

#### A revisão adversarial achou TRÊS defeitos na superfície que eu acabei de ligar

Ligar o SAC foi ligar uma rota **pública, sem sessão**, que agora manda e-mail.
Isso é superfície nova, e antes de commitar passei o conserto por uma revisão com
quatro lentes independentes, cada achado posto à prova por um cético com instrução
de **derrubá-lo**. Dos 28 achados, 9 sobreviveram. Três eram da superfície nova, e
os três reproduzem por execução.

**1. O corpo do e-mail não tinha escape nenhum.** `sac.phtml` imprimia os cinco
campos com `mb_convert_encoding(...)` e nada mais — `grep -c
"escapeHtml\|htmlspecialchars"` no arquivo devolvia **0**. O corpo é `text/html`, e
quem o abre é a franqueadora. Medido: `nome=<img src=x onerror=alert(1)>` e
`mensagem=<script>roubar()</script>` chegavam crus.

*Consertado:* `htmlspecialchars($campo, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')`
**antes** do `mb_convert_encoding` — escapar em UTF-8 e só depois converter, porque
as entidades são ASCII e atravessam a conversão intactas. Medido nos bytes
entregues: `&lt;script&gt;roubar()&lt;/script&gt;`, e nenhum `<script` cru.

**2. Quem escreve escolhia o NOME DE EXIBIÇÃO do `Reply-To`.** `Laminas\Mail` só
recusa CRLF. Medido: `Atendimento Barbearia VIP <atacante@exemplo.invalid>` passava
inteiro e virava o `Reply-To` da mensagem. Quem recebe na franqueadora aperta
"responder" e fala com o atacante, achando que fala com o atendimento da própria
rede — e o e-mail *veio* de um endereço da rede, o que torna o disfarce melhor.

**3. E-mail com erro de digitação derrubava o chamado.** Medido:
`email=joaoexemplo.com` — um arroba esquecido, não um ataque — fazia
`setReplyTo` lançar *"The input is not a valid email address"*, a resposta saía
**500** em HTML, e o chamado **não chegava**. Quem digitou errado perdia o chamado
e via erro.

*Consertados juntos, porque são a mesma fronteira:* o `Reply-To` passa por
`FILTER_VALIDATE_EMAIL`, que recusa os três casos — o vazio, o malformado e o `Nome
<a@b>`. O que não passa cai no destinatário, e **o chamado chega**. É a diferença
entre perder o chamado e perder só o caminho de resposta.

| o que chega | `Reply-To` | o chamado |
|---|---|---|
| e-mail válido | o cliente | chega |
| e-mail com erro de digitação | a franqueadora | **chega** (antes: 500, perdido) |
| nome de exibição disfarçado | a franqueadora | chega, sem o disfarce |
| `email[]` como array | a franqueadora | chega, sem o *Warning* |
| sem `email` | a franqueadora | chega |

Os cinco medidos por execução, com a caixa conferida: as cinco respostas vieram
`HTTP 200` `application/json` `{"sucesso":true}`, e as cinco mensagens foram
entregues à franqueadora.

**E os cinco campos ganharam teto de tamanho** — 200 caracteres para nome, e-mail e
assunto, 60 para telefone, 4.000 para a mensagem, cortando com aviso no corpo em
vez de recusar. A rota é pública e o corpo que chegasse virava e-mail do tamanho que
fosse. Quatro mil caracteres são umas duas páginas de texto: o teto não impede
ninguém de reclamar, impede que a reclamação seja um anexo.

**A documentação embutida da API também estava errada**, e por minha causa:
`ApiDocsController:523` dizia que `unidade_id` *"define e-mail da unidade quando
informado"* — a frase descrevia exatamente o bloco que o conserto arrancou. Agora
diz *"aceito e ignorado"*, e `email` e `telefone` entraram na tabela como
opcionais, que o serviço lê e ela omitia.

#### E um defeito vizinho, achado de passagem e NÃO mexido

O mesmo levantamento mostrou que o **reset de senha** tem um problema que o SAC
não tem. Em `AutenticacaoService:24-27`, o token novo é persistido **e
descarregado** antes do envio:

```
1. grava token novo no banco   (flush)
2. tenta enviar o e-mail
```

SMTP indisponível no passo 2 → o token novo está gravado, o e-mail não saiu, o
token antigo morreu, e o usuário recebe 500. Ele perde o link que tinha **e** não
recebe o novo. É a ordem invertida: gravar depois de enviar, ou não invalidar o
antigo até o novo sair.

Não mexi. É outro endpoint, outro público, e `/autenticacao/migrar-senhas` já é
assunto delicado o suficiente neste projeto (D78) para que uma mudança ali não
entre de carona num conserto de SAC.

**Também medido, e sem dono:** dois templates de mailer estão órfãos —
`cliente-novo.phtml` e `cliente-aniversario.phtml`. Nenhum dos dois `new Mail(` do
sistema os referencia. O `sac.phtml` era o terceiro órfão até hoje.

**E uma anotação sobre a resposta de erro:** quando a ação estourava, o `500` saía
**sem** o cabeçalho `Access-Control-Allow-Origin`, porque a exceção acontecia antes
dos dois `header()` do fim da ação. Para o aplicativo, um 500 sem CORS é uma falha
de rede, e não uma resposta de erro. Com o conserto o caminho de sucesso não passa
mais por ali; o caminho de exceção continua assim.

#### Um teto de taxa que a suíte precisa respeitar

Medido no caminho: `/api/clientes/sac` e `/api/clientes/novo` dividem a **mesma**
zona de limite no nginx — `escrita_publica`, 10r/m com rajada de 5, por IP
(`docker/nginx/default.conf:60,139-148`). A suíte fala de um IP só e hoje gasta
2 dos 6 créditos imediatos. Quem acrescentar casos nessas duas rotas soma no
mesmo balde, e o 429 do nginx tem a **mesma forma de snapshot** que o 500 — só
muda o número. A guarda nova nomeia esse caso na mensagem de falha.

#### Os restos: o reset na ordem certa, o CORS antes do corpo e o e-mail em UTF-8 — CORRIGIDO em 03/10/2026

O item SESSAO-SENHA-EMAIL fechou as três anotações acima que ficaram sem dono.

- **O reset gera, envia e só então grava** (`AutenticacaoService::senhaRedefinir`). O token novo nasce
  na entidade, o e-mail o leva, e só o envio que não lançou o grava. Com o SMTP fora do ar, a exceção
  sobe como antes (o 500), o banco não muda -- o link que a pessoa já tinha segue valendo -- e a
  entidade volta ao banco (`refresh`), para nada mais naquela requisição gravar o token que não saiu.
  O risco da ordem inversa fica, e é menor: o e-mail que saiu e a gravação que falhou dão um link que
  não abre, e pedir de novo resolve. O `Reply-To` do fornecedor antigo não mudou (está nas decisões).
- **O CORS do SAC antes do corpo** (`AplicativoController::sacAction`): o
  `Access-Control-Allow-Origin: *` é posto na resposta do Laminas no começo da ação, e não mais com o
  `header()` do fim. O envio que lança chegava antes do fim, e o 500 saía sem ele. ~~a página de erro
  reaproveita a mesma resposta (`ExceptionStrategy`). O `Content-type: application/json` ficou no fim:
  no 500 o corpo é HTML, e o cabeçalho de JSON mentiria.~~ (revisão, 04/10/2026: o mecanismo escrito
  estava errado.) Quem responde o 500 de `/api/clientes/sac` é o `ErroJsonListener` do `ISApi` --
  preso ao `dispatch.error` com prioridade 1000 para tudo sob `/api/` (`ISApi\Module::onBootstrap`) --,
  e não a `ExceptionStrategy`: ele pega a resposta do evento, que é a MESMA que o controller recebeu
  (o `DispatchListener` passa a da aplicação), põe o 500, o `Content-Type: application/json;
  charset=utf-8` e um corpo JSON, e para a propagação. É por isso que o CORS posto no começo chega ao
  aplicativo, e o 500 é JSON, e não HTML. O `Content-type` do `header()` do fim ficou onde estava: é o
  do caminho que dá certo, e o 500 já sai com o do ouvinte.
- **Os e-mails em UTF-8.** `Mail::prepare` declara `charset=UTF-8` na parte HTML (antes não declarava
  nenhum), e os quatro templates de `mailer/` deixaram o `mb_convert_encoding` para Windows-1252 e o
  `<meta charset=iso-8859-1>` -- os dois em uso (`redefinir-senha` e `sac`) e os dois órfãos, para o
  dia em que alguém os ligar. O emoji do cliente chega como foi escrito. De passagem, o nome no "Olá"
  do reset passou a ser escapado, como os campos do SAC (o corpo é HTML). A transferência continua
  `8bit`, como era.

**Provado:** `EmailsDoSistemaTest`, 6 casos verdes nos dois bancos, com SMTP dublê (nenhum e-mail sai):
o dublê que guarda lê o banco NO MOMENTO do envio -- ainda o link antigo -- e depois o banco tem o token
que o e-mail levou; o que lança deixa o banco e a entidade com o link antigo, e um `flush` depois não
grava nada; os bytes da mensagem como iria pela rede (`toString()`) trazem `c3 a7 c3 a3` em
"solicitação" e no nome, nenhum `e7 e3`, e a parte declara `charset="UTF-8"` (o cabeçalho vai dobrado
no fio); o nome com `<b>` vai escapado; o SAC leva `f0 9f 98 a1` (o emoji) e o acento em UTF-8; e o SAC
despachado com o SMTP que lança sobe a exceção com o CORS já na resposta. **No Mailpit, pelos bytes:**
um reset de verdade, por HTTP, ao colaborador de fixture (`colab@teste.invalid`), nos bancos de
contrato MySQL e PostgreSQL -- a parte diz `charset=UTF-8` (transferência `8bit`), o `<meta>` diz
`utf-8`, "solicitação" chega como `73 6f 6c 69 63 69 74 61 c3 a7 c3 a3 6f`, nenhum byte de
Windows-1252, o corpo inteiro é UTF-8 válido, e o HTML que o Mailpit decodifica traz o texto certo; a
caixa e o token do link voltaram ao que eram. O SAC não foi ao Mailpit: o destino dele é um endereço de
verdade. Mutantes, um de cada vez, vermelhos: gravar antes de enviar (os dois do reset); sem o `refresh`
(o do SMTP que lança); o CORS de volta ao fim (o do SAC que falha); o `Mail` sem o charset (os dois de
UTF-8); o texto do reset, o `<meta>` e a mensagem do SAC de volta a Windows-1252 (cada um o seu); o nome
sem escape (o dele).

**A revisão (04/10/2026):** o caso do SAC que falha despachava só o controller, e não passava por quem
responde o 500. Entrou `testOSacQueFalhaChegaAoAplicativoEmJsonComOCors`: a exceção passa pelo
`dispatch.error` da aplicação montada, como o `DispatchListener` faz, e o 500 sai do `ErroJsonListener`
na mesma resposta que o controller recebeu -- JSON, com o `Content-Type` do ouvinte e o CORS. Verde nos
dois bancos; vermelho, nos dois, com o CORS de volta ao fim (junto com o caso antigo) e com o ouvinte
montando uma resposta nova (só ele: o caso antigo não via o ouvinte). O 500 do SAC continua sem ter
sido visto por HTTP: provocá-lo pede o SMTP fora do ar no servidor local, e o destino do SAC é um
endereço de verdade.

**O que fica:** o SMTP caído continua virando 500 no reset e no SAC, como a primeira das "três coisas
que ficaram de fora" acima; e o envio continua síncrono (a terceira).

---

### D173 — O cenário de fixtures não podia ser recarregado, e dois testes eram verdes por ausência de dado — CORRIGIDOS em 12/09/2026 — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: `BuscaDeLogradouroTest` depende do dicionário de endereços curto e falha contra o real; o conserto provável é buscar termo que só a rua do cenário case.

> **05/10/2026, depois da nota:** consertado e provado contra o dicionário real (a seção "O conserto do achado 3", no fim deste débito). E o sexto vermelho do achado 3, o do `BuscaDeClienteComAcentoTest`, que a primeira versão do conserto deixava de fora, consertado na verificação do mesmo dia (a mesma seção).

Os três achados desta entrada saíram do **mesmo ato**: recarregar o cenário no
banco local para poder medir o que os testes de relatório precisavam. Nenhum
deles aparece no CI, e é isso que os une.

#### 1. O `fixtures.sql` carregava UMA vez, e abortava na segunda

A guarda do **D122** acusou primeiro: *"O cenario deste banco esta defasado"*. Ao
recarregar, a carga parou em

    ERROR 1062 (23000) at line 1183: Duplicate entry '90001000' for key 'estados.PRIMARY'

**Medido:** o arquivo **INSERE em 42 tabelas** e a limpeza do topo conhecia
**23**. Das 19 que sobravam, nove morrem por `CASCADE` de um pai que a limpeza já
apaga — conferido em `REFERENTIAL_CONSTRAINTS.DELETE_RULE`, e não suposto — e
**dez não morrem**:

| tabela | como falhava |
|---|---|
| `estados`, `cidades`, `bairros`, `logradouros` | sem pai apagado: o `INSERT` repetido bate em chave duplicada |
| `saidas_tipos`, `vipclass` | idem, por caminhos próprios |
| `clientes_debitos` | aponta para `vendas`, `clientes` e `unidades` com `NO ACTION` — não quebra no próprio `INSERT`, **bloqueia** o `DELETE` de `vendas` |
| `nps_resultados_unidades`, `unidades_socios` | bloqueiam o `DELETE` de `unidades` |
| `pacotes_itens` | bloqueia o `DELETE` de `produtos` |

**POR QUE ISSO NUNCA APARECEU NO CI, e é a parte que importa:** lá o banco
**nasce vazio** a cada execução, então um arquivo que carrega uma vez é
indistinguível de um que carrega sempre. Quem paga é quem tem banco local — e o
preço não é um erro na tela: a carga **aborta no meio**, deixando o cenário pela
metade, que é a mesma falha que um comentário do próprio arquivo já descrevia
vinte linhas acima da limpeza.

Corrigido com as dez exclusões faltantes, em ordem de filho antes de pai.
**Provado por execução: duas cargas seguidas, sem erro.**

#### 2. `PrivilegioApiTest` afirmava a tabela INTEIRA vazia

Com o cenário recarregado, foi a única das 439 a cair. A linha era

    self::assertSame([], $this->em->getRepository(Privilegio::class)->findAll());

**Medido:** `privilegios` tem **139 linhas**, ids de 2 a 180, **todas da cópia
anonimizada** e nenhuma do cenário. A afirmação é verdadeira no CI, onde o banco
nasce de `mysql.sql` mais as fixtures e mais nada, e **falsa em qualquer banco com
a cópia carregada**.

O teste passava **pelo motivo errado** — pela tabela estar vazia, e não por o
cenário não criar molde de grupo. *Verde por ausência de dado.* Escopado à faixa
9000xxxx, e falsificado: com o limiar em 0 a conta volta a ver as 139.

#### 3. `BuscaDeLogradouroTest` passa no MySQL porque o dicionário local está vazio

Este **fica em aberto**, e é o mais interessante dos três. Rodando a
caracterização contra o `franquiabv_dev` do PostgreSQL — que tem a cópia —, cinco
casos dele e um do `BuscaDeClienteComAcentoTest` ficam vermelhos. **Não é
regressão:** os dois bancos locais carregam dicionários de endereço diferentes.

| tabela | MySQL | PostgreSQL |
|---|---:|---:|
| `logradouros` | **5** | **709.442** |
| `bairros` | 4 | 38.778 |
| `cidades` | 4 | 10.054 |
| `estados` | 4 | 28 |
| `clientes` | 242.597 | 242.597 |
| `vendas` | 2.269.221 | 2.269.221 |

Tudo bate **menos** as quatro tabelas de endereço. E o método que o teste exercita
termina em `ORDER BY e.nome, c.nome, b.nome, l.nome` com **`setMaxResults(10)`** —
então, com o dicionário de verdade, a rua do cenário cai fora dos dez primeiros,
porque o estado dela se chama `ESTADO CARACTERIZACAO` e vem depois de Acre e
Alagoas.

**Onde isso deixa o teste:** ele passa no CI (dicionário com 2 ruas), passa no
MySQL local (dicionário com 5) e falha contra o dicionário real — que é a condição
de produção, com 16.203 ruas segundo o docblock do próprio arquivo. Ele guarda o
`LOWER()` do **D120**, que é uma guarda que vale; o que não vale é depender de a
lista ser curta.

**A correção não foi feita aqui de propósito:** mexer nos cinco casos de um teste
que guarda o D120 merece mudança própria, e nenhum caminho que o projeto de fato
executa está vermelho hoje. O conserto provável é buscar por um termo que só a rua
do cenário case, em vez de um que a cópia case milhares de vezes.

#### O conserto do achado 3 — CORRIGIDO em 05/10/2026

**Medido antes, no `franquiabv_dev` do PostgreSQL, só leitura e só contagem:** 709.440
ruas; "presidente" casa 2.520; o nome inteiro, "presidente vargas", **237** — e **37**
delas em estados que vêm antes do "ESTADO CARACTERIZACAO" (são oito). Então o termo
"que só a rua do cenário case" não existia: "AVENIDA PRESIDENTE VARGAS" é nome de rua de
verdade, e nenhum pedaço dele é só do cenário. "caracteriza" casa **zero**.

**O conserto, nos dois lados:**

| | |
|---|---|
| `tests/Caracterizacao/fixtures.sql` | as duas ruas passam a terminar em "DA CARACTERIZACAO" — "AVENIDA PRESIDENTE VARGAS DA CARACTERIZACAO" e "RUA SÃO JOÃO DA CARACTERIZACAO", em maiúsculas como os Correios escrevem; o bloco diz por quê |
| `BuscaDeLogradouroTest` | cada termo carrega esse final ("presidente vargas da caracteriza", "são joão da caracteriza", "sao joao da caracteriza"); a caixa e o acento continuam medidos como antes — é a guarda do `LOWER()` do D120, que segue sendo o que o arquivo guarda |
| `testODicionarioLongoNaoTiraARuaDoCenarioDaResposta` | caso novo: dentro da transação, um estado "ACRE ..." com onze ruas de cada nome de antes; o controle exige que, com os termos de antes, a rua do cenário SAIA das dez, e com os de agora a resposta seja ela e só ela |

**Provado contra o dicionário real**, que era a condição que faltava: num banco
PostgreSQL descartável (schema, fixtures de hoje e as quatro tabelas de endereço do
`franquiabv_dev` — só as linhas reais, lidas por `COPY (SELECT ...)` —, apagado no
fim), os oito casos verdes; e com "presidente" de volta no lugar do termo novo, cinco
ficam vermelhos — os três de caixa, o do endereço inteiro e o do dicionário longo —, o
sintoma que este registro media.

**Falsificado no banco de teste**, um mutante por vez e restaurado byte a byte: sem o
`LOWER()` da consulta, seis casos vermelhos no PostgreSQL (os quatro de caixa e de
acento, o do endereço inteiro e o do dicionário longo) e nenhum no MySQL, cuja
collation ignora caixa — o mesmo de antes, e o arquivo o diz; com o termo de antes, o
caso do dicionário longo vermelho nos dois bancos.

**O sexto vermelho, consertado na verificação do mesmo dia.** A primeira versão desta
seção o deixava de fora ("não foi remedido aqui") e o atribuía ao
`ClienteRepository::autoComplite`. Errado nas duas coisas: o achado 3 conta "cinco casos
dele e um do `BuscaDeClienteComAcentoTest`", e o caso é
`testSemOAcentoSoOMysqlAcha`, que no PostgreSQL afirmava **zero** num
`SELECT COUNT(*) FROM clientes WHERE tags LIKE '%jose%'` — a tabela inteira, e não o
`autoComplite`. Medido no `franquiabv_dev` do PostgreSQL, só a contagem: **3.576**. É o
"verde por ausência de dado" do título, e com o avesso junto: o caso da coluna
normalizada passaria contra a cópia sem normalizar nada, achado pelos 3.576.

| | |
|---|---|
| `BuscaDeClienteComAcentoTest::quantos()` | conta só o cliente acentuado do cenário (`id = 90001030`); os quatro casos que a usam afirmam 1 ou 0, e não "pelo menos 1" |
| `testUmJoseSemAcentoDeOutroClienteNaoMudaOQueSeAfirmaDoCenario` | caso novo: dentro da transação, um cliente de outra unidade com "jose" sem acento, como a cópia tem aos milhares; o controle exige que a tabela inteira o veja, e o caso continua dizendo do cenário o mesmo de antes |

**Falsificado** nos dois bancos, um mutante por vez e restaurado byte a byte: com a conta
de volta na tabela inteira, o caso novo fica vermelho no MySQL (2 onde afirma 1) e no
PostgreSQL (1 onde afirma 0). Os dois casos do arquivo que passam pelo `autoComplite`
não dependem disso: ele parte o termo em palavras, e no `franquiabv_dev` do PostgreSQL
nenhum cliente fora da faixa do cenário tem "jose" (com ou sem acento) e "acentuado"
juntos — contado em 05/10/2026, só a contagem: zero.

---

### D174 — O contrato congelado descreve `data_venda` como texto, e a produção sempre manda nulo — ANOTADO em 12/09/2026

Achado ao acrescentar duas indicações ao cenário para poder testar os contadores
do relatório de indicações. A suíte de contrato ficou vermelha:

    O contrato do endpoint 'indicacoes-listar-autenticado' mudou.
    -   "data_venda": "string",
    +   "data_venda": "null",

**A guarda funcionou, e o que ela revelou foi o contrário do esperado: quem estava
errado era o instantâneo.**

#### A medição

| | |
|---|---:|
| indicações na cópia de produção (fora do cenário) | 6 |
| … com `data_venda` **nula** | **6** |
| … com `data_adesao` nula | 4 |

**Todas.** Em produção, `data_venda` é nula em 100% das indicações desta base — a
coluna só é preenchida quando a indicação vira venda, e nenhuma virou. O contrato
diz `"string"` porque o cenário de fixtures tinha **uma** indicação, completa e
artificial, com as três datas preenchidas.

O aplicativo instalado, portanto, **sempre recebeu `null` ali** — e o contrato,
que existe para congelar o que ele recebe, descrevia outra coisa. É o mesmo
*verde por ausência de dado* que apareceu três vezes em 12/09/2026, agora na
camada que protege o parque instalado.

#### E há uma limitação de mecanismo por baixo

`ContratoTestCase::estrutura()` reduz uma lista à estrutura do **primeiro item** —
*"já que todos os itens de uma lista devem ter o mesmo formato"*. Para campo
anulável isso não vale: o tipo gravado depende de qual linha vier primeiro, e o
mesmo endpoint congela `"string"` ou `"null"` conforme a ordenação e o cenário.
O formato não sabe dizer `string|null`.

Então **qualquer** valor congelado para `data_venda` estará errado em alguma
linha. Não é o instantâneo que está mal preenchido: é o vocabulário que não tem a
palavra.

#### O que foi feito, e o que NÃO foi

As duas indicações novas foram penduradas noutro cliente (90001020), fora do
endpoint congelado — o contrato voltou ao verde **sem ser alterado**. Virar um
contrato congelado é decisão de quem opera, e a própria mensagem da guarda diz
por quê: *"confirme antes que TODAS as versões do aplicativo em uso continuam
funcionando — inclusive as antigas"*.

**Três saídas, e a escolha é sua:**

1. **Atualizar o instantâneo** para `"null"`. É o que a produção manda hoje em
   100% dos casos, e portanto o mais fiel. Risco: se uma indicação virar venda, o
   campo volta a ser texto e a guarda acusa de novo — pelo motivo certo, mas
   acusa.
2. **Ensinar o contrato a dizer `string|null`**, varrendo a lista inteira em vez
   de olhar o primeiro item. É a correção de raiz, e alcança **todo** campo
   anulável de **todos** os 67 instantâneos — provavelmente mais de um mudaria.
3. **Deixar como está**, sabendo que este campo do contrato descreve um caso que
   a produção não produz.

A segunda é a que resolve a classe inteira, e é também a que mexe em mais coisa de
uma vez.

### D175 — A ordem alfabética de uma lista muda entre os dois bancos, e não é por acento — ANOTADO em 12/09/2026

Encontrado por acidente, escrevendo um teste: `ComboDeConveniosNoRelatorioTest`
afirmava que a ordem devolvida pelo `ORDER BY con.nome` era igual à de `sort()` do
PHP. Ficou vermelho, e o vermelho não era do método — era da colação.

Seguir o fio levou a comparar os **mesmos dados nos dois bancos locais**,
ordenados pela mesma consulta:

```sql
SELECT nome FROM convenios WHERE status = true ORDER BY nome;   -- 616 linhas
```

| | |
|---|---:|
| linhas ordenadas | **616** nos dois |
| posições que trazem nome **diferente** | **376** |
| nomes fora de lugar | **32** |

**E a causa não é acento — é espaço e pontuação.** O D119 já descreve a colação de
*busca*: `utf8mb4_general_ci` ignora caixa e acento, e o PostgreSQL compara byte a
byte no `LIKE`. Este débito é sobre *ordenação*, que é outro eixo, e ali o
PostgreSQL **não** compara byte a byte: ele usa a colação do locale, que ignora
espaço e hífen no primeiro nível de comparação. Medido nas duas primeiras linhas da
lista:

| | MySQL | PostgreSQL |
|---|---|---|
| 1ª | `" ATIVA PLAY FITNESS"` (começa com espaço) | `"360 crédito"` |
| 2ª | `" Itapiruba Esporte Clube"` (idem) | `"AAAD-UFPR"` |

Dois convênios têm nome **começando com espaço**. O MySQL trata o espaço como
caractere e os põe à frente de tudo; o PostgreSQL o descarta e os arquiva sob A e
sob I. O mesmo acontece com o hífen em `"ADEPOL - Delegados da Policia Civil"` e
`"ARC TOUCH"`.

**O tamanho disto não é o combo de convênios.** Toda lista ordenada por nome muda
de ordem: combos de unidade, de grupo, de produto, de colaborador, listagens de
relatório, e o que o aplicativo recebe em ordem. Nenhum número muda — a mesma linha
está lá —, mas a **posição** muda, e há telas paginadas: quem procura na página 1
pode passar a achar na 2, que é a forma do defeito já visto no VipClass.

**Por que fica anotado e não corrigido agora:** são três caminhos, e a escolha é de
quem opera.

1. **Criar as colunas com uma colação que imite o MySQL** (`ICU`, com o nível de
   pontuação configurado para não ser ignorado). Resolve na raiz e muda o esquema.
2. **Normalizar os dados** — tirar espaço das pontas de `convenios.nome` e dos
   outros nomes. Conserta estes 32 casos e não a regra; e é alteração de dado de
   produção, que por decisão do dono só acontece na migração final.
3. **Deixar como está**, sabendo que as listas saem em ordem um pouco diferente no
   dia do corte.

**O que já está garantido:** nenhum teste desta suíte afirma a ordem inteira de uma
lista; os que medem `ORDER BY` afirmam a **posição relativa de dois nomes
conhecidos**, que é igual nos dois motores. Sem isso, a migração teria ganhado uma
fileira de vermelhos que não eram defeito.

### D176 — A consolidação de NPS divide por zero se a primeira pesquisa do mês não tiver item — CORRIGIDO em 01/10/2026

`CronJobService::npsContabilizar()` calcula a nota de colaboradores da unidade
assim:

```php
$totalizadoresColaboradores = $repoNpsColaborador->selecionarTotalizadoresNotasPorUnidadeMesAno(...);
$resultadoUnidade->setNotaColaboradores($totalizadoresColaboradores['nota'] / $totalizadoresColaboradores['numeroAvaliacoes']);
```

O divisor vem de um método que devolve **`0` quando não há linha** — é o
`!empty($totalizadores['numeroAvaliacoes']) ? ... : 0` que todos os repositórios de
NPS usam. No PHP 8, `0 / 0` lança `DivisionByZeroError`.

**Quando é alcançável.** O laço de itens, logo acima, persiste uma linha de
colaborador para o mês corrente — então depois dele o divisor é ≥ 1. A divisão só
encontra zero se **o laço não rodou**, isto é, se a pesquisa não tem nenhum item, e
se nenhuma outra linha de colaborador existir para aquela unidade naquele mês.
Medido na cópia local em 12/09/2026: **1 pesquisa sem item nenhum** em 37.957. O
par (pesquisa sem item) × (primeira do mês para a unidade) não ocorreu ainda.

**O que acontece se ocorrer.** O `npsContabilizar` tem `try { } finally { }` e
**nenhum `catch`**: a trava de execução é liberada corretamente — isso está certo —,
mas o erro sobe e a rotina aborta no meio. As pesquisas já processadas ficam
consolidadas, as seguintes não, e a próxima passada tenta de novo a mesma pesquisa
sem item e aborta no mesmo lugar. **Fica em laço de falha até alguém olhar.** E a
rotina é disparada por URL a cada 10 minutos (ver `f1-6-agendamento.md`), então o
sintoma seria "o NPS parou de fechar" sem nada na tela.

**Ao resolver:** guardar o divisor, não a divisão — `numeroAvaliacoes` zero
significa "não há nota de colaborador neste mês", e o valor correto a gravar é zero,
não um erro. Uma linha:

```php
$media = empty($tot['numeroAvaliacoes']) ? 0 : $tot['nota'] / $tot['numeroAvaliacoes'];
```

Não foi feito agora porque a migração é de caracterização: a correção muda
comportamento num caminho que hoje não é percorrido, e o lugar dela é junto com a
revisão das rotinas de cron, não no meio da cobertura de relatórios. O que existe
hoje é este registro e a medição que o sustenta.

#### CORRIGIDO em 01/10/2026

Guardado o divisor, como esta entrada pedia: `numeroAvaliacoes` zero grava `nota_colaboradores`
zero -- o mesmo valor com que `NpsResultadoUnidade` nasce -- e a rotina segue para a pesquisa
seguinte; a primeira pesquisa com item do mês recalcula a média. A guarda cobre um caso a mais do
que esta entrada descrevia: a consulta filtra pela unidade do COLABORADOR, então uma pesquisa com
item cujo colaborador é de outra unidade também chegaria a zero (0 na cópia hoje).

**Provado:** `D176NpsSemItemTest` (2 casos, nos dois bancos), chamando `npsContabilizar` pelo
serviço, como `CronTravaSimultaneaTest`: a pesquisa sem item, primeira do mês, grava zero, conta 1
pesquisa e sai da fila; com uma nota de colaborador de 8 no mês, a média continua 8. A divisão de
antes deixa o primeiro em `DivisionByZeroError`; gravar zero sempre deixa o segundo vermelho (0
contra 8). **Medido de novo na cópia:** a mesma 1 pesquisa sem item em 37.957, já contabilizada --
nenhuma na fila. A rotina rodou só pelo teste, no banco de teste.

**O que ficou:** enquanto o mês da unidade só tiver pesquisa sem item, a nota geral é a média das
três parcelas com esse zero dentro (`setNotaGeral`). A frequência medida é zero, e mudar a média é
regra de negócio, e não este débito.

### D177 — A cobertura era medida por NOME de método, e nome não é método: 179 pares atrás de 140 nomes — MEDIDO E COM PORTÃO em 12/09/2026, e o PORTÃO APERTADO em 15/09/2026, que estava verde casando por substring — PARCIAL, lido em 05/10/2026 — PARCIAL em 08/11/2026: dinheiro e vazamento entre unidades com teste; ficam os outros pares

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como medido, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: cobrir os pares sem menção em teste (64 em 15/09) e os 25 falsos positivos, dinheiro primeiro; `UnidadeRepository::listagemIndex` segue na fila de vazamento.
>
> **06/10/2026:** a medida passou a ler os testes sem comentários; com isso, 148 pares sem menção e 26 falsos positivos (ver "06/10/2026: os quatro métodos por cliente da agenda cobertos", no fim deste registro).

Este débito nasce de um erro de **medição**, não de código. Ele foi aberto no dia
em que a lista dos relatórios fechou, porque foi fechando-a que o erro apareceu: o
inventário contava **nomes** de método, e um teste que citasse `listagemIndex`
fazia `listagemIndex` contar como coberto.

`listagemIndex` é **declarado em 19 repositórios**, com 19 corpos diferentes — de
18 a 37 linhas cada, nenhum herdado, todos estendendo direto o `EntityRepository`
do Doctrine. `popularCombobox` está em 13. São consultas distintas, com filtros
distintos, contadas como uma.

#### A medição, e ela foi refeita do zero

**Os números desta seção são da PRIMEIRA medição, e ela estava incompleta.** Ficam
como estavam porque a contraprova mais adiante os corrige com motivo, e apagá-los
esconderia o erro que vale mais que o acerto: o glob pegava 63 controllers e havia
**84**. O número final é **178 pares atrás de 139 nomes**.

Varrendo os **63 controllers** então visíveis e resolvendo cada chamada de
repositório à classe concreta:

| | |
|---|---:|
| nomes de método de repositório chamados por controller | **135** |
| **pares (classe, método)** por trás desses nomes | **173** |
| nomes que existem em mais de uma classe | 15 |
| pares alcançados **só** pela forma de variável (`$r = getRepository(...)`) | 27 |
| chamadas que o resolvedor não atribui a uma classe | 6 |

E a cobertura, pela unidade certa:

| | |
|---|---:|
| pares cujo método não aparece em **nenhum** teste | **70** |
| pares cujo nome aparece em teste e **nunca junto da própria classe** | **20** |
| pares com nome e classe no mesmo teste (candidatos a coberto) | 83 |

Pela medida antiga o sistema parecia ~83 de 135 coberto. Por par, **90 de 173
(52%) estavam descobertos ou contados errado**.

#### Os 20 falsos positivos, nomeados

São estes, e a lista está no portão para não crescer em silêncio:

`AgendaFechamentoRepository::listagemIndex`, `AppBannerRepository::listagemIndex`,
`AreaAtuacaoRepository::popularCombobox`,
`ArquivoCategoriaRepository::popularCombobox`, `ArquivoRepository::listagemIndex`,
`ConvenioRepository::listagemIndex`, `ConvenioUnidadeRepository::convenioAtivo`,
`CupomRepository::listagemIndex`, `EstadoRepository::popularCombobox`,
`NotificacaoRepository::listagemIndex`, `PesquisaRepository::selecionarPorIdHash`,
`PrivilegioRepository::listagemIndex`, `ProdutoEstoqueRepository::listagemIndex`,
`ProdutoRepository::popularCombobox`, `ProdutoTagRepository::listagemIndex`,
`ProdutoTagRepository::popularCombobox`, `RoyaltiesFaturaRepository::listagemIndex`,
`SorteioRepository::listagemIndex`,
`UsuarioComissaoExtraRepository::selecionarPorUsuario`,
`VisagismoRepository::listagemIndexRelatorio`.

#### O MEDIDOR MENTIU PRIMEIRO, e isso vale mais que o resultado

A primeira rodada do resolvedor devolveu **9 pares e 468 chamadas ilegíveis**. 408
delas com o motivo "argumento do getRepository não é reconhecível" — número que, num
sistema que roda em produção, acusa o instrumento e não o medido.

A causa: **o PHP 8 junta o nome qualificado num único token.**
`\ISCadastro\Entity\Cliente` era `T_NS_SEPARATOR` + `T_STRING` no PHP 7 e é
`T_NAME_FULLY_QUALIFIED` no 8; o laço procurava um `T_STRING` antes do `::` e não
achava nenhum. Corrigido para tratar os quatro tokens de nome, o resultado foi 173
pares e 6 ilegíveis.

Por isso o resolvedor **conta o não resolvido à parte e nunca soma como resolvido**.
As 6 de hoje foram lidas à mão, e as quatro classes que elas revelaram **já estavam
no conjunto** por outro sítio de chamada: o ponto cego é real e está vazio.

#### O PORTÃO, e o que ele deliberadamente não afirma

`scripts/conferir-cobertura-por-par.php`, ligado ao `conferir-tudo.sh` como o 34º
portão. Ele afirma só o que é mecânico:

1. o **conjunto** de pares é o declarado — por contagem **e por sha1**, porque
   contagem sozinha não pega troca de um par por outro;
2. a contagem de chamadas por módulo é a declarada — para um módulo não perder
   chamada enquanto outro ganha;
3. o número de pares sem teste nenhum é **exatamente** o declarado;
4. a lista dos 20 falsos positivos não cresce.

Ele **não** afirma que os 83 restantes estão cobertos: "nome e classe no mesmo
arquivo" é positivo fraco, e dizer o contrário repetiria o erro que o portão existe
para impedir.

**E o item 3 foi corrigido no próprio dia.** A primeira versão comparava com `>`,
para não ficar vermelha quando alguém melhorasse a cobertura. Parecia gentil e era
erro: ao fechar o primeiro vão, o número caiu de 70 para 69 e **o portão continuou
verde**. Com igualdade exata, fechar um vão obriga a descer a base no mesmo commit —
e foi o que aconteceu, medido: o portão ficou vermelho pedindo isso.

#### QUATRO VÃOS FECHADOS, e os quatro movem dinheiro

O critério da casa: dinheiro primeiro.

| par | arquivo | o que ele decide |
|---|---|---|
| `VendaPagamentoRepository::totalPagamento` | `TotalDoPagamentoDaVendaTest` | quanto de crédito do cliente o sistema **debita** — `setValor(getValor() - $creditoUsado)` |
| `ClienteDebitoRepository::selecionarValorDebitoCliente` | `ValorDoDebitoDoClienteTest` | quanto o cliente deve |
| `ClienteDebitoRepository::selecionarValorDebitosCliente` | o mesmo arquivo | idem — são **gêmeos** |
| `CaixaRetiradaRepository::selecionarRetiradasPorCaixa` | `RetiradasDoCaixaTest` | a sangria que entra no fechamento do caixa |

De 70 para **66**. Cada caso foi falsificado: neutralizar o filtro medido deixa
vermelhos exatamente os casos que dependem dele.

##### Os gêmeos são duplicata, e a diferença entre eles é o eixo da migração

`selecionarValorDebitoCliente` e `selecionarValorDebitosCliente` estão lado a lado
no mesmo arquivo, com nomes a **uma letra** de distância, e fazem a mesma coisa.
A única diferença:

```
singular   " WHERE deb.status = :status AND ..."   parâmetro, com true
plural     " WHERE deb.status = true AND ..."      literal dentro da DQL
```

É a família do D81/D97 — e a forma literal é justamente a que escapa do portão do
D81, que confere valor ligado a parâmetro. Eles são chamados de telas diferentes, e
quem consertar um não saberia que o outro existe. O primeiro caso do teste chama os
dois e exige o **mesmo número**, em três combinações de argumento; falsificado
trocando o literal do plural para `false`, ele fica vermelho.

##### E um caso meu prometia mais do que provava

O teste de `totalPagamento` foi escrito afirmando que uma segunda linha da mesma
forma de pagamento exercitava o `GROUP BY vp.formaPagamento`. **Não exercitava.**
Apagando o `GROUP BY` do repositório, os cinco casos continuam verdes — com ele a
consulta devolve uma linha por forma e o PHP as soma, sem ele devolve uma linha já
somada, e o resultado é o mesmo sempre.

O docblock foi corrigido para dizer o que o caso prova — que as duas linhas contam,
e contam uma vez só — e para registrar que o `GROUP BY` é **redundante**, medido.
Teste que parece provar mais do que prova é pior que teste ausente: ele convence.

#### O ESCOPO, e a consequência dele

"Chamado por controller" é o critério herdado do D10: a superfície que o usuário
alcança. Método chamado só por serviço, cron, view `.phtml` ou outro repositório
**não** entra na conta. Isso não é descuido, e a consequência é real — foi assim que
`NpsItemRepository::selecionarTotalizadoresPorColaboradorMesAno` entrou na lista dos
relatórios sendo chamado apenas pelo `CronJobService`.

#### A CONTRAPROVA: a mesma coisa medida de outro jeito, e as duas estavam erradas

Enquanto o portão era escrito, 13 agentes resolveram os mesmos pares **lendo o
código** e julgaram cada um **lendo os testes**. Duas medições independentes da
mesma coisa, para que a divergência aponte o erro. Apontou três, e dois eram meus.

##### Sobre o CONJUNTO de pares: meu resolvedor era subconjunto estrito do deles

| | |
|---|---:|
| meu, mecânico | 173 |
| deles | 228 |
| dos quais método do **Doctrine** (`find`, `findBy`, `findOneBy`…) | 48 |
| código do projeto | 180 |
| pares só no meu | **0** |
| pares só no deles | **7** |

**Zero pares só no meu lado**: o resolvedor mecânico não inventou nenhum. E dos 7
só deles, cinco eram meus erros e dois eram deles:

| par | veredito |
|---|---|
| `AgendaRepository::selecionarHorariosFilhos` | **meu erro** |
| `AgendaRepository::selecionarPorColaboradorDataHora` | **meu erro** |
| `AgendaRepository::selecionarPorUnidadeData` | **meu erro** |
| `FormaPagamentoRepository::popularCombobox` | **meu erro** |
| `VendaRepository::selecionarComandasAbertas` | **meu erro** |
| `LogradouroRepository::buscarEndereco` | erro deles — chamador é um **Service**, fora do escopo declarado |
| `VendaProdutoRepository::pesquisaNpsLocalizarCliente` | erro deles — e **virou o D178**: o método não existe |

**MEU ERRO ERA UM GLOB.** `Controller/*Controller.php` pega 63 arquivos e deixa
**21** de fora: 18 em `Controller/Adm/` e 3 em `Controller/V4/`. Um quarto da
superfície de controllers, perdido por um asterisco. Corrigido, o portão foi de 173
para **178 pares** — exatamente os cinco, e nenhum saiu. 180 deles menos os 2
indevidos dá 178: **a aritmética fecha nos dois lados.**

##### Sobre a COBERTURA: eles estavam certos, e o limite é estrutural do meu método

Cruzando meu balde mecânico com o veredito deles, par a par:

| meu balde | veredito deles | pares |
|---|---|---:|
| candidato (nome **e** classe no teste) | coberto | 74 |
| candidato | **descoberto** | 19 |
| sem menção do nome | coberto | **16** |
| sem menção | descoberto | 47 |
| só o nome, sem a classe | coberto | 1 |
| só o nome | descoberto | 19 |

Os **16 "sem menção mas coberto"** pareciam erro deles, e não eram. A evidência que
devolveram é específica e foi conferida:

- `UnidadeRepository::paisesSelecionar` — `tests/Contrato/AplicativoApiTest.php::testPaisesSelecionar`
  faz `GET /api/paises/selecionar`, e `AplicativoController::paisesSelecionarAction:467`
  chama o método. **Conferido linha por linha.**
- `PontuacaoHistoricoRepository::selecionarPontuacaoValidadePorClienteUnidade` — o
  nome não aparece em teste nenhum, e `FidelidadePontuacaoTest` chama
  `ClienteService::atualizarPontuacao()`, que o chama em `ClienteService.php:318`.
  **Conferido.**

Ou seja: **a cobertura pode ser INDIRETA** — por rota HTTP no contrato, ou por um
serviço no meio —, e casamento de substring é estruturalmente cego a isso. Não é um
parâmetro a ajustar; é o que o método não alcança.

**Por isso o `SEM_MENCAO_ESPERADA` do portão não significa "descoberto".** Ele
significa o que a constante diz: o nome do método não aparece em nenhum `.php` de
teste. É sinal mecânico, serve para pegar regressão, e **16 dos 64 de hoje estão
exercitados por caminho indireto**. Dizer 64 descobertos seria exatamente o tipo de
número que não vale.

##### E duas armadilhas da própria contraprova, ditas porque custaram tempo

**A primeira foi minha.** O script do fan-out pedia o nome da classe aos
resolvedores como *nome do arquivo* e aos juízes de outro jeito; na junção, **zero**
dos 225 vereditos casou, e o resultado voltou com "0 cobertos, 0 descobertos".
Recuperei tudo do diário do run e refiz a junção normalizando a chave — nome base,
sem `.php` —, e **225 de 228 casaram**. O dado estava lá; a junção era o defeito. A
crítica final do fan-out, que foi alimentada com a lista vazia, concluiu que o risco
de falso negativo era "zero por construção": essa conclusão é artefato do meu erro e
foi descartada. As **medições** dela, não — foram conferidas uma a uma.

**A segunda é de tempo.** O fan-out leu `tests/` como estava **uma hora antes**, e
por isso julgou descobertos quatro pares que eu cobri nesse intervalo
(`selecionarRetiradasPorCaixa`, os dois gêmeos do débito e `apagarVendaTemporaria`).
São 4 dos 19 "candidato mas descoberto" — sobram 15 discordâncias reais. Medição
assíncrona tem data, e a data entra no resultado.

##### O que a crítica do fan-out acrescentou, conferido

Cinco afirmações, todas medidas antes de entrar aqui:

| afirmação | medido |
|---|---|
| as views `.phtml` seriam um vão | **0 de 171** usam `getRepository` — vão **vazio** |
| "coberto por teste de rota" dentro da caracterização | **0 de 93** arquivos chamam `dispatch(` — os testes de rota estão no **contrato** |
| o cron é invisível a este escopo | `CronJobController` tem **0** `getRepository`; as 24 rotinas moram no Service |
| `relatorioFranquias` é homônimo **e** dinheiro | **confirmado**: `RoyaltiesRepository:45` e `VendaPagamentoRepository:97`, mesma assinatura `($request, $unidadeId)` |
| `popularComboboxAgenda` decide quais unidades a agenda oferece | chamado **só** de `Form/AgendaIndex.php:18` — fora do escopo, e é a própria superfície do recorte |

As duas primeiras **poupam trabalho**: são vãos que o escopo nomeia e que estão
vazios. As três últimas são fila, e a terceira explica por que
`NpsItemRepository::selecionarTotalizadoresPorColaboradorMesAno` enganou o
inventário dos relatórios.

#### Ao resolver

Os **64** restantes são trabalho sem decisão pendente, e a ordem é a do critério:
dinheiro, depois vazamento entre unidades, depois o resto. Entre os que sobram com
cara de dinheiro: `VendaRepository::extratoCreditoCliente`,
`SaidaParcelaPagamentoRepository::selecionarPagamentosPorSaida`,
`VendaRepository::selecionarVendasPorConvenio` e
`PontuacaoHistoricoRepository::selecionarPontuacaoValidadePorClienteUnidade`.

E os **20 falsos positivos** são mais baratos do que parecem: metade é
`listagemIndex` de tela de administração, e nove das 19 `listagemIndex` não filtram
unidade — **conferido, e nenhuma das nove tem coluna de unidade na tabela**, então
são globais por esquema e não por omissão. A décima, `ProdutoEstoqueRepository`,
recorta pelo produto, e o chamador valida a unidade do produto antes de chamar.

#### O portão estava VERDE e mentindo — apertado em 15/09/2026

Ele casava por `strpos` em três lugares, e substring credita cobertura que não
existe. A tese deste débito é *"nome não é método"*; faltava dizer **"prefixo não
é nome"** e **"o nome de uma classe não atesta outra"**.

**Primeira forma — o prefixo credita o nome.** Dois pares eram dados por
mencionados sem que o método aparecesse em teste nenhum:

| par | quem o creditava | é |
|---|---|---|
| `UnidadeRepository::selecionarUnidades` | `selecionarUnidadesRelatorioNps` (4×), `selecionarUnidadesRelatorioFranquias` (4×), `selecionarUnidadesAtivas` | outros **três métodos** da mesma classe |
| `ProdutoRepository::pesquisar` | `pesquisarcep` (7×), `pesquisarQuantidade`, `pesquisarCreditoDebitoConvenioAction` | idem |

Conferido à mão: nenhum dos dois aparece como identificador inteiro em `tests/`.

**Segunda forma — uma classe atesta outra.** `listagemIndex` aparece inteiro em
exatamente três arquivos (`ClienteApiTest`, `DevedorTest`, `SaidaFinanceiroTest`),
e em **nenhum** deles `Usuario`, `Unidade`, `Grupo` ou `Pesquisa` aparece como
identificador — o que os creditava eram tokens como
`testAListagemNaoAtravessaAUnidade` e `getUsuario`. Cinco pares saíam por essa
porta, e **é o erro deste próprio débito um nível acima**: a evidência de uma
classe atestando outra.

**E as duas folgas se compõem.** Medido pelas mutações: revertendo só a classe,
ou só o método, saem os mesmos **três** (`UnidadeRepository::listagemIndex`,
`UsuarioRepository::listagemIndex`, `UsuarioRepository::popularCombobox`). Os
outros dois — `GrupoRepository::listagemIndex` e `PesquisaRepository::listagemIndex`
— só são creditados quando **as duas** afrouxam juntas: o método frouxo traz
arquivos a mais para a lista, e a classe frouxa acha o nome dentro deles.

| | antes | depois |
|---|---:|---:|
| pares chamados por controller | 179 | 179 |
| sem menção em teste nenhum | 62 | **64** |
| nome sem a própria classe | 21 | **26** |

**A subida de 62 para 64 não é regressão.** Nenhum teste foi apagado: o
instrumento parou de mentir. Está escrito assim no docblock da constante, para
que a próxima pessoa não leia o número como perda de cobertura.

#### A catraca que faltava, e o argumento já estava escrito ao lado

`FALSOS_POSITIVOS` era comparada só numa direção —
`array_diff($soNome, FALSOS_POSITIVOS)` —, então detectava crescer e nunca
encolher. Cobrir um dos 26 deixava o portão **verde com a lista desatualizada**,
e a regressão futura de volta para 26 ficava invisível.

É palavra por palavra o defeito que este mesmo débito conta ter corrigido na
constante irmã (`SEM_MENCAO_ESPERADA`, que ganhou as duas direções): *"uma linha
de base que só olha para cima protege menos do que diz"*. O argumento estava
escrito a trinta linhas de distância e não tinha sido aplicado aqui.

Entrou também `'ISBase' => 0` em `CHAMADAS_POR_MODULO`: o módulo tem três
controllers e zero `getRepository` hoje, e sem o zero declarado ele não aparece
no `foreach` — uma chamada nova ali cairia no total sem dono, e a conta por
módulo continuaria fechando.

#### O "16 de cobertura indireta" é de 12/09, e não é contagem de hoje

A leitura par a par daquele dia achou **16 dos 64 de então** exercitados por
caminho indireto. Aquela lista desceu para 62 com os testes do D178/D179/D184 e
voltou a 64 agora **por outro motivo**. São 64 pares diferentes. O portão passou
a imprimir a data e a ressalva em vez do número seco: vale como argumento — a
cobertura indireta existe e este método é cego a ela —, não como contagem
corrente. Refazer a leitura é trabalho deste registro, não do portão.

#### A falsificação: seis mutações, seis vermelhos

Cada arquivo restaurado byte a byte, com sha1 e `git status --porcelain` vazio
conferidos depois de cada uma.

| | o que afrouxou | esperado | obtido |
|---|---|---|---|
| F1 | só a classe volta a `strpos` | vermelho | 1 — 26 → **23** |
| F2 | só o método volta a `strpos` | vermelho | 1 — 64 → **62** e 26 → 23 |
| F2b | as duas juntas (o código de ontem) | os números de ontem | 1 — **62 e 21**, exatos |
| F3 | um teste "cobre" `UnidadeRepository::listagemIndex` sem cobrir nada | vermelho pela catraca nova | 1 — *"a lista desceu de 26 para 25"*; **e com a catraca removida, VERDE** |
| F4 | `RetiradasDoCaixaTest.php` renomeado | vermelho pela asserção antiga | 1 — 64 → **65** |
| F5 | uma chamada trocada num controller | vermelho pelo sha1 | 1 — sha1 e contagem, as duas |

**O F3 é o que prova a catraca**, e prova nos dois sentidos: mesma entrada,
vermelho com ela e **verde sem ela** — o verde imprimindo *"25 (base 26)"* e
saindo com 0. O defeito em uma linha.

O F4 existe para provar que o aperto **não quebrou o que já protegia**: a
asserção de 12/09 continua pegando teste que some.

#### O que continua aberto

Os **64 pares sem menção** e os **26 falsos positivos**. A ordem é a da casa —
dinheiro, depois vazamento entre unidades, depois o resto. Os candidatos de
dinheiro que sobram já estão nomeados acima nesta entrada:
`SaidaParcelaPagamentoRepository::selecionarPagamentosPorSaida`,
`VendaRepository::selecionarVendasPorConvenio`,
`PontuacaoHistoricoRepository::selecionarPontuacaoValidadePorClienteUnidade` e
`CupomRepository::selecionarCuponsGerados`.

E o aperto **acrescentou dois à fila de vazamento entre unidades**:
`UnidadeRepository::listagemIndex` e `UsuarioRepository::listagemIndex` — que
até ontem pareciam cobertos e não estavam.

#### 28/09/2026: o F3 aconteceu de verdade, por um teste legítimo

A fatia dos convênios trouxe `ConvenioUnidadesTest`, que chama
`getRepository(Convenio::class)->listagemIndex` — e isso tirou
`ConvenioRepository::listagemIndex` dos falsos positivos com razão (28 → 27) — e
também `getRepository(Unidade::class)->popularCombobox`. Medido por arquivo,
`Unidade` e `listagemIndex` apareciam juntos, e `UnidadeRepository::listagemIndex`
saía da lista **sem cobertura nenhuma**: é o F3 desta entrada, desta vez sem ninguém
fabricar. A catraca ficou vermelha, como devia — e a mensagem dela ("isso é BOM, o
par ganhou teste") estava certa para um dos dois e errada para o outro.

O portão ganhou `COINCIDENCIAS` (par ⇒ arquivo ⇒ motivo): o arquivo listado não
conta para aquele par, só para ele, e a lista tem catraca própria — se a
coincidência sumir do arquivo, vermelho pedindo para tirá-la. Falsificado em cópia:
sem a entrada, 27 → 26 e vermelho; com uma entrada inventada, "a coincidência
sumiu" e vermelho; sem o `continue` que a pula, 26 e vermelho. Os pares foram de
288 para 293, os cinco dos convênios, todos com teste.

`UnidadeRepository::listagemIndex` continua na fila de vazamento entre unidades.

#### 04/10/2026: `UsuarioRepository::popularCombobox` coberto (a revisão do USUARIOS-F0)

O `UsuarioApiTest` passou a perguntar a lista de colaboradores da agenda
(`getRepository(Usuario::class)->popularCombobox(null, $unidade, 'todos', true)`) para
os quatro valores de `visivel_agenda`, o fora da lista e os dois estados, contra a API
e a grade (a revisão do D345). O par saiu dos falsos positivos (26 → 25) **coberto de
verdade**, e as duas coincidências dele (`UsuarioAtivacaoTest` e
`ProfissionalParceiroTest`) saíram de `COINCIDENCIAS`: a medida para no primeiro
arquivo que cobre o par, e a catraca pediu para tirá-las. Os pares foram de 314 para
317 -- os três novos de `UsuarioRepository` da revisão do D349, todos com teste que
chama a própria classe.

#### 05/10/2026: as duas listas da tela de serviços cobertas (o D202)

`D202EdicaoPelaRotaNoErpTest` monta o formulário de editar serviço como o controller,
com `getRepository(AreaAtuacao::class)->popularCombobox()` e
`getRepository(ProdutoTag::class)->popularCombobox()`, e a catraca desceu de 25 para 23.
**A chamada sozinha não era cobertura**: os dois `select` têm
`disable_inarray_validator`, e o caso da edição passaria com a lista vazia. Antes de
tirar os pares, o arquivo ganhou `testAsListasDaTelaDeServicoSaoAsAtivas`, que afirma o
que as duas trazem — a área e as etiquetas ativas, pelo nome, e não a desativada que o
caso cria —; com o filtro de `status` trocado em cada repositório, um mutante por vez,
o caso fica vermelho. Os dois saíram de `FALSOS_POSITIVOS` **cobertos de verdade**; os
pares continuam 320, e os sem menção, 146.

**A metade das áreas não estava presa (revisão de 05/10/2026).** "Não a desativada" só
valia para as etiquetas: o caso criava uma etiqueta desativada e nenhuma área, e as duas
áreas do cenário (90001000 e 3) são ativas. O mutante "trocado" (`status = false`)
esvazia a lista e fica vermelho; o filtro **arrancado** (`WHERE 1 = 1`) deixava a área
ativa lá, e a suíte ficava verde — medido, 12 de 12. O caso ganhou a área desativada
90202006 e afirma que ela não entra. Com o filtro arrancado, um mutante por vez e
restaurados pelo sha1, o caso fica vermelho nos dois repositórios (no das etiquetas,
`pro.status = :status OR 1 = 1`, porque o parâmetro tem de continuar ligado), no MySQL
e no PostgreSQL. A descida de 25 para 23 fica, e agora com as duas metades.

#### 06/10/2026: os quatro métodos por cliente da agenda cobertos (o fim do D189)

`DiaDoClienteNaAgendaTest` chama `AgendaRepository::horariosMarcadosPorClienteData`,
`::selecionarProximoAgendamentosCliente`, `::servicosMarcadosPorClienteData` e
`::agendaPorClienteUnidadeData` sobre um cenário que ele mesmo planta — o último dia de
um mês, com horários às 00:00:00 e às 23:59:59, a véspera, a virada, a outra unidade e
um inativo —, e **afirma os ids**: na ordem, nos três que têm `ORDER BY age.data`; na
comanda (`servicosMarcadosPorClienteData`), ordenados antes de comparar, porque ali a
ordem não está definida (o D426) — uma regressão de ordem na comanda **não** fica
vermelha neste arquivo (desde 07/10/2026 a ordem está escrita, e quem a prende é o
`D426OrdemDosServicosDaComandaTest`). Os sem menção desceram de 146 para 142.
Conferido tirando o arquivo do lugar: a contagem volta a 146, e a diferença são só
estes quatro. E a chamada não está sozinha: um mutante de fronteira em cada um dos
quatro (o fim fechado com `<=`, o início aberto com `>`, o fim esquecido) fica
vermelho no caso dele, no MySQL e no PostgreSQL.

**É a chamada, e não o docblock, que sustenta a descida — medido na revisão de
06/10/2026.** Tirar o arquivo do lugar não separava uma coisa da outra: o docblock do
`DiaDoClienteNaAgendaTest` cita os quatro métodos pelo nome, e o `use` traz `Agenda` e
`AgendaRepository`. A medida lia o arquivo inteiro, comentário incluído — e foi assim que
a primeira versão da descida do D190 se apoiou num docblock. **A medida passou a ler os
testes sem comentários** (`semComentarios()`, pelo `token_get_all`). Com ela, os quatro
da agenda continuam mencionados; e o cenário da revisão — as quatro chamadas a
`servicosMarcadosPorClienteData` trocadas por outro nome, o docblock intacto — fica
**vermelho** (149 contra a base de 148), enquanto o portão de antes ficava **verde** em 142.

**E a leitura sem comentários mexeu nas linhas de base, sem que teste nenhum mudasse:**

| | antes | depois |
|---|---:|---:|
| pares sem menção em teste | 142 | **148** |
| nome sem a própria classe (`FALSOS_POSITIVOS`) | 23 | **26** |
| `COINCIDENCIAS` | 4 | 3 |

Seis pares só eram "mencionados" em comentário e foram para "sem menção":
`CaixaRepository::selecionarPorUsuario`, `PrivilegioUsuarioRepository::selecionarPorUsuario`,
`ProdutoRepository::pesquisarQuantidade`, `VendaRepository::listagemIndexComandas` e os dois
que saíram de `FALSOS_POSITIVOS` (`UnidadeRepository::selecionarUnidadesAtivas` e
`UsuarioComissaoExtraRepository::selecionarPorUsuario`) — conferido par a par: o nome só
aparece dentro de `/** ... */` ou `//`. Cinco entraram em `FALSOS_POSITIVOS` porque a
própria classe, ou o nome no arquivo que a cita, só estava em comentário:
`GrupoRepository::popularCombobox`, `AssistenteRepository::listagemIndexAdmin`,
`MensagemRepository::listagemIndexAdmin`, `FuncionalidadeRepository::popularCombobox` e
`DashboardFaturamentoRepository::relatorio`. E a coincidência do `CupomTest` (a `Unidade`
num comentário antigo) deixou de existir, e saiu.

> **13/10/2026 (o item UNIDADES-GRUPOS-ROYALTIES):** `GrupoRepository::popularCombobox` saiu de `FALSOS_POSITIVOS` -- o
> `GruposQueONaoAdministradorPoeTest` o chama com a própria classe, nos dois bancos (o **D124**) --, e
> `UnidadeRepository::getUnidadeColaboradoresServicos` saiu de "sem menção", chamado pelo `D351UnidadeDesativadaTest` (de
> 146 para 145). A chamada não resolvida nova (de 2 para 3) é a `$vigencia->registrar()` de `UnidadeService`, um
> `VigenciaDaAliquota` feito com `new`, lida antes de a base subir. **Na revisão do mesmo item**,
> `EstadoRepository::popularCombobox` também saiu de `FALSOS_POSITIVOS` (de 25 para 24): o `D368VigenciaDaTaxaDeRoyaltyTest`
> o chama pelo repositório da própria classe, para montar o POST inteiro do formulário antigo da unidade -- conferido no
> teste, é a chamada, e não só o nome.

> **17/10/2026 (a fatia ACL-API):** `FuncionalidadeRepository::popularCombobox` saiu de `FALSOS_POSITIVOS` (de 23 para 22):
> o `MoldePelosControllersTest` o chama pelo repositório da própria classe e afirma que a grade do grupo aberto é o
> catálogo dele, sem "Vendas caixa", na mesma ordem. E entrou o par `GrupoRepository::atribuiveisPorQuemNaoEAdministrador`
> (de 334 para 335), chamado pelo `PrivilegioService` e citado, com a classe, no mesmo arquivo; o "sem menção" ficou em 130.

> **20/10/2026 (a fatia 21, BANNERS-SERVIDOR):** `AppBannerRepository::listagemIndex` saiu de `FALSOS_POSITIVOS` (de 22 para
> 21): o `TelasDosBannersDoErpTest` o chama pelo repositório da própria classe e afirma a ordem com o desempate pelo id. E
> entraram dois pares (de 338 para 340), `AppBannerRepository::listagemDaApi` e `::unidadesAtivas`, chamados pela
> `/adm/api/banners` e pelo nome, com a classe, no `BannersPelaApiTest`; o "sem menção" desceu de 130 para 129, porque
> `::selecionarBannersAtivos` -- a ordem do aplicativo -- passou a ser chamado por nome nos dois arquivos.

> **07/11/2026 (a fatia 109, SESSAO-LE-O-BANCO):** `ConvenioUnidadeRepository::convenioAtivo` saiu de `FALSOS_POSITIVOS` (de 22
> para 21): o `SessaoLeOBancoTest::testOConvenioDoDiaLeOsDiasDaUnidadeNoBanco` o chama pelo repositório da própria classe e
> afirma que os dias do convênio são os da unidade no banco, e não os do arranjo que chega (o D350).

> **07/11/2026 (a fatia 112, MARKETING-CAMPANHAS-E-SORTEIOS):** `NotificacaoRepository::listagemIndex` saiu de
> `FALSOS_POSITIVOS` (de 21 para 20): o `D618BuscaDasCampanhasSemAcentoTest` o chama pelo repositório da própria classe e
> afirma a busca por título e por texto da lista antiga das campanhas, com e sem a chave do acento (o D618). E saiu um par
> (de 456 para 455), `SorteioRepository::validarVencimento`, com a escrita no GET da lista antiga dos sorteios (o D334).

#### 08/11/2026: os três pares de dinheiro, o dos cupons e os dois `listagemIndex` (a fatia 126, PORTOES-E-COBERTURA)

**Os três de dinheiro** que este registro nomeava estão no `D177ParesDeDinheiroTest`, chamados pelo repositório da própria
classe sobre um cenário plantado (ids 90012600 a 90012699, dentro da transação), com os ids, a ordem e a soma afirmados:
`SaidaParcelaPagamentoRepository::selecionarPagamentosPorSaida` (o extrato da despesa: só o pagamento ativo, da parcela
ativa, da despesa pedida, do mais novo para o mais velho pela data, e não pelo id), `VendaRepository::selecionarVendasPorConvenio`
(só as situações 1 e 2, só o convênio pedido, da mais nova para a mais velha, e o teto recortando pela mesma ordem) e
`PontuacaoHistoricoRepository::selecionarPontuacaoValidadePorClienteUnidade` (só a linha `ativa`, do cliente e da unidade; sem
linha, zero inteiro -- as somas são potências de dez distintas, para que cada filtro arrancado dê um número que só ele dá).
`CupomRepository::selecionarCuponsGerados` **já estava coberto** pelo `CupomEmitidosTest` (os ids por filtro, desde o D266):
conferido com dois mutantes, o filtro do `utilizado` arrancado (C1) e o `validade >= :hoje` trocado por `>` (C2), vermelhos nos
dois bancos.

**Os dois `listagemIndex` da fila de vazamento** estão no `D177ListagensDeUnidadeEUsuarioTest`: na de usuários, o recorte de
quem não é administrador (a própria unidade, e ninguém do grupo de administradores; sem unidade, nada) e o administrador vendo
a rede ou a unidade que escolhe, mais a situação, o grupo e a ordem pelo nome com o desempate pelo id; na de unidades, a
situação, o país, o estado, a cidade e o bairro, e a ordem pelo lugar com o desempate pelo id, com e sem as colunas dos
acessos. Os acessos já tinham caso pelas telas (o `D76AcessosDaPropriaPessoaNasListasTest`).

**Os mutantes**, um por vez, cada um vermelho nos dois bancos pela asserção do caso e devolvido pelo sha1: E1 a E4 (o extrato:
a parcela, o pagamento, a despesa, a ordem), V1 a V4 (o convênio: a situação, o convênio, a ordem, o teto), P1 a P4 (a
pontuação: a situação, a unidade, o cliente, o nulo no lugar do zero), N1 a N5 (as unidades: a cidade, a situação, o bairro na
ordem, o estado, o bairro no filtro) e U1 a U4 (os usuários: o grupo de administradores, o ramo de quem não é administrador, a
unidade escolhida, a situação). O U3 da primeira rodada deu erro, e não falha (o parâmetro ficou sem uso e o Doctrine
recusou); refeito tirando o bloco inteiro, ficou vermelho pela asserção.

**As linhas de base do `conferir-cobertura-por-par.php` desceram:** "sem menção" de 103 para 100, `FALSOS_POSITIVOS` de 20 para
18, e a coincidência do `ConvenioUnidadesTest` (a única de `COINCIDENCIAS`) saiu, porque a catraca pediu.

**Resta:** os outros 100 pares sem menção e os 18 falsos positivos; a ordem continua a da casa (dinheiro, vazamento, o resto).

### D178 — O botão da pesquisa de satisfação chama um método que não existe: 500 na cara do cliente — CORRIGIDO em 13/09/2026

`/pesquisanps/localizarcliente` é rota **declarada**, **pública** e **quebrada**.

```php
// ISCadastro\Controller\PesquisaNpsController::localizarClienteAction(), linha 56
$repository = $this->em->getRepository(\ISVenda\Entity\VendaProduto::class);
$dados['cliente'] = $repository->pesquisaNpsLocalizarCliente($request['telefone'], $dataLimite);
```

**O método `pesquisaNpsLocalizarCliente` não existe.** `VendaProdutoRepository`
declara `pesquisaNpsLocalizarClientePorId` e `pesquisaNpsLocalizarColaboradores`, e
mais nenhum parecido — conferido por busca exaustiva em `module/`.

#### A cadeia, medida em três pontos independentes

1. **O método não está declarado em lugar nenhum** do projeto.
2. **O `__call` do Doctrine lança.** `vendor/doctrine/orm/src/EntityRepository.php:268`
   trata `findBy`, `findOneBy` e `countBy`, e para qualquer outro nome faz
   `throw new BadMethodCallException('Undefined method ...')`. Não há `__call` no
   repositório do projeto que intercepte antes.
3. **A rota responde 500.** Medido contra o ambiente local:

   ```
   GET  /pesquisanps                       -> 200
   POST /pesquisanps/localizarcliente      -> 500
   ```

   O POST levava só `telefone=(47) 99999-9999` — o `if` da ação exige 14 ou 15
   caracteres. A página irmã, que é a mesma tela, responde 200: não é ambiente.

#### O que isso significa — CORRIGIDO em 13/09/2026, e a correção é grave

**A primeira versão deste registro dizia que nenhuma tela chamava a rota. Estava
errado, e o erro era meu.**

A rota é **pública**: `/pesquisanps` é a pesquisa de satisfação que o cliente abre
por link, sem autenticação, e a guarda de rotas não a bloqueia — o 200 do `GET`
prova. E ela **é chamada pelo botão CONTINUAR dessa mesma tela**:

```js
// public/layout/scripts/mod-relatorios.js:258-267
function pesquisaNpsLocalizarCliente(campo) {
    $.ajax({
        url: "/pesquisanps/localizarcliente",
        type: "post",
        data: { telefone: $(campo).val() },
```

A pesquisa tem **dois caminhos de entrada**, e só um funciona:

| caminho | como se chega | estado |
|---|---|---|
| por link com `?cli=` e `?uni=` | o cliente recebe o link pronto | **funciona** — usa `pesquisaNpsLocalizarClientePorId`, que existe |
| digitando o telefone no teclado | o cliente abre `/pesquisanps` e digita | **500** — chama o método que não existe |

**Provado na tela, e não por leitura.** Abrindo `/pesquisanps` no navegador,
digitando um telefone no teclado numérico e clicando CONTINUAR: o `POST` sai, o
servidor responde **500**, e o cliente vê *"Erro ao localizar cliente."* — a
mensagem do `error:` do próprio AJAX. Reproduzido também por linha de comando, com
o mesmo telefone de 15 caracteres que o teclado monta.

#### POR QUE EU NÃO TINHA VISTO, e a lição é sobre a busca

Procurei por `localizar-cliente` e `localizarCliente`. A URL no JavaScript é
**tudo minúsculo** — `localizarcliente` — e a função se chama
`pesquisaNpsLocalizarCliente`, com prefixo. **Nenhum dos dois padrões casava com a
string que importava.** O `grep` devolveu zero e eu li o zero como ausência.

E o que achou foi **abrir a página**. É a máxima da casa — *curl verde não prova a
tela* — cobrando o preço de novo, agora sobre um registro que eu mesmo tinha
escrito com confiança.

Então o estado real é: **a funcionalidade está quebrada na cara do cliente**, não
morta num canto. Quantas pessoas tentam por ali em vez do link é coisa que o banco
não registra — a ação estoura antes de gravar qualquer coisa.

#### Como apareceu, e por que vale contar

Não foi revisão de código. Apareceu numa **divergência entre duas medições** da
mesma coisa: um fan-out de agentes listou
`VendaProdutoRepository::pesquisaNpsLocalizarCliente` como par chamado por
controller, e o resolvedor mecânico do D177 **não** — porque ele só registra o par
quando o método existe como `public function` na classe resolvida.

Os dois estavam certos: existe a chamada, e não existe o método. **O desencontro
era o defeito.**

#### COMO FICOU, e a escolha foi a primeira das quatro

**Implementado.** O que decidiu foi abrir a tela: `/pesquisanps` sem parâmetros é
**inteiramente** o teclado — logo, título, "Informe seu telefone", teclado numérico
e CONTINUAR, e mais nada. É o modo totem, de tablet de balcão: a tela se rearma
sozinha em 4 segundos e o logo entra em tela cheia.

Então as outras três opções caem por terra sozinhas. Tirar o teclado deixaria uma
página de marca **vazia**; trocar o 500 por "use o link que você recebeu" falaria
com quem não tem link nenhum; e deixar como está é o erro visível.

**E a objeção de privacidade que este registro levantou não se sustenta.** O
caminho que *funciona* — `?cli=N&uni=M` — é **enumerável**: basta incrementar o id.
O caminho quebrado exige **saber o telefone** e só responde sobre quem tem pesquisa
**pendente**. A superfície maior é a que já estava de pé.

#### O MÉTODO RECUSA AMBIGUIDADE EM VEZ DE SORTEAR

`ClienteRepository::selecionarClientesPorTelefone` já tinha medido e escrito que
**telefone não identifica cliente**: 10.000 combinações têm mais de um cadastro,
uma delas com 26 — os telefones de balcão do D32. Lá a regra é `cl.id ASC`, "o
titular provável", porque aquela tela precisa de alguém.

Aqui não. Sortear entre homônimos mostraria a um cliente os barbeiros que
atenderam **outro**, e gravaria a nota na pessoa errada — numa rota sem sessão.
O método usa `setMaxResults(2)` e devolve **só quando o casamento é único**; com
dois ou mais, devolve `null`, e o controller já tinha a frase certa.

Efeito colateral bom: sem `setMaxResults(1)`, não há linha sorteada, e o portão
`conferir-linha-sorteada.php` — que roda no CI — passa com *"Nenhuma busca nova sem
ordem"*, sem precisar de dispensa. A saída barata seria copiar o marcador
`@linha-sorteada-conferida` do irmão, e ela instalaria uma justificativa **falsa**:
o texto do irmão diz "o WHERE já fixa `cli.id`", e aqui o WHERE fixa telefone.

#### A JANELA DE DATA ERA UM SEGUNDO DEFEITO, e bloqueava o conserto ingênuo

`localizarClienteAction` passava `date('Y-m-d')` — **só hoje** —, enquanto os
outros três pontos do NPS usam **-30 dias**. Não era escolha de privacidade; era
defeito que só apareceria depois de o método existir.

Medido em 13/09/2026 na cópia: **zero** vendas pendentes na data de hoje contra
**1.674** clientes na janela de 30 dias. Com o limite antigo, a rota consertada
responderia *"Não localizamos nenhuma pesquisa"* para **todo** telefone.

E pior que devolver vazio: o formulário era montado com os colaboradores de HOJE e
validado no salvar contra os de 30 DIAS — **13.901 de 13.930 clientes divergem**. A
tela abriria e recusaria salvar, pedindo resposta a pergunta que nunca mostrou.
A linha 68 do controller passou a -30 dias, e as três janelas do NPS concordam.

#### MEDIDO NA TELA, antes e depois

    antes   POST /pesquisanps/localizarcliente  ->  500, em 8,9 segundos
    depois  POST /pesquisanps/localizarcliente  ->  200, com o formulário montado

O 200 traz a saudação, as duas perguntas e os colaboradores que atenderam.

#### A REDE QUE FICOU

`tests/Caracterizacao/LocalizarClienteDaPesquisaNpsTest.php`, seis casos, montando
o próprio cenário na transação revertida — porque os telefones das fixtures têm 18
caracteres e o controller só aceita 14 ou 15, e porque na cópia o anonimizador
colapsa 242 mil clientes em 10 mil números, o que faria *toda* busca cair no caso
ambíguo.

Os quatro filtros foram falsificados um a um, mutando o método e conferindo que o
caso certo fica vermelho:

| mutação | caso que caiu |
|---|---|
| recusa de ambiguidade vira "devolve o primeiro" | `testTelefoneCompartilhadoNaoDevolveNinguem` |
| tira `cli.status = true` | `testClienteInativoNaoAparece` |
| tira a janela de data | `testVendaForaDaJanelaNaoAparece` |
| tira `ven.npsRespondido` | `testPesquisaJaRespondidaNaoAparece` |

O portão `conferir-cobertura-por-par.php` subiu quatro linhas de base no mesmo
commit: `PARES_ESPERADOS` 178→179, o sha1, `SEM_MENCAO_ESPERADA` 63→62 e
`CHAMADAS_POR_MODULO['ISCadastro']` 86→87.

#### O QUE NÃO FOI CONSERTADO DE CARONA

Três coisas apareceram no caminho e ficaram anotadas em vez de resolvidas junto:
o irmão `...PorId` não filtra `cli.status` (**D182**), e `/pesquisanps/salvar`
aceita id de cliente cru e vaza o caminho do servidor (**D180**).

### D179 — O PDV recalculava o crédito do cliente e gravava por cima — GRAVAÇÃO REMOVIDA em 13/09/2026; o passado continua decisão — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 15/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: reconciliar as linhas que já divergem, dinheiro de cliente em produção congelada, e fazer a devolução de crédito virar registro; as duas são decisão sua.

Este é o maior achado de dinheiro da frente de cobertura, e ele saiu de escrever
teste para um método que não tinha nenhum.

#### A cadeia, e o último elo é o que assusta

```
extratoCreditoCliente($cliente, $unidade)                  VendaRepository:1220
  -> calcularSaldoCreditoClientePorUnidade(...)             VendaRepository:1311  (última linha, com max(0, ...))
    -> sincronizarValorCreditoClienteComExtrato()           ClienteService:330    (GRAVA em clientes_creditos.valor)
      <- pesquisarCreditoDebitoConvenioAction()             ClientesController:554
        <- public/layout/scripts/mod-vendas.js:215
```

**O PDV chama isso ao selecionar o cliente.** Abrir uma venda e escolher alguém
dispara o recálculo; se o número novo diferir do guardado por mais de R$ 0,009, o
guardado é **sobrescrito**. Sem aviso, sem registro, e o operador não vê nada.

Os dois métodos vêm da **importação inicial** — é código de produção, rodando hoje,
não algo que esta frente introduziu. O que a frente de 04/09 fez (`d9db320`) foi
acrescentar o `ORDER BY` que faz ler e gravar caírem sempre na mesma linha do par
duplicado; o recálculo em si é antigo.

#### O que o extrato enxerga é MENOS do que o sistema usa para conceder crédito

O extrato é a união de duas consultas:

| | critério | valor |
|---|---|---|
| crédito | `v.vendaCredito = 1 AND v.status = 1` | `v.valorTotal` |
| débito | pagamento com `fp.id = 5`, venda `status = 1` | `vp.valor` |

E os gravadores de `clientes_creditos.valor`, lidos um a um, são **cinco**:

| onde | o que faz | o extrato vê? |
|---|---|---|
| `ClienteService:136/141` | soma `getValorRecebido()` na compra de crédito | **não** — o extrato lê `getValorTotal()` |
| `VendaPagamentoService:178/183` | soma o **TROCO**, em venda que não é `vendaCredito = 1` | **não** |
| `VendaPagamentoService:118` | subtrai o crédito usado | sim, pelo pagamento forma 5 |
| `ClienteService:152` | devolução de crédito | **não** |
| `FidelidadeService:131` | zera | **não** |

Ou seja: **o extrato não é o sistema de registro do crédito, e o sincronizador o
trata como se fosse.** Três dos cinco caminhos que criam ou alteram crédito são
invisíveis para ele.

#### O tamanho, medido na cópia de produção

Comparando, para cada par (cliente, unidade) com crédito positivo, o valor guardado
com o que o extrato calcula:

| | |
|---|---:|
| linhas com crédito > 0 | **3.998** |
| que **divergem** do extrato | **3.573 (89%)** |
| guardado em `clientes_creditos` | R$ **93.458,33** |
| calculado pelo extrato | R$ **316.069,26** |

E por direção do efeito:

| efeito do recálculo | linhas | reais | maior caso |
|---|---:|---:|---:|
| **SOBE** — cliente ganha crédito | 538 | **R$ 266.488,21** | R$ 14.536,00 |
| **DESCE** — cliente perde crédito | 3.035 | **R$ 43.877,28** | R$ 9.392,40 |
| igual | 425 | zero | — |

**Exemplo conferido linha por linha:** o cliente 41640 tem R$ 9.392,40 guardados na
unidade 5 e o extrato dele é **inteiramente vazio** — zero compras de crédito, zero
usos. Selecioná-lo no PDV daquela unidade zera o saldo.

#### Por que isso não aconteceu com todos ainda

Porque o recálculo só roda **para o cliente que alguém selecionar**. As 3.573 linhas
divergentes são as que ainda não passaram por lá desde a última divergência. É dreno
silencioso, um cliente por vez, nos dois sentidos.

#### E o `max(0.0, ...)` esconde o negativo

`calcularSaldoCreditoClientePorUnidade` devolve `round(max(0.0, $saldo), 2)`. Quando
os usos passam as compras, o **extrato mostra saldo negativo** e a função que grava
**devolve zero**. As duas funções do mesmo arquivo respondem coisas diferentes sobre
o mesmo cliente, e a que grava é a que zera. Está gravado em
`ExtratoDeCreditoDoClienteTest::testUsoMaiorQueAsComprasFicaNegativoNoExtratoEZeroNoSaldo`,
falsificado removendo o limitador.

#### COMO FICOU: a opção 2, e o método mudou de nome junto

**O sincronizador parou de gravar em linha que já existe.** Ele virou
`ClienteService::garantirLinhaDeCreditoDoCliente()` — o nome antigo prometia
sincronização, e quem lesse o chamador continuaria acreditando que o saldo fica
alinhado. Nome que mente custa mais que nome feio.

O que ficou: **criar a linha quando ela não existe**. Ali não há valor anterior
para destruir, e o contrato antigo — *"chamar antes de `atualizarPontuacao` quando
se incluir linha nova"* — depende da linha existir. Medido, esse ramo dispara para
**zero** pares na cópia: quem compra crédito já ganha a linha na compra.

E o extrato passou a ser consultado **só** quando não há linha. No caminho comum do
PDV — cliente que já tem linha — a consulta cara, que hidrata entidades `Venda`,
deixou de acontecer.

#### NÃO CASE ESTE MÉTODO COM `atualizarPontuacao`

O docblock antigo mandava chamá-lo junto. Fazer isso põe a cadeia no **cron das
23:00** (`CronJobService:392`), que varre **3.060 clientes por noite** — o defeito
de um cliente por vez viraria varredura. Está escrito dentro do método.

#### TRÊS CORREÇÕES AO PRÓPRIO REGISTRO, e as três apareceram medindo de novo

**1. Não eram cinco caminhos: são quinze sítios, em cinco arquivos.** A tabela
acima lista cinco. Faltavam, no mínimo, a **comissão de indicação**
(`VendaPagamentoService:320/327`) — que CRIA dinheiro e não tem venda nenhuma por
trás, então não dá para deduzi-la de `vendas` — e o **cancelamento/estorno**
(`VendaService:195/202`). Um conserto construído sobre a lista de cinco continuaria
apagando crédito de indicador, só que com menos linhas.

**2. Os números incluíam 3 linhas de fixture, R$ 165,00.** O D167 existe para
lembrar disso e mordeu o próprio registro. Remedido em 13/09/2026 com a faixa
`>= 90000000` fora:

| | registro de 12/09 | remedido |
|---|---:|---:|
| linhas com crédito > 0 | 3.998 | **3.995** |
| divergem | 3.573 | **3.570** |
| guardado | R$ 93.458,33 | **R$ 93.293,33** |
| calculado pelo extrato | R$ 316.069,26 | R$ 316.069,26 |
| sobem | 538 / R$ 266.488,21 | 538 / R$ 266.488,21 |
| descem | 3.035 / R$ 43.877,28 | **3.032 / R$ 43.712,28** |

**3. O denominador escondia metade do alcance.** A medição usou `valor > 0`, que
são 3.995 de 256.987 linhas. Contando a tabela inteira, o sincronizador também
gravava onde o guardado era **zero** e o extrato calculava positivo — mais 1.385
linhas. O alcance real era **4.959**, e a diferença toda na direção que DÁ dinheiro.

#### O MEDIDOR ESTÁ CEGO, e isso muda o que os números provam

`vendas_pagamentos` tem **1.002 linhas para 2,27 milhões de vendas** nesta cópia, e
**zero** nas formas 5 e 6. O lado do DÉBITO do extrato — a consulta `fp.id = 5` — é
**vazio para todo mundo** aqui. Logo, nesta cópia, o saldo calculado é a soma das
compras sem nunca subtrair uso.

Consequência, e ela precisa ficar dita: **o "3.570 de 3.995" não distingue "o
sincronizador enxerga pouco" de "esta cópia não tem linha de pagamento"**. A
divergência real em produção é **NÃO MEDIDA daqui**.

O que **sobrevive** à cegueira é a direção destrutiva: **2.984 pares** têm valor
gravado e extrato zero, somando **R$ 43.337,93** que o sincronizador zerava. Esse
número é **piso**: com as linhas de pagamento presentes o extrato só pode ficar
menor, então mais pares cairiam em zero, nunca menos. Pelo mesmo raciocínio, a
direção que DÁ dinheiro (R$ 266.488,21) está **inflada** e não transfere.

Isso virou o **D181**, porque vale para qualquer medição de pagamento nesta cópia.

#### E O NÚMERO NÃO É DECORATIVO

`Adm/ComandasController:563` e `VendasPagamentosController:335` leem essa coluna
como **TETO do pagamento em crédito** — e por nome de método montado
(`$credito->{$saldos[$formaId]}()`), que nenhum `grep` por `getValor` acha. Valor
errado ali não é número torto na tela: é **recusa de pagamento no balcão**.

#### A REDE QUE NÃO EXISTIA

Com um contador injetado nos dois métodos de `ClienteService`, a suíte inteira —
626 testes — chamou o sincronizador **zero** vezes, enquanto o controle positivo
`atualizarPontuacao`, no mesmo arquivo e pelo mesmo mecanismo, recebeu **5**. Não
havia rede; havia a ilusão de rede, porque os arquivos de nome parecido
(`ExtratoDeCreditoDoClienteTest`, `SaldoDuplicadoTest`) cercam a **entrada** e param
antes da escrita.

`tests/Caracterizacao/SincronizacaoDoCreditoDoClienteTest.php` fecha o vão, com
cinco casos. Ele foi escrito **antes** do conserto e rodado contra o código velho:
três vermelhos, mostrando o defeito com número — R$ 100,00 zerados e R$ 40,00
virando R$ 500,00.

#### O QUE CONTINUA SENDO DECISÃO SUA

A gravação parou; **o passado não foi tocado**. As linhas que já divergem seguem
como estão, e reconciliá-las é operação sobre dinheiro de cliente em produção
congelada — mesma natureza da decisão do D135. E há uma pergunta anterior a ela,
que a medição levantou: a **devolução de crédito** não deixa rastro em lugar nenhum
(nem venda, nem pagamento, nem saída de caixa, nem histórico), então nenhum extrato
conseguirá explicar o saldo enquanto ela não virar registro.


---

#### A resposta 89 (b) — 15/10/2026, a fatia 5c (RESPOSTAS-DO-FINALIZAR-E-DA-DEVOLUCAO)

As duas decisões que este débito deixava ao dono foram tomadas pela página do lote (a 89,
`registro-da-devolucao-de-credito`), com a opção (b): *"A (a), e a devolução sai do caixa aberto como uma retirada. Serve se
o dinheiro devolvido sai da gaveta: o caixa fecha certo. Sem caixa aberto, a devolução é recusada."* -- e a (a): *"Cada
devolução grava quem autorizou, quem operou, quanto e quando, numa tabela nova (nas cópias locais, pela pergunta do
esquema; na produção, no corte), e o histórico do crédito a mostra. Nenhum saldo do passado muda."*

**O passado não muda:** as linhas que já divergem ficam, e o saldo de hoje é o ponto de partida.

**A devolução, daqui em diante** (`ClienteService::creditoDevolucao`, a única porta do ERP e da API), numa transação só:
o cliente, o crédito e o caixa aberto de quem opera, travados nessa ordem; sem caixa aberto, a recusa
(`DEVOLUCAO_SEM_CAIXA`); a retirada pela regra da retirada da API (`CaixaService::retiradaDaDevolucaoDeCredito`: o saldo do
caixa recalculado sob a trava, e nada além dele -- `DEVOLUCAO_ALEM_DO_CAIXA`; **essa recusa não é da resposta 89**, que só
nomeia a falta de caixa: veio junto com a regra da retirada, e o saldo que ela confere não é o dinheiro da gaveta -- fica
como está até a resposta da pergunta nova, o D501), com o motivo *"Devolução de crédito a <o
nome> (cliente <o código>)"* e sem conta a pagar; o saldo do cliente baixado; e a linha nova em
`clientes_creditos_devolucoes` (`ISCadastro\Service\RegistroDaDevolucao`): o cliente, a unidade, o valor, quem autorizou
(o gerente da senha), quem operou (`CrudController::autorDaEscrita`) e a retirada. O extrato do crédito (a aba do
histórico do cliente no ERP, `VendaRepository::extratoCreditoCliente`) mostra a devolução como débito, com quem autorizou.
A tabela entrou no `mysql.sql`, no `schema.sql` do gerador, na carga (`migrar-dados.sh`), nas duas cópias locais (a 83 a)
e no laço do passo 0 (`scripts/migracao-clientes-creditos-devolucoes.sql`); sem ela no banco (a base do teste do BVIP até a
próxima cópia), a devolução segue, com a retirada e sem a linha (a `ColunaNoBanco`, o D473).

**As telas.** O ERP vai ao caixa, com a frase, quando não há caixa aberto (como a compra de crédito), e diz cada recusa; o
aviso vermelho da página deixou de dizer *"não será debitado do caixa"*. A API responde 409 com `motivo: sem_caixa` antes
de conferir a senha do gerente, e 409 com `motivo: alem_do_caixa`; a tela nova diz que o valor sai do caixa aberto e
oferece "ir ao caixa" na recusa, como na compra.

**Provado** em `D179DevolucaoRegistradaTest` (15 casos) e no `DevolucaoDeCreditoSobTravaTest` refeito, verdes nos dois
bancos, e no front (`FidelidadeLigada.teste.tsx`). **Sem decisão nova, ficou de fora** o que cancelar a retirada da
devolução faz com o crédito: a pergunta foi ao lote (o D500). **E a recusa do que passa do saldo do caixa** é escolha que a
89 não fez: a pergunta foi ao lote (o D501), e até a resposta ela fica, por ser o padrão que não perde dado (o cliente
continua com o crédito).

> **Lido em 16/10/2026 (a fatia 5e, RESPOSTAS-231-E-232):** as duas perguntas voltaram. Pela 231 (a), cancelar a retirada da
> devolução a desfaz -- o crédito volta ao cliente, e o registro ganhou quem desfez e quando (o D500, com as duas colunas
> novas); pela 232 (c), a devolução sai mesmo maior que o saldo do caixa, que pode ficar negativo, e a recusa
> `DEVOLUCAO_ALEM_DO_CAIXA` saiu das duas portas (o D501). O registro e o extrato continuam os daqui; este débito segue
> corrigido.

### D180 — `/pesquisanps/salvar` gravava a pesquisa para qualquer id de cliente — CORRIGIDO em 13/09/2026

Apareceu fechando o D178, mapeando a mesma tela.

`PesquisaNpsController::salvarAction` lê `$request['cliente']` — **um id cru do
POST** — e grava a pesquisa para aquele cliente. A rota é pública, não há sessão, e
não há nada que prove que quem envia é aquela pessoa. Quem souber o formato do
corpo pode gravar avaliação em nome de qualquer cliente que tenha venda pendente.

Não é escalada de privilégio: a pesquisa não dá acesso a nada. É **integridade do
NPS** — o número que a rede usa para medir atendimento aceita entrada não atribuída.

E a resposta da mesma ação sai com **dois `Warning` do PHP na frente do JSON**, com
o caminho absoluto do arquivo e o número da linha: `salvarAction` compara
`$request['nota_recepcao_valor']` e `$request['nota_experiencia_valor']` sem
conferir se as chaves existem. O JSON só continua válido porque o formulário normal
sempre as manda.

#### A METADE DO VAZAMENTO SAIU no mesmo dia

`validarFormularioSalvar` passou a ler os campos com `?? ''`. Antes, um POST sem
eles devolvia isto na frente do JSON, numa rota **pública**:

```
Warning: Undefined array key "nota_recepcao_valor" in
/var/www/html/module/ISCadastro/src/Controller/PesquisaNpsController.php on line 138
```

Depois do conserto, o mesmo POST devolve JSON limpo. A mudança veio junto com a do
**D183**, porque são as mesmas duas linhas.

#### A ATRIBUIÇÃO FECHOU PELO DESENHO DA TELA IRMÃ, e não pelo hash

O registro dizia que o conserto natural era exigir o `hash` do cliente no corpo. Ele
funcionaria — e **mudaria o link que o cron já mandou**: quem tivesse o link antigo
no WhatsApp deixaria de conseguir responder. Por isso era decisão.

O conserto que entrou não toca no link. `PesquisasController::responderAction`, a
**outra** pesquisa pública deste mesmo sistema, já resolvia isso há anos: ela guarda
a pesquisa na sessão ao abri-la e a lê de volta depois. Era desenho pronto, na casa,
e ninguém precisou inventá-lo.

A amarra fica em `criarPesquisaHtml`, que é o **único** lugar por onde os dois
caminhos passam — o link com `?cli=` e o teclado do totem. Quem não abriu o
formulário não tem o que enviar.

**E o campo do POST continua sendo lido, de propósito.** Ele tem de *concordar* com
a sessão. Num tablet de balcão compartilhado, o formulário de uma pessoa pode estar
na tela enquanto a sessão já é de outra, se a segunda digitou o telefone antes de a
primeira enviar. Ler só a sessão gravaria a nota da primeira na segunda — trocaria
um defeito de atribuição por outro, mais silencioso.

`$this->sessao` **não servia**: o controller chama `parent::__construct(true)`, o modo
API, então o `SessaoAcl` nunca é montado — e não deveria ser, porque quem abre a
pesquisa não tem usuário. O `Container` do Laminas é o mesmo mecanismo que o
`SessaoAcl` usa por baixo; muda só o nome do quarto.

#### MEDIDO PELA ROTA, antes e depois

| o que se manda | antes | depois |
|---|---|---|
| corpo completo, sem ter aberto o formulário | **gravava** | recusa |
| sessão do cliente A, corpo dizendo o cliente B | **gravava para B** | recusa |
| sessão de A, corpo de A, faltando um campo | cobra o campo | cobra o campo |

#### A FALSIFICAÇÃO ACHOU DOIS DEFEITOS NO MEU PRÓPRIO ENSAIO

O `ensaiar-rotas-publicas-do-erp.php` ganhou cinco casos e passou de 18 para **23**.
Devolvendo o defeito para conferir que ele fica vermelho, **ele morreu com 255** — e
os dois motivos valem mais que o conserto:

1. **A limpeza só desfazia o caminho certo.** Com o D180 de volta, o caso do POST cru
   passa a *gravar* — o corpo dele é completo de propósito, para que a única recusa
   possível seja a da sessão — e a limpeza bateu na chave estrangeira de `nps_itens`.
   O ensaio não reportou vermelho: estourou, e deixou cenário para trás. *Limpeza que
   só desfaz o caminho certo falha exatamente quando faz falta.*

2. **Um caso era vago, e passou sob a regressão.** O caso da sessão trocada afirmava
   só `sucesso=false` — verdade por **dois** motivos, porque o corpo dele não mandava a
   nota do colaborador e a validação de campo o recusava sozinha. É exatamente o erro
   contra o qual o cabeçalho do próprio ensaio adverte: *o código HTTP não basta*.

Corrigidos os dois, a falsificação passou a derrubar **quatro** asserções nos casos 8
e 9, com resíduo zero no banco depois. E os casos destrutivos-sob-regressão foram
movidos para o **fim** do bloco: com o defeito de volta, a escrita de um deles
envenenava os anteriores, e o diagnóstico virava ruído.

23 de 23 no MySQL e 23 de 23 no PostgreSQL.

---

### D181 — As fixtures apagaram 2,3 milhões de linhas de `vendas_pagamentos` da cópia, e ninguém viu por 28 dias — MEDIDO E COM PORTÃO em 13/09/2026 — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como medido, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: recarregar a cópia exige ler produção de novo, na janela do corte; até lá, toda medição por forma de pagamento nesta cópia é piso.

Apareceu fechando o D179, e vale para qualquer medição futura.

`vendas_pagamentos` tem **1.002 linhas para 2.269.221 vendas** na cópia, e **zero**
nas formas 5 (Crédito Cliente) e 6 (Crédito Prog. Fidelidade). Nenhuma venda com
`venda_credito = 1` tem linha de pagamento.

O efeito é sistemático: toda consulta que soma, subtrai ou filtra por forma de
pagamento devolve, nesta cópia, **um lado só da conta**. No D179 isso inflou a
direção "cliente ganha crédito" e transformou "cliente perde crédito" num piso — o
número continua servindo para decidir, mas **não** para dizer o tamanho.

#### A CAUSA, e ela não é a que este registro dizia

A primeira versão daqui afirmava: *“não é defeito do anonimizador: é recorte da
cópia”*. **Não é nem uma coisa nem outra.** O anonimizador de fato não toca na
tabela (conferido), mas o dump também não veio cortado: as linhas **entraram nesta
cópia e foram apagadas aqui**.

Quem apagou está versionado. O `tests/Caracterizacao/fixtures.sql` de 15/08/2026
(commit `7e98fa7`) começava assim:

    DELETE FROM vendas_pagamentos WHERE venda >= 1000;
    DELETE FROM vendas_produtos   WHERE venda >= 1000;
    DELETE FROM vendas            WHERE id    >= 1000;

A faixa de fixture era `>= 1000`, e a cópia de produção tem ids muito acima disso.
Carregar as fixtures levou **2,3 milhões de linhas** — 99,96% da tabela.

**`vendas` sobreviveu porque tem DEZ chaves estrangeiras apontando para ela.
`vendas_pagamentos` não tem NENHUMA**, e nada a protegeu.

#### A PROVA ESTÁ NO DISCO

O InnoDB não encolhe o arquivo num apagamento. Medido:

| tabela | dados | livre | % livre |
|---|---:|---:|---:|
| **`vendas_pagamentos`** | **0 MB** | **194 MB** | **100%** |
| `vendas` | 211 MB | 7 MB | 3% |
| `vendas_produtos` | 263 MB | 7 MB | 3% |

Duzentos megabytes de espaço livre para 1.002 linhas. E como o anonimizador não
toca nessa tabela, esse espaço **só pode vir de `DELETE`**.

#### E O EPISÓDIO FOI REGISTRADO COMO QUASE-ACIDENTE

Em 16/08 a faixa foi movida para 90.001.000, fechando o mecanismo. O commit
`37b7b71` registrou o episódio com esta frase:

> Só não apagou porque as chaves estrangeiras bloquearam.

**Para esta tabela, apagou.** A frase está certa sobre `vendas` e errada sobre
`vendas_pagamentos`, e foi por acreditar nela que ninguém procurou o estrago. O
mecanismo está fechado há 28 dias — os `DELETE` de hoje ficam todos na faixa de
fixture, conferido — e o dano nunca foi notado nem desfeito.

#### POR QUE NINGUÉM VIU

**A tabela não ficou vazia.** Sobraram 993 linhas — as de id 1 a 999, com dado
real de 5 a 23 de junho de 2015 — mais 9 de fixture. Consulta que acha linha não
parece vazia: parece medição.

Foi assim que o D179 mediu crédito de cliente sobre um extrato cujo lado de
**débito** estava zerado, e ninguém estranhou. Em números: `vendas` vai até
16/08/2026 e `vendas_pagamentos` morre em 23/06/2015 — **onze anos e dois meses**
de ausência —, e **2.268.216 das 2.269.209 vendas** (99,96%) não têm nenhuma linha
de pagamento.

#### COMO FICOU: um portão, e o instrumento já estava no repositório

`scripts/conferir-completude-da-copia.php`, o **35º** do conjunto.

`mysql.sql` é um dump **só-de-esquema** de `franquia_producao`, e cada `CREATE
TABLE` dele carrega o `AUTO_INCREMENT=` que a tabela tinha **em produção** — o
número de ids já consumidos lá. São 82 tabelas que o declaram, e isso é uma marca
d'água melhor que qualquer heurística local:

- **imune a exclusão** — apagar linha não baixa o contador, então uma tabela que
  perdeu metade das linhas em onze anos continua alcançando o topo;
- **imune às fixtures** — o filtro do que é cenário é **lido dos próprios `DELETE`
  do `fixtures.sql`**, então os dois ficam em sincronia sozinhos;
- **direta** — não infere: lê o que produção declarou.

Resultado: **81 tabelas conferidas, 1 sem o topo** — `vendas_pagamentos`, maior id
local **999** contra **2.309.941** em produção.

#### DUAS ARMADILHAS QUE A PRÓPRIA CONSTRUÇÃO ACHOU

**1. A heurística ingênua acusaria dez inocentes.** Comparar `COUNT(*)` com
`MAX(id)` põe o dicionário de CEP no topo da lista — e ele foi esvaziado **de
propósito** em 31/08/2026, quando o ViaCEP virou a fonte
(`scripts/limpar-dicionario-cep.sql`). A marca d'água o absolve sem precisar
conhecer a história de cada tabela.

**2. A primeira versão do portão disse que estava tudo bem.** Ela filtrava
`id < 90000000`, e as fixtures de `vendas_pagamentos` inserem **sem dizer o `id`**:
o auto_increment as põe logo acima do último id de produção — 2.311.026 a
2.311.034. **Nove linhas nossas escondiam a falta de 2,3 milhões.** O conserto foi
parar de adivinhar e ler o filtro de onde ele já estava escrito.

#### ELE FALHA NOS DOIS SENTIDOS, e o segundo é o que importa

Falsificado: tirar a declaração de `vendas_pagamentos` dá vermelho com *“perdeu o
topo, e ninguém declarou isso”*; declarar uma tabela que está inteira dá vermelho
com *“voltou a alcançar o topo”*.

Esse segundo caso é deliberado. No dia em que alguém recarregar a cópia, vários
números deste projeto — o D179 inteiro — estão publicados com a ressalva *“isto é
piso, e não medida”*, e essas ressalvas passam a mentir na direção oposta. O
portão obriga a revisitá-las antes de tirar a entrada.

#### O QUE CONTINUA EM ABERTO

**Os dados não voltam daqui.** Não há dump de dados no repositório — o `mysql.sql`
é só esquema, 90 KB — e recarregar a cópia exige ler produção de novo, que por
decisão de 11/09/2026 acontece **dentro da janela do corte**. Até lá, toda medição
por forma de pagamento nesta cópia é **piso**, e o portão existe para que isso
continue dito em vez de lembrado.

---

### D182 — `pesquisaNpsLocalizarClientePorId` não filtrava `cli.status`, e o salvar exige — CORRIGIDO em 13/09/2026

O irmão do método escrito no D178 tem o mesmo vão que o novo já fecha.

`PesquisaNpsController::salvarAction` exige `$cliente->getStatus()` e responde
*"Cliente inválido"*. Mas `cadastroAction` chama
`pesquisaNpsLocalizarClientePorId`, que **não filtra status**. O cliente inativo que
abre o link do WhatsApp vê a pesquisa, responde tudo, e leva a recusa no fim.

O método novo (`pesquisaNpsLocalizarCliente`) filtra `cli.status = true`, e o teste
`testClienteInativoNaoAparece` prende isso. O irmão ficou como estava **de
propósito**: mexer nele muda o que o link já enviado faz, e isso é mudança de
comportamento numa tela que o cliente vê — não cabe de carona no conserto de outro
débito.

Medido em 13/09/2026 na cópia: **1** cliente da janela de 30 dias está nessa
situação.

#### COMO FICOU

`AND cli.status = true` entrou no irmão, e os dois caminhos agora concordam sobre
quem pode abrir a pesquisa. O caso `testClienteInativoNaoApareceNemPeloLink` prende
isso, e foi falsificado tirando a cláusula de volta.

Fechou de imediato, e não “de carona”: virou mudança própria, e o efeito para quem
recebe o link é estritamente melhor — em vez de responder a pesquisa inteira e levar
*“Cliente inválido”* no fim, a pessoa vê de cara a tela de cliente não localizado.

---

### D183 — A nota ZERO a um barbeiro não podia ser enviada, e a nota zero à loja podia — CORRIGIDO em 13/09/2026

Apareceu lendo `validarFormularioSalvar` para fechar o vazamento do D180, e vale
mais que ele.

As duas perguntas de loja eram validadas com `== ''` e as dos colaboradores com
`empty()`. Em PHP:

| valor | `== ''` | `empty()` |
|---|---|---|
| `'0'` | **falso** | **verdadeiro** |
| `'10'` | falso | falso |
| `''` | verdadeiro | verdadeiro |

Ou seja: **a nota 0 passava nas de loja e era recusada nas de colaborador.**

#### Provado pela própria rota, antes do conserto

    POST /pesquisanps/salvar  nota_colaborador_1243_valor=0, resto preenchido
      -> {"sucesso":false,"mensagem":"Responda as seguintes perguntas:
          - Atendimento do(a) Colaborador 1243"}

    POST /pesquisanps/salvar  nota_recepcao_valor=0, nota_experiencia_valor=0
      -> {"sucesso":true}

O cliente que quisesse dar **zero** a um barbeiro — a pior nota, e a mais
informativa — via "Responda as seguintes perguntas" apontando para a pergunta que
ele **tinha** respondido. Não havia como enviar.

#### O QUE ISSO FAZ COM O NÚMERO

O NPS é o que a rede usa para comparar unidades e colaboradores. Com a recusa, a
nota de colaborador nascia com **piso 1**: quem quisesse dar zero, ou desistia, ou
trocava por um número que passasse. O indicador era **enviesado para cima**, e o
viés era invisível porque a recusa acontecia antes de qualquer gravação.

**E isso cria uma descontinuidade na série.** A partir de 13/09/2026 podem aparecer
zeros de colaborador que antes não existiam. Quem comparar médias de antes e depois
tem de saber disto.

#### Ao resolver — já resolvido

`validarFormularioSalvar` passou a usar `($request[...] ?? '') === ''` nos três
lugares, o que trata os três de forma idêntica e de quebra fecha o vazamento do
D180. `PesquisaNpsService::salvar` grava com `setNota($request[...])`, sem
`empty()`, então o zero chega ao banco inteiro — conferido antes de liberar.

#### A SUSPEITA MAIOR, CONFERIDA E DESCARTADA

`empty()` sobre valor numérico é defeito sistêmico? **Não.** Varridas as 15
ocorrências de `empty($request['...valor/nota/quantidade/desconto...'])` em
`module/`: `desconto` zero significa "sem desconto"; `quantidade` zero não faz
sentido; `VendasProdutosController:114` já trata o zero explicitamente
(`empty(...) || ... == 0`); e a cota-parte de comissão extra igual a zero seria
registro inócuo. O caso do NPS era o afiado, porque ali o zero é **resposta**, não
ausência.

---

### D184 — Nenhuma suíte exercitava a validação de controller das rotas públicas do ERP — CORRIGIDO em 13/09/2026 — CORRIGIDO em 05/10/2026

Os três defeitos da pesquisa de satisfação — D178, D180 e D183 — moram no mesmo
lugar: um controller de rota **pública** do ERP. E os três foram achados à mão,
dois deles abrindo a página no navegador.

O que existe hoje não alcança esse lugar:

| instrumento | o que cobre |
|---|---|
| `tests/Caracterizacao` | repositórios e serviços, por chamada direta |
| `tests/Contrato` | as rotas de `/api`, do aplicativo instalado |
| `ensaiar-fluxo-adm-api`, `...-permissao-...`, `...-recusas-...` | `/adm/api` |
| `ensaiar-escritas-do-aplicativo` | `/api` |
| `conferir-rotas-sem-login`, `conferir-guarda-do-erp` | **quem** pode entrar, não **o que** a ação responde |

Os portões de rota dizem que `/pesquisanps` é pública e legítima. Nenhum deles
pergunta o que ela devolve — e foi por isso que um 500 na cara do cliente
sobreviveu sem ninguém notar.

#### UMA CORREÇÃO À TABELA ACIMA, achada escrevendo o conserto

Falta um instrumento nela, e ele parecia cobrir isto: `medir-telas-no-postgres.php`
pede **todas** as telas do ERP e exige que nenhuma responda 500. Mas ele
**entra com sessão antes de medir** — *"sem sessão, toda tela responde 401 e a
medição não diz nada"*, escrito no próprio roteiro. Ele mede a superfície
**autenticada**. A pública continuava sem ninguém.

#### COMO FICOU

`scripts/ensaiar-rotas-publicas-do-erp.php` — **18 casos** quando este débito
fechou, e **23 hoje**: o D180 acrescentou cinco no mesmo dia, ao fazer a gravação
depender da sessão do formulário (os casos 8 e 9). Ele é o único ensaio da casa
que **não faz login**. Os outros quatro entram por `/autenticacao/entrar`
antes de medir; este não, e isso é a propriedade que ele mede: se alguma destas
rotas passar a exigir sessão, os casos ficam vermelhos.

| casos | o que afirmam |
|---|---|
| 1 | `GET /pesquisanps` responde 200 **e traz o teclado** — a página sem parâmetros *é* o teclado |
| 2 | telefone vazio → 200 com a recusa de comprimento |
| 3 | telefone de 15 caracteres que não casa ninguém → 200 — **o canário do D178** |
| 4 | o telefone do cenário monta a pesquisa, com as duas perguntas e a do colaborador |
| 5 | telefone de dois clientes **recusa** em vez de sortear |
| 6 | `POST /salvar` sem campos não traz `Warning` nem `/var/www` — **D180** |
| 7 | nota **zero** num colaborador é aceita — **D183**, provado sem gravar |
| 8–10 | as URL-capacidade dão **404** sem a capacidade — 200 ali seria o defeito |
| 11–12 | as duas páginas de agradecimento respondem 200 |

**O caso 3 é o que pega o D178**, e vale explicar por quê: o caso 2 para no
`strlen` e nunca chega ao repositório. Um telefone de 15 caracteres que não casa
ninguém **alcança** a consulta — e era ali que o método inexistente estourava.

**E o caso 7 prova o D183 sem gravar nada:** manda zero no colaborador e **omite**
a recepção. A validação falha pela recepção, então nada é salvo, e a mensagem não
pode citar o colaborador. Se citar, o zero voltou a ser tratado como ausência.

#### ELE ESCREVE, E DEVOLVE TUDO

O cenário das fixtures **não servia**: as vendas recentes dele têm `status = 3`,
então nenhum cliente de fixture tem pesquisa pendente na janela. Sem isso os casos
4, 5 e 7 passariam por vacuidade — a mensagem não citaria colaborador porque não
**há** colaborador, e não porque o zero foi aceito.

Então ele troca o telefone de três clientes de fixture e cria três vendas com item
na faixa **90008xxx**. A data é `NOW()`, e não um literal: a janela do NPS é
relativa a hoje, e uma data cravada sairia dela sozinha no mês seguinte — o ensaio
começaria a falhar sem que nada tivesse mudado. Tudo volta num `finally`, cada
valor ao que foi **lido** antes, e ele confere resíduo zero antes de sair.

#### FALSIFICADO, e essa é a parte que dá valor ao resto

Portão que não fica vermelho é pior que portão nenhum. Reintroduzindo cada defeito,
um a um, e conferindo que o caso certo cai:

| defeito devolvido | saída | caso que caiu |
|---|---|---|
| o método do D178 volta a não existir | 1 | 3 |
| a ambiguidade volta a sortear | 1 | 5 |
| o campo volta a ser lido sem `??` | 1 | 6 |
| o colaborador volta ao `empty()` | 1 | 7 |

Os quatro arquivos foram restaurados byte a byte depois.

#### ONDE ELE RODA

No job `caracterizacao-postgres`, **primeiro entre os ensaios** — ele lê o telefone
e o status dos três clientes **antes** de trocá-los, e rodando depois de quem mexe
no cenário devolveria o valor do vizinho.

Executado nos **dois bancos** antes de entrar: 18 de 18 no MySQL e 18 de 18 no
PostgreSQL. Isso não é zelo: o job é o do PostgreSQL, e hoje mesmo cravar `1`/`0`
num INSERT passou no MySQL e quebrou lá — `vendas.status` é `integer`,
`nps_respondido` é `smallint` e `comanda_temp` é `boolean`, três tipos na mesma
tabela (D128).

#### O QUE ELE NÃO COBRE, dito para ninguém supor que cobre

O **salvamento bem-sucedido** da pesquisa, que gravaria uma resposta de NPS — a
caracterização cobre essa metade, no repositório, nos dois bancos. O `POST` de
`/fidelidade/salvarDados`, pelo mesmo motivo. E a família `/autenticacao`, de
propósito: ela redefine senha, e um ensaio não é lugar de exercitar isso.

#### O `POST /fidelidade/salvarDados`, no banco de teste — CORRIGIDO em 05/10/2026

O que ficou de fora por escrito tinha um motivo — a rota GRAVA, e o ensaio público
corre por HTTP no banco de desenvolvimento — e o motivo caiu com o
`ControllerEmProcesso`: o `FidelidadeController` de verdade despachado no banco de
teste, dentro da transação que o `tearDown` desfaz. A etapa 5 já tinha caso desde
04/10/2026 (`AdesaoComPesquisaTest`); a PORTA da rota não.

`tests/Caracterizacao/D184SalvarDadosDaAdesaoTest.php`, doze casos (vinte e três com os do D423), com dois clientes
criados no caso com hash de 8 caracteres (a cópia os reescreveu para 32):

| pedido | resposta | e no banco |
|---|---|---|
| o par mal formado — sem id, sem hash, hash de 7 ou de 9, id que não é número | "Dados de segurança inválidos." | nada |
| o par bem formado e errado — hash trocado, **o hash do outro cliente**, id que não existe | "Não foi possível localizar o cliente." | nada, nem no cliente do id nem no dono do hash |
| o par certo e o campo da etapa vazio; o nome de uma palavra só na etapa 1 | a recusa da validação, com o campo | nada, e o nome fica o que era |
| o par certo e a etapa 4 preenchida | `sucesso`, `pontuacao` e `proxima_etapa: 5` | a profissão, um histórico de 10 pontos e o crédito de 10; o reenvio grava o campo de novo e **não** pontua de novo (o D406) |
| `GET` | 404 | nada |

**Falsificado**, um mutante por vez e restaurado byte a byte: com o cliente achado só
pelo id (`find` no lugar de `selecionarPorIdHash`), os casos do hash trocado e do hash
do outro cliente ficam vermelhos — gravavam; sem a guarda do par bem formado, os cinco
do par mal formado; sem a validação da etapa, os dois da validação.

**O que ele não prova:** que a rota responde sem sessão por HTTP. Quem diz que
`/fidelidade/salvarDados` é pública é o `conferir-rotas-sem-login.php`
(`iscadastro-fidelidade-salvardados`) e o `conferir-guarda-do-erp.php`; o ensaio público
continua sem o caso de escrita desta rota, de propósito.

**A verificação do mesmo dia achou o que a tabela acima prometia sem ter:** "não pontua
de novo" valia só para a grafia exata da etapa. A etapa é campo oculto, como o par, e
ninguém a conferia — `-1` rendia 10 pontos, `-2` mais 10, e `04` pontuava a etapa 4 outra
vez. Registrado e consertado como **D423**; os casos estão no mesmo arquivo de teste.

---

### D185 — O ramo do operador na pesquisa de satisfação era inalcançável — RETIRADO em 13/09/2026

Apareceu fechando o D180, lendo o construtor para descobrir por que `$this->sessao`
era nulo.

`PesquisaNpsController` chama `parent::__construct(true)` — o **modo API** do
`CrudController`. Nesse modo o construtor pula o bloco inteiro que monta a sessão, e
com ele `$this->usuarioId`, que **nunca recebe valor**.

`criarPesquisaHtml` passa esse nulo adiante:

```php
$dados['usuarioId'] = $this->usuarioId;   // sempre null
```

E o template decide por ele:

```php
// perguntas.phtml:4
<?php if(empty($dados['usuarioId'])) { ?>
    <h3>Olá, <?= $dados['cliente']['apelido'] ?></h3>
<?php } else { ?>
    <h3>Cliente: ...</h3>  <h5>Telefone: ...</h5>  <h5>Última visita: ...</h5>
<?php } ?>
```

`empty(null)` é sempre verdadeiro, então **o segundo ramo nunca roda**. Ele mostra o
nome completo, o telefone e a última visita — a identificação que um operador
precisaria para conferir que abriu a pesquisa da pessoa certa.

**E não é código órfão: há um botão que leva exatamente a ele.** A lista de vendas do
ERP (`index-vendas.phtml:195`) tem um "Abrir pesquisa NPS" por venda, que abre
`/pesquisanps?cli=&uni=` numa aba nova. Quem clica é o operador, autenticado — e vê
*"Olá, Fulano"*, a saudação escrita para o cliente, em vez da ficha.

#### A MINHA PRÓPRIA ANÁLISE ESTAVA ERRADA, e convém dizer primeiro

Este registro dizia, ao ser aberto: *“a tela precisaria saber que quem a abriu tem
sessão de ERP, e hoje ela não pode saber … exigiria um parâmetro próprio, e aí é
decisão”*. **Ela pode saber, e sem parâmetro nenhum.**

`SessaoAcl` é um invólucro de `Laminas\Session\Container`. Instanciá-lo sem
argumentos não toca no banco, e `getUsuario('id')` lê o ACL da sessão devolvendo
**nulo quando não há sessão** — o `?? null` que o D-de-05/09 pôs ali justamente para
não imprimir aviso em requisição anônima. Uma linha resolveria.

Escrevi aquela frase sem conferir. O conserto mudou por causa disso: a pergunta
deixou de ser *“dá para fazer?”* e passou a ser *“vale fazer?”*.

#### E NÃO VALIA. O ramo saiu.

Três razões, em ordem de peso:

1. **A ficha exibe telefone numa rota PÚBLICA.** Código que mostra dado pessoal
   atrás de uma guarda é mais arriscado que código que não existe — a guarda pode
   falhar, o código ausente não. E hoje aquele ramo é o **único** lugar onde
   `telefone` e `última visita` do cliente apareceriam nessa tela.
2. **O operador já sabe de quem é.** Ele clica em “Abrir pesquisa NPS” numa LINHA
   da lista de vendas; a conferência que a ficha daria, ele acabou de fazer com o
   dedo.
3. **Ninguém sentiu falta, porque nunca houve o que sentir.** O ramo nunca rodou:
   `parent::__construct(true)` e o `$dados['usuarioId']` sempre nulo vieram na
   **importação inicial do ERP**, em 15/08/2026 — ou seja, o código já chegou morto
   a este repositório, e presumivelmente estava morto em produção antes disso.

Saíram o `if/else` do `perguntas.phtml` e a linha
`$dados['usuarioId'] = $this->usuarioId;` do controller, que não tinha mais leitor.
O porquê ficou escrito no topo do template, onde quem for reescrever a tela topa
com ele.

#### O QUE **NÃO** MUDOU, e vale dizer

O botão da lista de vendas continua lá, e continua funcionando: ele abre a pesquisa
do cliente daquela venda. O que mudou é só o cabeçalho da tela — o operador vê
*“Olá, Fulano”*, a saudação do cliente, como todo mundo sempre viu.

Conferido na tela e no ensaio: `ensaiar-rotas-publicas-do-erp.php` **23 de 23** no
MySQL e 23 de 23 no PostgreSQL, com os casos 4 e 7 exigindo que o formulário
montado traga as duas perguntas de loja e a do colaborador.

### D186 — O roadmap publicava "112 débitos com marca (dá 118 radical)", e o número nunca existiu — CORRIGIDO em 13/09/2026, com régua escrita

Descoberto em 13/09/2026, ao republicar o Artifact depois de fechar o D181.

Contei os débitos resolvidos com uma régua improvisada: **deu 121**. Refiz com
outra régua improvisada, **no mesmo arquivo, no mesmo minuto: deu 118**. As duas
eram minhas, escritas com dez minutos de diferença.

E o roadmap já estava publicado com a frase **"112 com marca (dá 118 radical)"** —
uma frase que admite não saber o número e publica assim mesmo, como se o intervalo
fosse uma propriedade do assunto e não do medidor.

#### O defeito não estava no arquivo. Estava na pergunta

"Quantos estão fechados" pressupõe **dois** estados. Este registro usa **seis**, e
a diferença entre eles é exatamente a informação pela qual ele existe:

| Estado | O que quer dizer | Hoje |
|---|---|---:|
| `fechado` | o defeito não existe mais | **119** |
| `medido` | o número existe e está escrito; **o defeito continua de pé** | 7 |
| `preparado` | o conserto está pronto e **desligado**, esperando a janela do corte | 1 |
| `parcial` | parte fechada, parte não — e o título diz qual | 4 |
| `adiado` | deixado para depois **por decisão de quem manda** | 4 |
| `aberto` | sem marca nenhuma no título | 50 |

Espremer seis em dois erra nas **duas** direções ao mesmo tempo: conta como
resolvido o que não foi — os 7 medidos e os 4 parciais —, e some com o trabalho de
quem mediu, que é o que separa um número escrito de um palpite.

#### O caso que prova: o D30

O título dele termina em **"MESMO DEFEITO DO D49"**, e *toda* régua que procura
verbo de fechamento no fim do título o conta como resolvido. Ele não é. Ele é uma
**remissão** — foi reduzido a ponteiro em 30/08/2026 —, e o defeito que ele aponta,
os 85 campos de dinheiro em ponto flutuante, continua **aberto** no D49, com a
resolução adiada para depois do corte.

Contar o ponteiro como conserto inventa um conserto que ninguém fez. E ele não
entra na conta nem como aberto: **remissão não é débito**, e contá-la infla os dois
lados da divisão ao mesmo tempo.

#### Como ficou: `scripts/contar-debitos.php`

A marca é escrita em **maiúsculas**, e é só isso que a torna legível: `str_contains`
sem ignorar caixa separa `RETIRADO em 12/09` de uma frase que por acaso diz
"retirado". Ela mora depois do último travessão do título; quando não há travessão,
vale o título inteiro — foi assim que o D15, o D21 e o D47 ficaram escritos, antes
da convenção existir. Esses três são os **únicos** que o atalho pega, conferido
varrendo os 186.

Quatro títulos cuja marca não descreve o estado ficam numa tabela de exceções, cada
um com o motivo, e cada um conferido **lendo o corpo do registro** — não o título:
o D30 (remissão), o D7 (marca em minúsculas, de antes da convenção), o D67 (7 de 9
corrigidas, 2 congeladas) e o D65 (o que foi medido é o segundo eixo).

**Ele não é portão, e isso é escolha.** Ele conta e imprime; sai com 0 tendo débito
aberto ou não — débito aberto é o estado normal de um registro de débito. Só sai
diferente de zero quando **não consegue ler**, que é quando o número não vale.
Pendurá-lo no `conferir-tudo.sh` acrescentaria um portão que só pode ficar vermelho
numa catástrofe de formatação, e inflaria o número de portões que este projeto
publica. O conjunto continua com **35**.

#### Uma armadilha que a própria construção achou

Escrevi no código que a ordem dos estados **importava**, "porque `7 CORRIGIDAS em
31/08/2026, 2 congeladas` tem que cair em PARCIAL". Ao falsificar, varri os 186
títulos atrás dos que disparam ressalva e conserto ao mesmo tempo: **nenhum**. A
ordem é guarda para o título que ainda vão escrever, e não o conserto de um caso
existente — e o D67, o caso que parecia pedi-la, ela não resolve: a ressalva dele
está na palavra *"congeladas"*, que não está em lista nenhuma. Quem o segura é a
tabela de exceções. O comentário foi corrigido antes de commitar: comentário que
mente custa o mesmo que nome que mente.

#### Falsificado em três sentidos

| O que se estraga | O que ele faz |
|---|---|
| a expressão que acha os títulos | sai com **2** e diz `NAO LI: achei apenas 0 titulos` — recusa dar número |
| tira-se a exceção do D30 | `fechado` 119 → **120**: o ponteiro vira conserto |
| tira-se a exceção do D67 | `parcial` 4 → 3 e `fechado` 119 → **120**: o parcial vira completo |

#### O número, agora com a régua escrita

**Em 13/09/2026: 185 débitos e 1 remissão.** Não estavam mais de pé **124 de 185
(67,0%)** — fechado, preparado ou adiado por decisão; **continuavam de pé 61**: 50
abertos, 7 medidos sem conserto, 4 parciais.

Esses números têm **data**, e é de propósito que estejam escritos com uma. Todo débito
novo os move, e número no presente dentro de um arquivo que cresce é o defeito que este
mesmo registro acabou de consertar no D184. **A fonte viva é o roteiro**, não esta
linha: `php scripts/contar-debitos.php` responde a qualquer hora.

O que ele **não** faz: não lê o corpo do débito. Um título que diz CORRIGIDO sobre
um débito que voltou passa limpo. É o limite do método, e é o mesmo do
`conferir-debitos-repetidos.php` ao lado.

### D187 — O CI parou de rodar por cobrança da conta — MEDIDO DE NOVO em 21/09/2026: oito dias, 18 commits nunca empurrados, e a lista dos "seis portões" estava errada — PARCIAL em 07/11/2026: a cobrança voltou e os jobs rodam inteiros desde 10/10/2026; os 5 casos que só caíam no runner (o fuso do job do MySQL, o superusuário do job do PostgreSQL) e o passo das telas do job do PostgreSQL (a concessão que batia na chave, o piso da cobertura, os 15 ensaios que caíam com ela) foram consertados; falta ver a primeira execução verde — CORRIGIDO em 07/11/2026: a execução 42360871047 (a8324f15) saiu verde inteira

**07/11/2026, a primeira execução verde desde 13/09/2026.** A execução 42360871047, no commit a8324f15, saiu `success` nos seis jobs: os dois do MySQL (20 de 20 passos cada), o do PostgreSQL (45 de 46; o pulado é "O que o servidor registrou", que só roda quando um passo falha), o `verificacao` (65 de 65), o do front (8 de 8) e o do isolamento entre inquilinos (14 de 14). Até ela, dois vermelhos que só o runner via, consertados depois da fatia 102: o ensaio do D111 abria sempre `mysql:` e caía na porta do PostgreSQL (fdd1ee6e: o driver vem do `local.php`), e o ensaio da administração da VIP Class exigia o `padrao.jpg`, que fica fora do git (a8324f15: ele guarda o seu onde falta e o tira no fim). Daqui em diante, vermelho no `gh run list` é regressão.

Descoberto em 13/09/2026, às 21:01, lendo o CI do commit anterior — o do D186.

**As cinco tarefas falharam em seis segundos, com zero passos executados.** Não é
portão vermelho: é tarefa que **nunca começou**. A anotação diz, com todas as
letras:

> The job was not started because recent account payments have failed or your
> spending limit needs to be increased. Please check the 'Billing & plans'
> section in your settings.

**E não é soluço.** Mandei a execução de novo às 21:05 e ela falhou igual, com a
mesma anotação nas cinco tarefas. As **sete execuções anteriores do mesmo dia**
foram reais — 14 a 25 passos cada, cerca de dois minutos — e todas verdes. O
bloqueio começou entre 20:33 e 21:01.

#### Por que isto é grave, e não um aborrecimento

Este projeto tem instrumentos que **só existem no CI**, porque dependem de
serviços que a máquina local não levanta de uma vez — e são seis:

| portão | o que ele defende |
|---|---|
| `conferir-guarda-do-erp` | a lista das 34 ações do ERP sem conferência de privilégio não cresce |
| `conferir-rotas-sem-login` | as 184 rotas anônimas continuam as mesmas, cada uma numa das 95 famílias liberadas |
| `conferir-linha-sorteada` | nenhuma busca nova devolve linha sorteada por falta de ordenação |
| `auditar-group-by` | nenhuma consulta agregada quebra no PostgreSQL |
| `conferir-referencias-docs` | nenhum caminho citado na documentação deixou de existir |
| `conferir-debitos-repetidos` | nenhum par de débitos novo descreve o mesmo assunto duas vezes |

Junto deles param a suíte contra os **três bancos** (MySQL 8.0, MySQL 8.4 e
PostgreSQL), o isolamento entre inquilinos e os cinco ensaios da API.

**E há um segundo estrago, mais traiçoeiro que o primeiro:** o commit fica com o
sinal **vermelho** no histórico. Quem olhar a lista amanhã vai ver um commit
falhado e concluir que o código quebrou. **O vermelho é da conta, não do código** —
e é por isso que esta página existe, para que ninguém precise adivinhar.

É a máxima da casa na forma mais pura: *portão que não roda é pior que portão que
não existe*, porque a existência dele dá uma confiança que ele não está ganhando.

#### MEDIDO DE NOVO em 21/09/2026 — o bloqueio continua, e a ficha acima envelheceu em três pontos

**1. O bloqueio é real e continua.** Pedi **uma** reexecução hoje, sobre o commit
que já estava no remoto — sem empurrar nada. Ela rodou às **23:31 de 21/09** e
falhou em **10 segundos**, com a mesma anotação, palavra por palavra:

> The job was not started because recent account payments have failed or your
> spending limit needs to be increased.

São **oito dias**. Medido, não suposto.

**2. E há um segundo motivo, que é meu.** A última execução do CI é de
**16/09**. Não porque o bloqueio tenha começado ali, mas porque **nada foi
empurrado desde então**: o remoto está em `d55036d`, e há **18 commits
locais** que o GitHub nunca viu.

Isso muda o diagnóstico. A ficha acima diz que *"os commits ficam vermelhos no
histórico"* — mais traiçoeiro que vermelho é **não estar lá**. Dezoito commits de
trabalho, incluindo os consertos do D189, D191, D192, D194, D195, D196 e D197,
não existem para quem olha o repositório.

**Empurrar é decisão do dono**, e não foi feito: com o bloqueio de pé, cada push
vira mais uma fileira de vermelho no histórico, e o vermelho continua sendo da
conta.

**3. A lista dos "seis portões que só existem no CI" está errada — nos dois
sentidos.** Conferido um a um:

| portão que a ficha nomeia | corredor local | CI |
|---|---|---|
| `conferir-guarda-do-erp` | **está** | está |
| `conferir-rotas-sem-login` | **está** | está |
| `conferir-linha-sorteada` | **está** | está |
| `auditar-group-by` | **está** | está |
| `conferir-referencias-docs` | **está** | **não está mais** |
| `conferir-debitos-repetidos` | **está** | **não está mais** |

**Os seis rodam no corredor local.** A frase que justificava o alarme deixou de
ser verdade em algum momento que ninguém registrou, e continuou sendo repetida —
inclusive por mim, em voz alta, no fim de cada sessão.

**4. E os que de fato só existem lá são 14, não 6.** Derivados do `ci.yml`
contra o `conferir-tudo.sh`, e não digitados:

| estado | quantos | quais |
|---|---:|---|
| **medidos aqui, verdes** | **3** | `verificar-schema`, `verificar-schema-postgres`, `ensaiar-erro-json-da-api` |
| pedem o banco de CONTRATO | 8 | os cinco `ensaiar-*` e os três `medir-*-no-postgres` |
| estoura o tempo desta máquina | 1 | `medir-cron-no-postgres` |
| não rodado de propósito | 1 | `ensaiar-fechamento-da-agenda` — ESCREVE |
| roda, mas sozinho | 1 | `rodar-contrato.sh` |
| preparo, não é portão | 2 | `definir-senha-contrato`, `gerar-ddl-postgres` |

**E o motivo de serem "só do CI" é mais bobo do que parecia.** Os dois do
PostgreSQL resolvem o host `postgres`, que é o nome do serviço no compose: do
shell do Windows esse nome não existe. De dentro do contêiner, com
`docker exec`, rodam e passam. **Ninguém tinha tentado** — e por isso ficaram
meses classificados como inalcançáveis.

Os outros oito têm razão de verdade: pedem o **banco de contrato** e a senha que
o `definir-senha-contrato.php` grava nele. Chegar lá exige repontar o
`local.php`, que é a disputa que já deu falso vermelho duas vezes esta semana —
então o roteiro novo **não reponta nada sozinho**, e diz o que fazer.

#### O que entrou

**`scripts/conferir-lacuna-do-ci.sh`** — deriva a lista a cada execução,
**exige que cada passo exclusivo seja declarado** com o que ele defende, e roda
o que dá. Na primeira execução ele me pegou: eu tinha deixado o
`rodar-contrato.sh` de fora da tabela. O que não roda sai como **NÃO MEDIDO**,
nunca como verde.

**Gatilho manual no CI** (`workflow_dispatch`). Até hoje o CI só disparava por
`push`, então testar se a cobrança voltou exigia **empurrar commit** — e com o
bloqueio de pé, cada push vira mais uma fileira de vermelho. Agora é uma
pergunta, sem publicar nada:

```
gh workflow run CI && gh run list --limit 1
```

Tarefa que falha em segundos é cobrança; tarefa que roda minutos é o CI de volta.

#### Dois falsos vermelhos meus, no caminho

**O primeiro custou uma rodada inteira.** Onze portões saíram vermelhos, e eu
sabia que ao menos um deles estava verde — era o Git Bash convertendo
`/var/www/html/...` num caminho do Windows antes de o `docker` ver. O medidor
mentindo antes do código, de novo.

**O segundo era de classificação.** Oito portões saíram como VERMELHO com saída
**2** — e 2, nesta casa, é *"não consegui medir"*, não *"achei defeito"*. Os
próprios roteiros dizem isso no cabeçalho. Contar "não medi" como vermelho é o
mesmo erro do D50 ao contrário: enche o relatório de alarme falso até ninguém
mais olhar.

**Um terceiro quase aconteceu, e a guarda de outra pessoa impediu:** o
`medir-cron-no-postgres.php` pede rotinas de cron, e **duas delas cobram de
verdade pelo Asaas**. A `const FORA` do próprio roteiro, escrita em 14/09/2026,
as exclui. Conferido antes de deixar rodar.

#### Continua de pé, e é seu

**Só você destrava a cobrança**, em *Billing & plans*. E só você decide empurrar
os 18 commits. Enquanto nenhuma das duas acontece, **11 dos 14 continuam não
conferidos** — e o roteiro novo diz isso em voz alta, toda vez.

#### O que foi feito enquanto isso

Os **seis** portões de CI foram rodados **à mão** contra o commit `516982c`, e os
seis saíram com 0. Somados aos 35 do `conferir-tudo.sh` e à suíte de 638 testes,
este commit específico está verificado. **Isso não se estende ao próximo:** cada
commit daqui para a frente precisa da mesma rodada manual enquanto o CI estiver
parado, e é exatamente o tipo de disciplina que ninguém sustenta por muito tempo.

#### O que ele precisa, e de quem

**Da sua conta do GitHub, em *Billing & plans*.** Não é coisa que se conserte no
repositório, e não é coisa que eu vá mexer: é pagamento. Assim que voltar, a
primeira execução verde fecha este débito — e vale olhar se ela roda os mesmos 14
a 25 passos por tarefa, e não uma versão encolhida.

#### Em 15/09/2026 os quatro que ainda eram só-CI vieram para o corredor local

**A tabela dos seis, acima, envelheceu — e envelheceu em silêncio, que é
exatamente o defeito que ela própria descreve.** Medido hoje, portão a portão:
três dos seis já rodavam no `conferir-tudo.sh` (`conferir-linha-sorteada`,
`conferir-referencias-docs` e `conferir-debitos-repetidos`), e um quarto que a
tabela não lista tinha entrado no CI depois dela.

O que estava fora do corredor local, e o que cada um defende:

| portão | estava só na CI | agora |
|---|---|---|
| `conferir-guarda-do-erp` | `ci.yml:125` | `conferir-tudo.sh`, `guarda do erp` |
| `conferir-recorte-por-unidade` | `ci.yml:133` | `conferir-tudo.sh`, `recorte por unidade` |
| `conferir-rotas-sem-login` | `ci.yml:159` | `conferir-tudo.sh`, `rotas sem login` |
| `auditar-group-by` | `ci.yml:346` | `conferir-tudo.sh`, `group by no postgresql` |

**O `conferir-recorte-por-unidade` é o mais gritante dos quatro** — e é preciso
ser exato sobre o porquê, porque a primeira versão deste parágrafo errava. **O
portão não é novo:** nasceu em 07/09/2026 e entrou no `ci.yml` no mesmo commit
(`89de19c`), que aliás já se chamava *"o portão do D131 não estava na CI"*. Ele
rodou de verdade, no CI, de 07 a 13/09.

O que é novo é a **41ª entrada** dele: `autenticarComoAction`, acrescentada hoje
(`511f32b`) para vigiar a porta da personificação do D127. Essa entrada **nunca
foi executada por corredor nenhum** — nasceu dois dias depois de o CI parar, num
portão que só vivia lá. A falsificação daquele débito foi feita à mão, e a partir
de hoje ela também é executada a cada `conferir-tudo.sh`.

É a máxima da casa se cumprindo sobre o trabalho de hoje: *portão que não roda é
pior que portão que não existe*, porque a existência dele dá uma confiança que
ele não está ganhando.

Medi os quatro do host antes de pendurá-los: **os quatro saem com 0**.
`auditar-group-by` nem toca no banco; `conferir-rotas-sem-login` precisa do
`vendor/autoload.php`, que localmente existe — foi justamente a falta dele que o
fazia falhar no runner com saída 255 antes de 11/09.

**Isto não substitui o CI**, e a distinção importa: lá eles rodam em máquina
limpa e contra três bancos. Substitui o **nada**. O corredor passou de 40 para
**44** portões, e a rodada completa depois da mudança: 43 verdes, 1 vermelho
conhecido (o `agendamento das rotinas`, do D50), nenhuma falha inesperada.

#### Em 07/10/2026, a outra direção (D441)

Tudo acima pergunta o que o CI roda e o corredor não. A pergunta contrária -- o que o corredor roda e
o CI não -- ficou sem dono até 07/10/2026, e a resposta era 36 roteiros, mais os testes do front, que
a primeira versão da conta nem enxergava. Ela mora no **D441**: os 28 roteiros e os testes do front
ganharam passo no `ci.yml`, e 8 ficam fora com o motivo numa segunda tabela do mesmo
`conferir-lacuna-do-ci.sh`, que agora para nas duas direções. Ele roda, só a declaração, no
`conferir-tudo.sh`; no job `verificacao` ele está ESCRITO, como os outros passos novos. Nenhum deles
rodou no runner -- nem o da própria lacuna: o bloqueio desta página continua.


#### Em 07/11/2026, a fatia 102 (CI-VERDE-NO-RUNNER): a cobrança voltou, e o vermelho passou a ser do código

**A parte do dono está feita.** `gh run list` mostra jobs rodando de novo desde 10/10/2026, com todos os
passos, em cerca de 4 a 4,5 min por execução -- e nenhuma verde nas 200 últimas. Os vermelhos não eram mais
da conta. Lidos com `gh run view --log-failed` nas execuções **42338443751** (o commit 696791ae) e
**42347466612** (o dd0b4ae3, o HEAD de então), os mesmos cinco casos, só no runner:

| job | caso | o que era |
|---|---|---|
| MySQL 8.4 e 8.0 | `NascimentoSemHoraTest::testNoDiaSemMeiaNoiteODiaVemAMeiaNoite` | `'UTC'` no lugar de `'America/Sao_Paulo'`: o PHP do `setup-php` roda em UTC, e o job do MySQL não fixava o fuso (o do PostgreSQL fixa, desde 04/09/2026) |
| MySQL 8.4 e 8.0 | `RegrasDoNpsTest::testOMesEODiaDaResposta` | `1785553199` contra `1785542399`: o texto `'2026-07-31 23:59:59'` lido no fuso do processo (UTC) e o mesmo texto em São Paulo, 3 h de diferença |
| PostgreSQL | `TemporariaSemAChaveComORecorteTest`, os três conjuntos | "a linha passou para outro inquilino (1 linha)": o job entra como o `POSTGRES_USER` do serviço, que a imagem cria **superusuário** -- e superusuário ignora o RLS mesmo com o `FORCE`. Aqui a suíte entra como `bvip_app`, que não é |

**Nenhum era defeito da aplicação, e nenhuma linha de produção mudou.** O conserto é do ambiente ou do caso:

- **O fuso do job do MySQL**, em `.github/workflows/ci.yml`: `ini-values: date.timezone=America/Sao_Paulo` no
  `setup-php` (como o job do PostgreSQL e o `docker/php/php.ini`), e `SET GLOBAL time_zone='-03:00'` no banco,
  ao lado do `sql_mode` (como o `--default-time-zone` do `docker-compose.yml`, que registra por que só o PHP
  em São Paulo com o banco em UTC é a combinação que derruba os royalties das 21h à meia-noite -- e o job
  roda de noite). E a **afirmação** de que os dois pegaram, no "Configurar o ambiente": `test` do
  `date_default_timezone_get()` e do `@@global.time_zone`, para que um `ini-values` que não pegue fique
  vermelho ali, e não quinze passos depois.
- **O caso do PostgreSQL** mede o comportamento por quem não ignora o recorte: quando a conexão é
  `rolsuper` ou `rolbypassrls`, ele cria, dentro de um ponto de salvamento, um papel sem nenhum dos dois,
  passa a ele a posse da temporária (para que o `FORCE` continue sendo o que se mede, como com a
  `bvip_app`) e faz `SET LOCAL ROLE`; o `ROLLBACK TO SAVEPOINT` desfaz o papel, a posse e o papel da sessão
  juntos. Quando a conexão não ignora (aqui), mede direto, como antes. O comentário do `ci.yml` que dizia
  "o dono ignora RLS" ganhou o porquê exato: ignora por ser superusuário.

**Provado aqui, e não só escrito:**

- com o PHP em UTC (`php -d date.timezone=UTC`, a mesma suíte montada como o CI monta), os dois do MySQL
  caem com os **mesmos números do runner** (`1785553199` contra `1785542399`); com
  `-d date.timezone=America/Sao_Paulo` (o que o `ini-values` faz), `NascimentoSemHoraTest|RegrasDoNpsTest|CampanhaAudienciaTest`
  dão 57 de 57. O `TZ` do sistema não entra: o `date.timezone` do `php.ini` manda no PHP, aqui e no runner;
- o PostgreSQL montado como o do CI e lido como **superusuário** (o `franquiabv` daqui, `rolsuper`): o caso de
  antes cai nos três conjuntos com a mesma frase do runner; o novo passa (3 de 3, 39 asserções -- as 3 a mais
  por conjunto são as do papel); sem o `SET LOCAL ROLE`, cai nos três com "o papel da medida não ignora o
  recorte". Nenhum papel sobra no servidor depois (`pg_roles`, 0).

**O que resta, e por que é PARCIAL:** ver a primeira execução verde no runner. O commit desta fatia é o que a
dispara; o que ela deve mostrar está no D441. A memória "o CI parou por cobrança" está atrasada desde
10/10/2026: os jobs iniciam.

#### Em 07/11/2026, a verificação da fatia 102: os cinco não eram tudo -- o passo das telas do PostgreSQL

A frase de cima dizia que "o que restava eram estes cinco casos". **Não era verdade**, e a verificação achou
lendo execuções mais antigas: as duas lidas pela fatia caíam na caracterização do PostgreSQL e pulavam os 28
passos seguintes, e atrás dela havia outro vermelho, determinístico, que nenhuma das duas mostrava.

- **A concessão batia na chave.** O passo "Telas do ERP (nenhuma pode responder 500)" dá ao gerente de fixture
  (90001001) todas as funcionalidades de fixture antes de medir, com um `INSERT ... SELECT` sem desvio. As
  fixtures já lhe dão dez delas, e desde 17/10/2026 (788c1e79) o `postgres/schema.sql` tem a unicidade
  `(inquilino_id, usuario, funcionalidade)`. Nas execuções 39085954433 (18/10), 41372978356 (02/11),
  41782686665 (04/11) e 41940012867/41987442908 (05/11) a caracterização do PostgreSQL saiu VERDE e este passo
  caiu com `duplicate key value violates unique constraint "privilegios_usuarios_inquilino_id_usuario_funcionalidade_unico"`,
  pulando os 27 seguintes. De 06/11 em diante ficou escondido atrás do `TemporariaSemAChaveComORecorteTest`.
  O `scripts/medir-telas-no-postgres.sh` tinha o mesmo `INSERT` nos dois ramos: no do PostgreSQL ele abortava o
  roteiro; no do MySQL (a unicidade `uq_privilegios_usuarios_usuario_funcionalidade` do `mysql.sql`) o `|| true`
  engolia o erro, e o MySQL era medido só com as dez.
- **E antes da chave, o piso.** A última execução com o passo verde foi a 38790993312 (15/10, df9321b7: 117 de
  246). A seguinte, 38814125072 (4693b8f2), deu 116 -- vermelho pelo piso da cobertura, dois dias antes da
  chave. Ninguém viu: o passo cai antes de imprimir a lista, e de 17/10 em diante nem chegava a medir.
- **E depois da medição, os ensaios.** O bloco do `ci.yml` dizia que este passo vinha "POR ÚLTIMO", porque
  concede privilégios; hoje 27 passos vêm DEPOIS dele, e a maioria dos ensaios monta o próprio cenário de
  privilégio do mesmo gerente. Com todas as telas concedidas, 8 batiam na mesma chave ao gravar o privilégio
  que testam, 6 recusavam rodar ("o gerente já tem privilégio de ... que não é deste ensaio") e o de recusas via
  a VIP Class recusar pelo motivo errado (3 recusas). Desde 15/10 nenhum passo depois das telas rodou no
  runner, e os ensaios que entraram depois disso nunca rodaram lá com a concessão na frente.

**Medido aqui, e não só lido.** O job do PostgreSQL repetido na ordem do runner, num banco descartável montado
como o do CI e lido como superusuário (`franquiabv_pgci102`, apagado no fim), com a concessão e a retirada
LIDAS do `ci.yml`: com o `ci.yml` desta fatia antes da verificação, o passo das telas saiu 1 (98 de 227, piso
117) e 15 ensaios caíram (14 com saída 2 e o de recusas); com a correção, **os 31 passos saíram 0** -- o
contrato, a concessão, as telas, a retirada, a rotina do cache, os 23 ensaios, o medidor da `/adm/api`, a
concessão de novo e os relatórios. Fora da repetição, pelas
regras da casa: a caracterização (desfeita caso a caso; roda inteira no ciclo), as rotinas de cron (no lugar
delas, só a do cache que o relatório de atendimentos lê) e o fechamento da agenda.

**O conserto**, sem mudar código da aplicação:

- a concessão é repetível -- `ON CONFLICT (inquilino_id, usuario, funcionalidade) DO NOTHING` no `ci.yml` e no
  ramo do PostgreSQL do roteiro, `NOT EXISTS` no do MySQL;
- o passo das telas lê o maior id antes de conceder e, depois da medição, **retira o que concedeu** (as linhas
  do gerente acima daquele id; as das fixtures ficam): os ensaios veem o banco como no corredor local;
- o passo dos relatórios, o último, concede de novo (o gerente precisa delas para pedir os relatórios);
- o `scripts/medir-telas-no-postgres.sh`, que roda as telas, o cron, 17 ensaios e os relatórios na mesma ordem,
  ganhou o mesmo: as duas concessões viram variáveis usadas antes das telas e antes dos relatórios, e a retirada
  vem depois das telas, nos dois ramos. Rodado inteiro uma vez nesta verificação, antes da retirada, nos dois
  bancos: as telas deram 98 de 227 no PostgreSQL e no MySQL (o MySQL, com a concessão inteira pela primeira vez),
  nenhuma com erro do servidor, e os ensaios depois delas caíram como no runner (o de cupons na chave). Depois da
  retirada ele não foi rodado de novo: chama o `medir-cron-no-postgres.php`, que pede pela rota as rotinas de
  cron -- o `relatoriosRoyaltiesAtualizar` entre elas --, e a casa não as pede fora do CI sem necessidade; o caso
  abaixo roda os textos dele;
- o piso da cobertura desceu de 117 para 98, com o motivo escrito ao lado da constante: a administração antiga
  da VIP Class saiu inteira (18 ações, 13 alcançadas em 15/10), saíram os relatórios antigos de clientes, de
  evasão, de aniversariantes e o de franquias potenciais, e telas fechadas ao gerente ou movidas para o /novo
  deixaram de ser alcançáveis (as Unidades, c02d7cee, de 4 para 7 não alcançadas). A conta não foi feita ação
  por ação -- a lista de 15/10 não existe mais --; o alcance de hoje é determinístico e é o piso novo.

**O caso que pega:** `ConcessaoDoUsuarioDeFixtureTest` lê o TEXTO das concessões no `ci.yml` (as telas e os
relatórios) e no roteiro, e o roda duas vezes contra as fixtures, nos dois bancos; e roda o maior id de antes,
a concessão e a retirada do passo das telas, conferindo que as linhas do gerente voltam a ser as das fixtures,
pelo id -- e o mesmo com o maior id, a concessão e a retirada de cada ramo do roteiro, no banco dele. Mutantes,
um por vez, vermelhos: cada concessão sem o desvio (as duas do `ci.yml` e as duas do roteiro, com a mesma frase
do runner), o passo sem a retirada, a retirada antes da medição, e a retirada do roteiro que tira todas as
linhas do gerente (nos dois ramos).

### D188 — Dois demos do jcrop executavam POST sem login no webroot, e um deles imprimia o caminho do servidor — REMOVIDOS em 14/09/2026 — CORRIGIDO em 07/10/2026: a pasta `demos` saiu inteira

Achado em 14/09/2026, e achado **por um parágrafo**. Ao escrever o portão do
D34 eu redigi a seção "o que ele não cobre", dizendo que ele olhava só os sete
diretórios de upload e não o resto do webroot. Escrever a limitação foi o que
me fez medi-la.

Três `.php` estavam versionados sob `public/`. Um é o front controller. Os
outros dois eram demos da biblioteca de recorte de imagem:

| Arquivo | O nginx | O PHP |
|---|---|---|
| `public/global/plugins/jcrop/crop-demo.php` | serve, 200 | **executa** |
| `public/global/plugins/jcrop/demos/crop.php` | serve, 200 | **executa** |

A regra de `deny all` do D34 cobre `arquivos`, `contratos`, `banners`,
`assistentes`, `vip-class`, `visagismo`, `logs` e `layout/img/avatar`. Não
cobre `global` — e não deveria: ali moram o CSS e o JS de todo o ERP. O
problema nunca foi a regra; era haver `.php` naquele diretório.

#### A medição, e ela não é teórica

Um POST sem sessão, sem cookie, sem nada:

```
POST /global/plugins/jcrop/crop-demo.php   x=0&y=0&w=10&h=10
  -> code=200  tipo=image/jpeg  bytes=1485
```

`Content-Type: image/jpeg` é a prova de que o PHP rodou: quem produziu aquilo
foi o `imagejpeg()` do arquivo, não o nginx servindo texto.

E os dois não são iguais. O `crop-demo.php` embrulha cada `$_POST` em
`intval()`. O `demos/crop.php` **não**, e passa a string crua para uma função
que exige `int`:

```
POST /global/plugins/jcrop/demos/crop.php   x=abc&y=0&w=10&h=10
  -> Uncaught TypeError: imagecopyresampled(): Argument #5 ($src_x) must be of
     type int, string given in /var/www/html/public/global/plugins/jcrop/demos/crop.php:21
     Stack trace: #0 ...(21): imagecopyresampled(Object(GdImage), Object(GdImage),
     0, 0, 'abc', '0', 150, 150, '10', '10')
```

O caminho absoluto do servidor, o número da linha e os argumentos, para quem
mandar quatro campos de formulário.

#### O tamanho real, dito sem inflar

Não é execução remota e não é leitura de arquivo arbitrário: a imagem de origem
é **fixa** no código (`./demo_files/image5.jpg`), e o destino é 150×150 fixo.
O que de fato havia:

1. **Superfície sem dono.** Dois endpoints sem login que ninguém sabia que
   existiam, em código que nunca foi escrito para este sistema.
2. **Divulgação de caminho e de versão** por mensagem de erro, num arquivo que
   não passa por tratamento de erro nenhum.
3. **Nada do ERP se aplica ali.** Não é rota do Laminas, então não passa pelo
   ACL, não passa pelo `ErroJsonListener` do D16 e não aparece em log da
   aplicação. Todo o trabalho de envelope de erro vale para quem entra pelo
   front controller; estes dois entravam por fora.

O `display_errors` deste contêiner está `On`, e é dele que veio o rastro acima.
**Se produção mostra o mesmo, não se confere daqui** — essa pergunta já está
registrada no D16 e continua lá, sem mudar de dono.

#### O conserto foi apagar, e custou o que tinha de custar

O Jcrop **é usado**: `module/ISBase/view/layout/layout.phtml` carrega o
`jquery.Jcrop.min.css` (linha 55) e o `jquery.Jcrop.min.js` (linha 731). A
biblioteca fica. Os dois demos saíram, e a varredura da árvore inteira antes de
apagar não achou uma citação sequer a eles — a única menção a `crop.php` no
projeto era o `action` do formulário do próprio demo, apontando para si mesmo.

Ficam os demos em HTML e as imagens de exemplo, que são estáticos e inertes. A
pergunta "por que há demos de biblioteca vendorizada no webroot" continua de pé
como arrumação, não como risco.

#### O vigia

`scripts/conferir-protecao-do-webroot.php` passou a valer o webroot inteiro
contra uma lista de permitidos de dois itens — `public/index.php` e o
`.htaccess` do esqueleto do Laminas. Falsificado: um `.php` plantado em
`public/global/` derruba o portão nomeando o arquivo, e um permitido que deixa
de existir derruba o portão duas vezes, acusando o órfão na lista **e** o
arquivo que virou intruso.

A lição vale além daqui: **escrever o que o instrumento não cobre é barato, e
às vezes é a medição.** O parágrafo custou dois minutos e achou o débito.

#### A arrumação que sobrava, feita em 07/10/2026 (o item CODIGO-MORTO-E-DOCS)

**"Ficam os demos em HTML" estava errado:** nenhum demo em HTML foi versionado (o
`git log` da pasta mostra só o `crop.php` e a `demo_files/`). O que sobrava em
`public/global/plugins/jcrop/demos/` eram os **11 arquivos** de `demo_files/` -- dois
CSS e nove imagens de exemplo --, servidos pelo nginx e citados por ninguém: `git grep`
por `demo_files`, `jcrop/demos` e pelos nomes das imagens achou só este registro. A
pasta saiu inteira (contados os alvos antes: 11 arquivos, 13 entradas com as duas
pastas). A biblioteca fica: `css/` e `js/` seguem, e o `layout.phtml` os carrega como
antes. O `conferir-referencias-docs.php` continua verde.

**E a pasta de volta é acusada** (corrigido na verificação do mesmo dia; a primeira
versão desta seção dizia que nenhum portão a acusaria). O vigia do webroot olha só
`.php`, mas a remoção pôs `public/global/plugins/jcrop/demos/` em `$explicadas` do
`conferir-referencias-docs.php`, porque este registro e o roadmap a citam. Se a pasta
existir, o laço pula a entrada antes de marcá-la vista, e ela sai como "EXPLICACOES QUE
NAO SE APLICAM MAIS" -- saída 1. **Medido:** recriada a pasta, mesmo vazia, o portão
sai com 1 nomeando-a; apagada, volta a 0. Pega a pasta, e não um arquivo solto dentro
de `jcrop/` com outro caminho.

### D189 — A grade da agenda lê 241.218 linhas para devolver 189, e o conserto tem duas metades que consertam coisas diferentes — PREPARADO em 21/09/2026: o código entrou, o `ALTER` espera a janela — PARCIAL, lido em 05/10/2026 — PARCIAL em 06/10/2026: os seis `DATE()` por cliente viraram faixa — PREPARADO em 07/11/2026: a migração do índice no passo 0

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como preparado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: o `ALTER` do índice (colaborador, data) e a leitura do `innodb_buffer_pool_size` em produção; os seis `DATE(age.data)` na Fase 1; o índice de `filtrarClientesPrimeiraVisita`.
>
> **06/10/2026:** os seis `DATE(age.data)` saíram — ver "Os seis, trocados", no fim deste registro. Falta o que é da janela: o `ALTER` do índice, a leitura do `innodb_buffer_pool_size` de produção e o índice de `filtrarClientesPrimeiraVisita`.

Achado em 16/09/2026 ao ligar a tela nova em `/adm/api/agendas/grade`: 200 com a
grade certa, em **22 segundos**. Medido de verdade em 21/09/2026, com
autorização explícita para criar e remover o índice na cópia local.

**A medição derrubou três afirmações desta própria ficha.** Elas estão
corrigidas abaixo, e o que as derrubou está dito em cada lugar.

#### O trabalho desperdiçado, que é o defeito de verdade

Os 14 colaboradores da unidade 1 somam **241.218 linhas** em `agendas`. Para
desenhar **um dia**, o plano lê todas elas — e devolve no máximo 189. Razão de
quase 1.300 para 1. O dia mais cheio que esses colaboradores já tiveram em toda
a história tem 501 linhas.

O plano é `ref` em `FK_AGENDAS_colaborador` seguido de uma busca de linha por
candidato, porque `data` não está em índice nenhum e precisa ser materializada
da tabela para o filtro ser avaliado. Por isso **um dia sem nenhum agendamento
custa o mesmo que um dia cheio**: o preço é a varredura, não o resultado.

#### Os 22 segundos NÃO transportam para produção — e isto corrige a ficha

A ficha dizia "22 segundos" como se fosse uma propriedade do sistema. É uma
propriedade **deste laptop**. Medido no mesmo código, mesma consulta, mesmo dia,
mudando só `innodb_buffer_pool_size`:

| `montar()`, dia cheio, SEM índice | pool de 128 MB | pool de 2 GB |
|---|---|---|
| tempo | 20,9 a 35,7 s | **0,90 s** |
| páginas pedidas ao InnoDB | 1.030.260 | 989.586 |
| páginas vindas do disco | 3 a 5% | **0%** |

O trabalho lógico é o mesmo — 989.586 contra 1.030.260 páginas. O que muda é
caber na memória. `agendas` ocupa 546 MB (281 de dado, 264 de índice) e o
buffer pool deste ambiente é **128 MB, o padrão de fábrica**: o
`docker-compose.yml` não define `innodb_buffer_pool_size` e não há `.cnf` no
projeto. A tabela não cabe, e cada rodada relê do disco.

**O que não se sabe, e que governa a leitura deste débito:** o
`innodb_buffer_pool_size` de produção. `docs/operacao-atual.md` registra a VPS
como 4 vCPU / 16 GB / NVMe, mas o valor do pool nunca foi lido — e o padrão do
MySQL é 128 MB **independentemente da RAM da máquina**. Se produção nunca foi
ajustada, os números da coluna esquerda valem lá. Se foi, vale a direita.
Ler isso é uma linha de comando e está na lista da Fase 1.

#### O conserto, medido: duas metades que consertam coisas DIFERENTES

A ficha dizia "as duas juntas — uma sem a outra não adianta". Meio certo, e a
razão escrita estava errada. Medido, com o índice `(colaborador, data)` criado:

| pool 128 MB, consulta da grade (14 chamadas) | sem índice | com índice |
|---|---|---|
| forma `DATE(age.data) = :data` (hoje) | 17,5 s | **0,080 s** |
| forma faixa `>= :inicio AND < :fim` | 15,6 s | **0,014 s** |

**A metade que conserta HOJE é o índice.** E o mecanismo não é o que a ficha
supunha: é **Index Condition Pushdown**. Com `data` na folha do índice, o MySQL
avalia o `DATE()` dentro do próprio índice e não busca as linhas que não casam.
Isto não é leitura de rótulo no `EXPLAIN` — foi medido pelo contrafactual, que
é uma variável de sessão:

| com índice, pool 128 MB | ICP ligado | ICP DESLIGADO |
|---|---|---|
| forma `DATE()` | 0,080 s | **30,1 s** |
| forma faixa | 0,014 s | 0,016 s |

Desligar o pushdown devolve o custo inteiro, **375×**. A forma em faixa é
indiferente a ele, porque não precisa de filtro: ela busca.

**A metade que conserta AMANHÃ é a reescrita.** Na forma `DATE()`, o índice é
usado com **uma** coluna de chave: o `EXPLAIN` diz `Index lookup (colaborador=200),
with index condition: (cast(data as date) = ...)`. A segunda coluna não é chave
de busca, é filtro varrido sobre **todo o histórico do colaborador**. A forma em
faixa a promove a chave: `Index range scan over (colaborador=200 AND '2026-08-06'
<= data < '2026-08-07')`.

A consequência não é de tamanho, é de forma de crescimento. Medido, mesmo dia,
mesmo índice, mesma resposta, variando só o histórico:

| linhas do colaborador na tabela | forma `DATE()` | forma faixa |
|---|---|---|
| 88.604 | 24,58 ms | 0,76 ms |
| 37.049 | 10,68 ms | 0,78 ms |
| 26.837 | 8,24 ms | 0,85 ms |
| 16.027 | 6,23 ms | 0,90 ms |
| 4.281 | 2,08 ms | 0,81 ms |
| 59 | 0,67 ms | 0,64 ms |

`DATE()` segue o histórico numa reta (~3.300 entradas por milissegundo). A faixa
é **plana**. Com só o índice, a agenda encarece a cada agendamento gravado, para
sempre; com a reescrita, para de encarecer. É por isso que "a reescrita é um
arredondamento" — que eu cheguei a escrever no meio da medição — está errado.

#### No PostgreSQL a reescrita é OBRIGATÓRIA, e isso decide a Fase 1

**Não existe Index Condition Pushdown no PostgreSQL.** Medido no `postgres:16`
deste ambiente, com as mesmas 4.000.084 linhas, com o papel de aplicação
`bvip_app` (RLS ativo, sem `BYPASSRLS`) e `random_page_cost` ajustado para NVMe:

| com o índice `(inquilino_id, colaborador, data)` | plano | tempo |
|---|---|---|
| forma `DATE(data) = :dia` | cai no índice VELHO de colaborador e descarta 88.575 de 88.604 linhas | **237,5 ms** |
| forma faixa | `Index Scan` com a condição inteira, RLS incluída | **0,102 ms** |

**2.330×.** Na forma `DATE()` o planejador não alcança a coluna `data` de jeito
nenhum — nem com o índice perfeito disponível. Ou seja: **aplicar só o índice no
MySQL produz um conserto que evapora na migração.** O ICP é uma muleta do MySQL
que o PostgreSQL não oferece.

Dois achados laterais do mesmo ensaio, que valem para toda a Fase 1 e não só
para a agenda:

- `random_page_cost` está em **4** no Postgres deste ambiente — valor de disco
  giratório. Com ele o planejador prefere varrer 4 milhões de linhas em paralelo
  a usar índice; baixando para 1,1 (valor de NVMe, que é o disco da VPS) ele
  passa a usar. Não é específico da agenda: é um parâmetro de custo que enviesa
  **todo** plano do sistema migrado.
- `shared_buffers` está em **128 MB**, o mesmo padrão de fábrica do MySQL.

#### A reescrita é equivalente, provado sobre a tabela inteira

Trocar `DATE(age.data) = :data` por `age.data >= :inicio AND age.data < :fim` só
é legítimo se devolver exatamente as mesmas linhas. `data` é `DATETIME` e
**3.999.907 das 4.000.084 linhas têm hora diferente de meia-noite** — então a
faixa tem de ser `[dia 00:00:00, dia+1 00:00:00)`, nunca igualdade.

Verificado em **todas as 4.000.084 linhas**, não por amostra: zero linhas caem
fora da janela do próprio dia, zero datas nulas, inválidas ou de ano zero.

(A primeira tentativa foi amostrar 19.811 pares colaborador-dia e comparar os
conjuntos de ids. Desenho ruim: mais lento, e mais fraco. A equivalência é uma
propriedade de cada linha e se prova na tabela inteira com uma consulta.)

#### O que o índice custa

| | MySQL 8.4 | PostgreSQL 16 |
|---|---|---|
| tempo para criar | 11 a 20 s | 6 s |
| tamanho | 81,6 MB | 120 MB |

No MySQL os índices da tabela vão de 252 para 334 MB (+32%), sobre 281 MB de
dado. É menor que o índice de `cliente`, que já existe. O custo recorrente é
manter mais um índice por escrita em `agendas` — com 15.041 agendamentos
futuros no banco inteiro, desprezível.

#### O que sobra depois do conserto

Com o índice posto, o próximo da fila é `filtrarClientesPrimeiraVisita`: **23.409
das 25.991 páginas** de um `montar()` em dia cheio, 90% do trabalho lógico
restante. É `vendas` (2,27 M linhas) com `WHERE cliente IN (...) AND status = 1`,
e `status` não está no índice. Mesma doença, outra tabela. Não foi medido a
fundo nem consertado: fica registrado aqui e **não** vira débito novo, porque é
a mesma forma do D189 e o conserto é o mesmo tipo de índice.

#### Correções que esta medição fez em mim, e como elas apareceram

Nenhuma veio de ler o código com mais atenção. Todas vieram de medir.

1. **"22 segundos"** era o laptop. Um pool de 2 GB põe o mesmo código em 0,90 s.
   Quem levantou: revisão adversarial, ao apontar que 128 MB é padrão esquecido
   e que produção tem 16 GB. Confirmado medindo.
2. **"a reescrita é um arredondamento"** — eu escrevi isso no meio da noite,
   depois de ver 0,080 s contra 0,014 s, e estava errado. A reescrita é o que
   impede o custo de crescer com o histórico. Levantado pela revisão, provado
   pela tabela de crescimento acima.
3. **"a contagem de páginas transporta para produção"** — retirado.
   `Innodb_buffer_pool_read_requests` é um contador **global**, sem escopo de
   sessão; um verificador mediu deriva de fundo de 1,1 milhão numa janela de
   20 s, maior que o número que eu atribuía à consulta. O número que transporta
   é o do `EXPLAIN`: **241.218 linhas examinadas para devolver 189**, que é
   propriedade do plano. (As contagens de página seguem citadas acima como
   ordem de grandeza, e não como medida atribuível a uma consulta.)
4. **Medi um dia vazio sem perceber.** 16/09/2026 não tem agendamento nenhum
   porque a cópia anonimizada termina em meados de agosto de 2026 — e isso
   inclui o "hoje" que a tela abre por padrão. O dia vazio serve de controle do
   estado SEM índice, onde o custo independe do resultado; **não** serve de prova
   do conserto, porque COM índice ele pula duas consultas e o laço de
   `buscarAgenda`, e diverge do dia cheio por 21× em páginas. Dia cheio usado:
   2026-08-06, 189 agendamentos.
5. **Inflei o custo do `primeiraVisita` em 120×** ao reproduzir sua consulta em
   SQL cru incluindo o cliente 2 — o genérico de balcão, com 63.517 vendas — que
   o serviço exclui de propósito. 3,18 s viraram 0,026 s ao medir o serviço de
   verdade em vez da minha transcrição dele.

#### O conserto, quando for autorizado

1. Trocar `DATE(age.data) = :data` por faixa em
   `AgendaRepository::selecionarPorColaboradorData`. **Obrigatório** — sem ele o
   Postgres não usa índice nenhum. Nove arquivos alcançam o método, e a mudança
   é de predicado, não de retorno.
2. Criar `(colaborador, data)` em `agendas` — no MySQL de produção e no gerador
   de DDL do PostgreSQL, que é onde o esquema novo é declarado. Medido: o
   esquema do Postgres espelha o do MySQL, índices feitos à mão inclusive
   (`vendas_idx_vendas_data_status` existe lá sem estar no mapeamento Doctrine),
   desde que seja regerado depois.
3. Ler o `innodb_buffer_pool_size` de produção antes de prometer qualquer
   número em segundos.

#### CORRIGIDO EM CÓDIGO em 21/09/2026 — e a segunda metade mudou de forma quando foi medida

O registro acima diz que o conserto tem duas metades. Tem — mas a segunda não é
"criar um índice". É **ampliar um que já existe**, e isso não foi deduzido: foi
medido, depois de a forma óbvia falhar.

#### A primeira metade: a reescrita, que entrou

`DATE(age.data) = :data` virou `age.data >= :inicio AND age.data < :fim` em
**dois** métodos — e o segundo é o achado desta passagem (abaixo).

**Equivalência provada, e não por amostra escolhida:** 521 pares
(colaborador, dia), **6.362 linhas comparadas**, incluindo as bordas de
meia-noite e as de depois das 23h — **zero divergências**. E 4 de 4 casos no
método por unidade. A referência foi a forma antiga, escrita em SQL cru ao lado.

#### O GÊMEO, que ninguém tinha ligado ao D189

`selecionarPorUnidadeData` tem a mesma doença e o mesmo tamanho. Ela entra pela
unidade, mas o caminho até as linhas é o mesmo: o plano percorre os **127
colaboradores** da unidade e, para cada um, busca o **histórico inteiro** dele —
3.068 linhas em média. São **389.636 buscas de linha para devolver 189**.

**E isso explica um número que estava solto no registro desde 16/09:**
`/adm/api/agendamentos` devolvia *"48 bytes em 73 segundos"*, reprodutível, e a
causa não era conhecida. É esta consulta.

#### A segunda metade: AMPLIAR, e não acrescentar

O índice `(colaborador, data)` **ao lado** do `FK_AGENDAS_colaborador` conserta a
grade e **não conserta a listagem por unidade**: o otimizador do MySQL continua
escolhendo o índice de uma coluna. Ele estima o plano bom como **mais caro**
(297.713 contra 258.624) e acredita nisso — inclusive depois de `ANALYZE TABLE`.

| medido em 21/09/2026, cópia de produção | grade de um dia | listagem por unidade |
|---|---:|---:|
| como está hoje | 17,5 s | 21,4 s |
| só a reescrita, sem índice | 15,6 s | 20,4 s |
| reescrita + índice **ao lado** do velho | 0,08 s | **30,5 s** |
| reescrita + índice **no lugar** do velho | **0,72 ms** | **95,7 ms** |

A última linha é o conserto. E ele é **mais barato** que a forma óbvia: em vez de
somar 82 MB, troca um índice de 69,6 MB por um de 81,6 — **+12 MB**.

**E é seguro, conferido e não suposto:** `colaborador` é PREFIXO do composto,
então ele serve à chave estrangeira. Testado no MySQL, em banco descartável:
criado o composto, o `DROP INDEX FK_AGENDAS_colaborador` é **aceito**, e a
restrição `FK_AGENDAS_colaborador → usuarios` continua de pé. A ordem segura é
criar antes de remover, que foi a testada.

#### O que entrou no repositório

1. **PostgreSQL, já:** `gerar-ddl-postgres.php` passou a **ampliar** o índice —
   `agendas_FK_AGENDAS_colaborador (inquilino_id, colaborador, data)`. A Fase 1
   nasce com ele, sem índice redundante. A coluna de inquilino vem na frente
   pela regra de `fase-1-plano.md`.
2. **A reescrita**, nos dois métodos, com a medição escrita ao lado.
3. **A normalização da entrada**, que fechou o **D194** de lambuja.

#### O que FALTA, e é decisão sua

**O `ALTER` no MySQL de produção.** São duas linhas, nesta ordem:

```sql
CREATE INDEX IX_AGENDAS_COLABORADOR_DATA ON agendas (colaborador, data);
DROP INDEX FK_AGENDAS_colaborador ON agendas;
```

Criar leva **11 a 20 segundos** nas 4 milhões de linhas, e é `INPLACE` — não
bloqueia leitura. Mas é mudança no servidor, e a regra desta casa desde
11/09/2026 é que nada muda lá antes da janela do corte.

**Enquanto o `ALTER` não acontece, a reescrita sozinha não acelera nada** —
medido: 15,6 s contra 17,5 s. Ela entrou mesmo assim por dois motivos: sem ela o
índice não adianta quando chegar, e no PostgreSQL ela é **obrigatória**, porque
lá não há pushdown.

E continua valendo a terceira pendência do registro acima: **ler o
`innodb_buffer_pool_size` de produção** antes de prometer qualquer número em
segundos.

#### Os seis predicados que FICARAM, e por quê

Restam seis `DATE(age.data)` no mesmo arquivo, todos filtrando por `cliente`.
Medido: o índice de cliente entrega **115 linhas**, e a consulta custa **15 ms**.
Mesmo padrão, outra ordem de grandeza — e cada reescrita é risco. Ficam
registrados aqui para não virarem descoberta futura: quando a Fase 1 chegar, eles
merecem a mesma passagem, porque no PostgreSQL o `DATE()` custa mais do que aqui.

#### Os seis, trocados — 06/10/2026

Os seis `DATE(age.data)` por cliente viraram a faixa do `diaInteiro()`:
`horariosMarcadosPorClienteData` (o dia pedido, e "de hoje em diante" como
`>= hoje 00:00:00`), `selecionarAgendaCliente` (o check-in da tela antiga),
`selecionarProximoAgendamentosCliente` (o aplicativo, a V4 e a validação do
agendamento), `servicosMarcadosPorClienteData` (a comanda aberta pela agenda) e
`agendaPorClienteUnidadeData` (o fechamento do pagamento). O dia ausente continua vazio,
como o `DATE(age.data) = NULL` dava. Não é Fase 1 por decisão de ninguém: o registro
acima dizia "quando a Fase 1 chegar", e era plano.

**Não acelera nada hoje, e isso foi conferido.** O índice que serve essas consultas é o
de `cliente`, e `data` não está nele: no PostgreSQL da cópia, as duas formas entram em
`agendas` pelo mesmo `Index Scan using agendas_fk_agendas_cliente`, com a data no
`Filter` — o que pode mudar é a ordem das junções em volta, porque a estimativa de linhas
muda (ver o D426, abaixo). A troca é de FORMA: a coluna nua é o que deixa um índice futuro com `data`
servir de chave, e no PostgreSQL não há pushdown que disfarce a outra.

**A equivalência, medida na cópia, só com `SELECT`, nos dois bancos** (o
`franquiabv_dev` do MySQL e o do PostgreSQL, este com o papel `bvip_app`, RLS ativo,
fuso da sessão `America/Sao_Paulo` e `data` em `timestamptz`). Nada de dado pessoal
impresso: só contagens.

| | MySQL | PostgreSQL |
|---|---:|---:|
| linhas da tabela | 4.000.085 | 4.000.084 |
| linhas fora da faixa do PRÓPRIO dia (a tabela inteira) | **0** | **0** |
| linhas às 00:00:00 em ponto | 177 | 177 |
| linhas depois das 23h (nenhuma depois das 23:59) | 2 | 2 |
| `=`: dias / pares (dia, unidade) / linhas, forma antiga contra a nova | 369 / 4.599 / 148.981 | 369 / 4.599 / 148.981 |
| `=`: divergências | **0** | **0** |
| `>=`: dias / pares / linhas somadas | 32 / 1.653 / 62.309.803 | 32 / 1.653 / 62.309.771 |
| `>=`: divergências | **0** | **0** |
| os seis métodos de verdade, HEAD contra hoje: triplas (cliente, unidade, dia) / chamadas / linhas | 4.059 / 24.790 / 660.782 | 4.059 / 24.790 / 630.686 |
| ids diferentes / só a ORDEM diferente | **0** / **0** | **0** / **36** |

A amostra de dias: o último e o primeiro dia de cada mês de 01/2023 a 09/2026 (a
virada), os 111 dias com linha às 00:00:00 e os vizinhos deles, os 22 dias de troca do
horário de verão brasileiro de 2008 a 2019 e as vésperas (no PostgreSQL a meia-noite de
alguns deles não existe no fuso da sessão), os dias com linha depois das 23h, e 40
sorteados com semente fixa. As linhas por dia e unidade passam pelo Doctrine, com a
mesma tradução do `DATE` (`CAST(... AS date)` no PostgreSQL) e a mesma ligação de
parâmetro do código. Os seis métodos foram chamados pelo arquivo do HEAD, com outro nome
de classe, e pelo de hoje, sobre os mesmos pares — os ids e a ordem.

**A única diferença, e ela é de entrada, não de dado:** o texto vazio. `DATE(x) = ''` é
erro nos dois bancos (`DriverException`, MySQL 1525); a forma nova devolve vazio, que é a
regra do irmão `selecionarPorColaboradorData` desde 21/09 (o `DiaDaAgendaTest` a
prende). Nenhum chamador manda `''`: `DataHora::stringDate()` devolve `null` para entrada
vazia, e os outros mandam `date('Y-m-d')`. Medido nos quatro métodos que recebem o dia
de fora, nos dois bancos; registrado aqui em vez de ajustado.

**E a ordem, no PostgreSQL — o D426.** Os ids são os mesmos em todas as chamadas, nos dois
bancos. A ORDEM é a mesma em todas no MySQL, e em 36 de 24.790 no PostgreSQL ela muda:
8 são empates do `ORDER BY age.data` (dois horários na mesma data e hora), e 28 são de
`servicosMarcadosPorClienteData`, que **não tem `ORDER BY` nenhum**. Nele, cada forma é
estável consigo mesma (as 28 chamadas que mudaram, repetidas: 28 de 28 iguais a si mesmas,
nenhuma igual à outra forma); quem muda a ordem é o PLANO: na forma antiga o
PostgreSQL estima 19 linhas no índice de cliente e entra pelos produtos da unidade; na
nova, 1, e entra pela agenda. A ordem nunca foi definida — o banco escolhe —, e é
justamente a da comanda aberta pela agenda, onde ela decide qual serviço gasta a sessão do
pacote. Não foi ajustada aqui: a ordem a escrever é regra de negócio, e foi ao lote --
decidida em 06/10/2026 (pergunta 23: a ordem do id), no **D426** -- a da comanda entrou em 07/10/2026; o
desempate dos quatro voltou ao lote.

**E um defeito achado no caminho: o D425.** `diaInteiro()` aceitava `'2026-02-31'` e
respondia pelo 3 de março, porque `createFromFormat` transborda e só avisa. Agora recusa,
como o `DATE()` recusava — e a conferência entrou ANTES de a faixa chegar ao check-in.

**Os casos** (`tests/Caracterizacao/DiaDoClienteNaAgendaTest.php`, 10), nos dois bancos:
o último dia de um mês com horários às 00:00:00 e às 23:59:59, a véspera às 23:59:59, a
virada às 00:00:00 do dia 1, a outra unidade e um inativo; "de hoje em diante" com a
00:00:00 de hoje e as 23:59:59 de ontem; o dia ausente; o dia que não existe (D425); e a
FORMA, lida no SQL que chega ao banco — a coluna `data` nua nos seis. Os casos de
resultado passam também sobre o `DATE()` (é a equivalência); o da forma é o que fica
vermelho se um dos seis voltar.

**Falsificado, um mutante por vez, restaurado pelo sha256, nos dois bancos:**

| mutante | vermelho |
|---|---|
| `agendaPorClienteUnidadeData` com o fim fechado (`<=`) | `testOPagamentoVeODiaDaMeiaNoiteAs235959` |
| `selecionarProximoAgendamentosCliente` com o início aberto (`>`) | `testOsProximosAgendamentosComecamNaMeiaNoiteDoDia` (e o da forma) |
| `horariosMarcadosPorClienteData` "de hoje" com `>` | `testOsHorariosMarcadosDeHojeEmDiante` (e o da forma) |
| `horariosMarcadosPorClienteData` no dia, com `<=` | `testOsHorariosMarcadosNoDiaPedido` |
| `servicosMarcadosPorClienteData` com o fim esquecido | `testOsServicosDaComandaSaoOsDoDiaNaUnidade` |
| `selecionarAgendaCliente` DE VOLTA ao `DATE(age.data) = :data` | só `testAColunaDataChegaNuaAoBancoNosSeis` — os de resultado ficam verdes, como devem |
| `diaInteiro()` sem conferir o aviso do calendário (D425) | `testODiaQueNaoExisteNoCalendarioRecusa` |
| `diaInteiro()` com o dia seguinte um segundo tarde | quatro casos de fronteira |

**Por HTTP, o contrato do aplicativo passa por eles — corrigido na revisão de
06/10/2026.** A primeira versão desta passagem (e a do roadmap) dizia que nenhum ensaio
por HTTP alcançava os seis, e o contrato não tinha rodado depois da troca. Errado:
`ApiV4Test::testAgendaAutenticada` (`GET /api/v4/clientes/{id}/agenda`) e
`AplicativoApiTest::testClienteAgendaAutenticado` passam por
`selecionarProximoAgendamentosCliente`, e `testAgendaSelecionar` e
`testAgendaSelecionarV2ComDados` passam pelo `diaInteiro()` que o D425 mudou (pelo
`selecionarPorColaboradorData`). Rodado depois da troca: **67 de 67 no MySQL e no
PostgreSQL**. Com o `diaInteiro()` recusando toda data (um mutante, restaurado pelo
sha256), os quatro ficam vermelhos com 500, nos dois bancos — o contrato atravessa mesmo o
código trocado. O snapshot confere o tipo, e não o valor: o resultado continua provado
pelos casos e pela equivalência.

**E um irmão que não era destes seis:** `totalAgendamentoPorMesAnoColaborador` filtra por
`MONTH(age.data)` e `YEAR(age.data)` — função sobre a mesma coluna, no par
`(colaborador, data)` do índice deste débito. Registrado no D427, com a medida.

**O que continua de pé neste débito**, e é da janela: o `ALTER` do índice
`(colaborador, data)` no MySQL de produção, a leitura do `innodb_buffer_pool_size` de lá,
e o índice de `filtrarClientesPrimeiraVisita`. Nenhum dos três muda aqui.

#### A migração do índice, no passo 0 — 07/11/2026 (a fatia 114, ESQUEMA-QUE-SO-ACRESCENTA)

**O que estava de pé** era da janela: o `ALTER` do índice no MySQL de produção, a leitura do `innodb_buffer_pool_size` de
lá e o índice de `filtrarClientesPrimeiraVisita`. Conferido antes de mexer: nenhuma das 28 `scripts/migracao-*.sql` fazia o
índice, e o `mysql.sql` ainda dizia `FK_AGENDAS_colaborador (colaborador)`; as duas cópias locais já o tinham com
`(colaborador, data)`, com o mesmo nome, desde 02/10/2026 (a F4.1 do Profissional Parceiro), e o gerador do PostgreSQL já o
ampliava. **O terceiro já não estava de pé:** o índice das vendas por cliente e status
(`migracao-vendas-indice-cliente-status-data.sql`, o **D308**, a resposta 294 (a)) é o que serve `filtrarClientesPrimeiraVisita`.

**O que entrou:**

- `scripts/migracao-agendas-indice-colaborador-data.sql`, no molde da das vendas por cliente: troca o
  `FK_AGENDAS_colaborador` de uma coluna pelo de `(colaborador, data)` **com o mesmo nome**, num `ALTER` só (`DROP INDEX` e
  `ADD INDEX`, `ALGORITHM=INPLACE, LOCK=NONE`), idempotente (já ampliado: nada; sem o índice: cria) e parando com saída 1,
  sem mudar nada, com o nome noutras colunas. O nome é o mesmo, e não o `IX_AGENDAS_COLABORADOR_DATA` que o item 2 da etapa 1
  e este registro diziam: é o que as cópias têm e o que o gerador amplia, e o ampliar NO LUGAR, e não ao lado, é o que este
  registro mediu (30,5 s com os dois, 95,7 ms com um só);
- a linha `| 0 | **Aplicar**` e o laço do passo 0 de `docs/procedimento-de-corte.md`, o laço do ensaio geral e o `KEY` de
  duas colunas no `mysql.sql`, juntos (o `scripts/conferir-passo-zero-do-corte.php` saiu 0, e o trecho que compara as
  colunas do índice com o mesmo nome, da 76b, o cobra); o item 2 de "O que falta" e o passo 4 da
  `docs/procedimento-de-corte-etapa-1.md` dizem que está feita;
- o `postgres/schema.sql` regerado: igual nesse índice (o `$indicesAmpliados` já o dava).

**Nas cópias**, às 18:56:47, ela disse "indice ja ampliado, nada a fazer", duas vezes, em 0,09 e 0,07 s.

**Os casos:** `PassoZeroDoEsquemaQueSoAcrescentaTest` (o portão sobre uma cópia do `mysql.sql`: o controle sai 0, e com o
`KEY` devolvido a uma coluna sai 1, com o motivo) e `EsquemaQueSoAcrescentaTest::testOIndiceDaGradeEDoColaboradorComAData` (o
catálogo dos bancos da suíte, nos dois: o de duas colunas, e o de uma coluna fora). **Os mutantes**, um por vez, devolvidos
pelo sha1: o `KEY` do `mysql.sql` de volta a uma coluna (vermelhos o controle do portão, o caso do `KEY` e o do catálogo no
MySQL) e a migração trocando para `(colaborador)` (vermelho o controle do portão).

**O que continua de pé, e é da janela:** rodar a migração na produção, pelo laço (o passo 4 da etapa 1), e ler o
`innodb_buffer_pool_size` de lá antes de prometer qualquer número em segundos.

**A correção da verificação (07/11/2026): a migração RODADA, e não só lida.** A verificação achou que só o ramo "índice já
ampliado" tinha rodado (nas cópias), e que nenhum caso executava o `.sql`: a troca, o único ramo que a produção roda, e a
parada nunca tinham rodado. **Ensaiada num banco descartável** (`descartavel_f114_ensaio`, apagado no fim) com a `agendas`
da cópia (4.000.085 linhas, sem o cliente e a observação) na forma da produção -- o índice de uma coluna e a chave
estrangeira para uma `usuarios` só com os ids -- e o buffer pool de fábrica (128 MB, MySQL 8.4.13): o `DROP INDEX` sozinho
recusado (1553); a troca em 3,6 s (a tabela recém-copiada, na memória), a chave estrangeira de pé, as linhas iguais; a
segunda vez "índice já ampliado" em 0,05 s; e, com o nome em `(colaborador, status)`, a parada com o `mysql` saindo 1, o
`|| break` do laço parando nela e o índice intocado (num segundo descartável, `descartavel_f114_parada`, também apagado).
Os 3,6 s não dizem o tempo da produção, fria e maior: lá segue a leitura do `innodb_buffer_pool_size`. **O caso que a roda a
cada suíte**, no MySQL: `EsquemaQueSoAcrescentaTest::testAMigracaoDaGradeTrocaOIndiceDeUmaColunaEParaNaFormaQueNaoConhece`
(o roteiro inteiro, numa tabela de ensaio com o nome trocado no texto e a chave estrangeira para uma tabela de ensaio, por uma
conexão própria: a troca, a segunda vez, a criação sem o índice e a parada). **Os mutantes**, devolvidos pelo sha1: o
`WHEN @colunas = 'colaborador'` com outro literal (a produção cairia na parada), a parada trocada por um `SELECT` inofensivo
e o `WHEN @colunas IS NULL` trocado -- os três vermelhos nesse caso.

### D190 — Duas ações da agenda leem e ESCREVEM na franquia que o POST mandar, e a guarda do D150 nunca guardou nada — CORRIGIDO em 20/09/2026 — CORRIGIDO em 05/10/2026

Achado em 20/09/2026 por revisão adversarial da fatia da grade, e **confirmado
por leitura direta do código**, não só pelo revisor.

O comentário de `AgendasController::atualizarAction` (linhas 124-129) diz, com
todas as letras, que o buraco foi fechado:

> A UNIDADE VINHA DO POST, com a sessao apenas como valor padrao: bastava
> trocar o numero para ler a agenda de outra franquia -- quem atende quem, a
> que horas, com telefone no balao.

E lá a correção existe: `!empty(...) && $this->unidadePermitida(...) ? ... : $this->unidadeId`.
**Ela não alcançou outras duas ações do mesmo arquivo.**

#### A que lê — `checkinAction:579`

```php
$retorno['tabela'] = $this->getAgenda(str_replace("/", "-", $request['data']), $request['unidade_id'], true);
```

`$request['unidade_id']` sai de `getPost()->toArray()` e vai direto. Quem tem
`agendas/gerenciar/escrever` numa unidade recebe a grade RENDERIZADA de
qualquer outra — e, por ter `gerenciar`, cai no ramo do `tabela.phtml:43`, que
põe **nome e telefone do cliente** no `data-original-title` de cada horário.

O JS real já manda o campo (`mod-cadastros.js`: `unidade_id: $("#unidade").val()`);
basta trocar o valor.

#### A que APAGA — `abrirAction:212` e `:222`

```php
$fechamento = $this->em->getReference(AgendaFechamento::class, $request['id']);
...
$retorno['sucesso'] = $this->service->abrir($fechamento);   // remove() + flush()
$retorno['tabela']  = $this->getAgenda(..., $request['unidade'], true);
```

Id cru e unidade crua. `AgendaService::abrir()` é `remove()` + `flush()`: o
fechamento de agenda de **outra franquia** é apagado, e a grade dela volta na
resposta.

De quebra, `getReference()` devolve proxy preguiçoso — `!empty($fechamento)` é
sempre verdadeiro, e o ramo `'Não foi possível localizar.'` é **código morto**.

#### O que ISTO NÃO É

Não é regressão de 45f9042. `git blame` da linha 579 aponta o próprio commit do
D150 — a correção nasceu incompleta. O que 45f9042 mudou foi o caminho: essa
leitura passa hoje pelo `GradeDaAgendaService`, que **por desenho declarado não
lê sessão** ("quem decide é o CHAMADOR"). O serviço está certo; os dois
chamadores é que não decidem nada.

#### O conserto, e por que ele não foi feito junto com o registro

As duas irmãs no mesmo arquivo já mostram a forma: `unidadePermitida($id)` para
a unidade e `agendaDaMinhaUnidade($agenda)` para o objeto — usadas nas linhas
347, 374, 421, 477 e 520.

O que cobra decisão é que **hoje esses pedidos funcionam**. Passar a recusá-los
muda comportamento do ERP em produção, e a regra desta casa é que nada muda lá
sem validação. Fica aguardando quem manda.

#### O portão que faltava

`scripts/conferir-recorte-por-unidade.php` vigia 40+ métodos e não vigia estes
dois. Um portão que cobre `Controller/` legado — "toda ação que recebe unidade
ou id por POST confere antes de usar" — é o que impede a terceira.

#### CONSERTADO em 20/09/2026 — e a raiz era pior do que este registro dizia

Ao consertar, a primeira medição derrubou a premissa deste débito. Eu havia
escrito que as duas ações **ficaram de fora** da correção do D150. Ficaram — mas
isso era o menor dos problemas: **a correção do D150 nunca funcionou**.

```php
private function unidadePermitida($unidadeId) {
    return $this->sessao->getAcl('agendas', 'geral')   // ← DOIS argumentos
            || (int) $unidadeId === (int) $this->unidadeId;
}
```

Com dois argumentos, `getAcl` devolve a funcionalidade inteira —
`['ler' => false, 'escrever' => false]` —, que é arranjo **não vazio** e portanto
**verdadeiro**. O primeiro termo do `||` era sempre `true`, o segundo nunca era
olhado, e **todo mundo passava**. É o padrão exato do D123, dentro do conserto do
D150.

**Medido pela tela de sempre**, com `user200` (unidade 1, gerencia agenda, **sem**
`Agendas unidades`), em `/mod-cadastros/agendas/atualizar`:

| pediu | colunas | células | **telefones** | bytes |
|---|---|---|---|---|
| unidade 1 (dele) | 15 | 313 | 66 | 311.320 |
| **unidade 48** | **12** | **262** | **88** | **269.643** |

E a mesma forma inerte estava em `UnidadeRepository::popularComboboxAgenda`, na
negação: o `AND u.id = :unidade` nunca entrava, e a lista de unidades saía
**inteira** para qualquer sessão. Medido pela falsificação do teste novo: **36
unidades** onde deviam ser 1.

**A tela escondia o seletor** (`index.phtml:63` usa a forma de três argumentos,
certa), e por isso o buraco não aparecia usando o sistema. Esconder na tela não é
guardar no servidor.

#### O que entrou

1. **`unidadePermitida()`** passou a usar `getAcl('agendas', 'geral', 'ler')`.
   Medido depois: `user200` pedindo a unidade 48 recebe **exatamente os mesmos
   311.320 bytes** da própria — a unidade pedida é ignorada. E `user1`
   (administrador) continua alcançando a 48, com os mesmos 269.643 bytes de
   antes: a guarda mede nas **duas** direções.
2. **`popularComboboxAgenda()`** idem.
3. **`checkinAction`** ganhou a conferência de unidade na grade — e o recorte da
   **escrita**, que era pior: `selecionarAgendaCliente` filtra por cliente e data
   e mais nada, então um check-in invertia o estado de todos os agendamentos
   daquele cliente naquele dia **na rede inteira**. O filtro ficou no controller,
   e não na consulta, porque ela tem oito outros chamadores.
4. **`abrirAction`** ganhou três consertos: `find()` no lugar de
   `getReference()` — o proxy preguiçoso fazia `!empty()` ser sempre verdadeiro e
   o ramo "não foi possível localizar" era **código morto** —, a conferência da
   unidade do fechamento, e a da unidade da grade. Medido sem apagar nada, com
   `acao=pesquisar`: fechamento próprio responde, o da unidade 48 e um id
   inexistente recebem a **mesma** recusa.

#### O portão, porque isto é classe de defeito e não caso isolado

`scripts/conferir-acl-como-booleano.php` — a terceira vez que esta armadilha
morde. Ele varre 836 arquivos, acha as 119 chamadas a `getAcl()` e acusa as que
têm **menos de três argumentos em posição booleana**. Hoje: zero.

**E a falsificação dele achou um defeito nele mesmo.** A primeira versão testava
o caractere imediatamente antes de `getAcl(` — que em `!$sessao->getAcl(...)` é
`>`, e não `!`. Ele saiu **verde sobre o defeito que motivou escrevê-lo**. Agora
descarta o receptor antes de decidir, e foi refalsificado nas **duas** formas.

#### O que NÃO foi falsificado, e por quê

O recorte da **escrita** do check-in está consertado e revisado, mas não
falsificado: devolver o defeito exigiria um check-in de verdade no banco de
desenvolvimento, e escrita ali pede autorização. A leitura das duas ações e a
recusa do `abrirAction` foram medidas sem escrever uma linha.

#### O recorte da escrita do check-in, falsificado — CORRIGIDO em 05/10/2026

O impedimento era o banco: devolver o defeito pedia um check-in de verdade no de
desenvolvimento. Com o `ControllerEmProcesso`, o `AgendasController` de verdade é
despachado no banco de teste, dentro da transação que o `tearDown` desfaz.

`tests/Caracterizacao/D190CheckinEmDuasFranquiasTest.php`, cinco casos (nove com os da verificação, abaixo; quinze desde o D422 e a verificação dele, em 07/10/2026; dezessete com os dois do conserto do D437 e dezenove com os dois da verificação dele, no mesmo dia -- os cinco que provam a escolha do dia estão no D437). O cenário nasce
no caso: o cliente 90001000 com um horário no colaborador 90001000 (unidade 90001000) e
outro no 90001150 (unidade 90001150), no mesmo dia -- desde 07/10/2026, o primeiro dia sem
horário nenhum do cliente e dos dois colaboradores a partir de daqui a 45 dias; o "daqui a 45
dias" cru caía, em 09/11/2026, no horário fixo da fixture em 24/12/2026 (D437). A sessão é o dublê
com a aridade do `SessaoAcl` (arranjo com dois argumentos, booleano com três), o do
`D237AgendaRecortePorUnidadeTest`.

| caso | afirma |
|---|---|
| o check-in na minha unidade | o meu horário marca a chegada; o do mesmo cliente, no mesmo dia, na outra franquia, fica como estava |
| o segundo check-in | o interruptor desfaz só o meu; o da outra continua como a outra o deixou |
| com "Agendas unidades" | só o horário da unidade do horário clicado vira, e a chegada que a outra franquia registrou fica — a regra do D422, decidida pelo dono (pergunta 22) e escrita em 07/10/2026. Até essa data o caso dizia o contrário, como fato e não como desejo: os dois viravam |
| a grade, pedindo a `unidade_id` da outra | volta a da minha unidade |
| a grade, com "Agendas unidades" | volta a da unidade pedida |

A grade é reconhecida pelo id do colaborador no botão "Agendar" de cada horário livre, e
não pelo nome: a coluna mostra o primeiro nome, e os dois se chamam "Colaborador". E o
colaborador 90001150 da fixture tem `visivel_agenda = 'sim'`, fora da grade
(`RetratoDoHorario::NA_AGENDA`): o caso o põe em `ext_int`, com a agenda aberta, dentro
da transação — sem isso, a grade da outra sairia vazia e os dois casos da grade seriam
verdes pelos dois lados.

**Falsificado**, um mutante por vez e restaurado byte a byte: sem o `array_filter` por
`agendaDaMinhaUnidade`, os dois casos da escrita ficam vermelhos (o horário da outra
franquia vira junto); com a `unidade_id` crua do POST, o caso da grade da minha unidade.

#### A verificação do mesmo dia: o `abrirAction`, uma correção e uma pergunta

**O `abrirAction` não tinha caso, e é a ação que APAGA.** O título fechou acima com só o
check-in provado; o conserto do `abrirAction` (o `find()`, `fechamentoDaMinhaUnidade` e a
unidade da grade) tinha sido medido uma vez por HTTP, só com a consulta do período, e
quem o vigiava era o portão textual. O mesmo arquivo de teste ganhou dois casos, com um
fechamento em cada franquia criado no caso:

| caso | afirma |
|---|---|
| abrir o fechamento da outra franquia | a mesma resposta do id que não existe ("Não foi possível localizar.", sem `tabela`), e os dois fechamentos continuam lá |
| abrir o da minha, pedindo a `unidade` da outra | o meu é apagado, o da outra não, e a grade que volta é a minha |

**Falsificado**, um mutante por vez e restaurado byte a byte: sem `fechamentoDaMinhaUnidade`,
o primeiro caso fica vermelho nos dois bancos — o `remove()` + `flush()` rodou, `sucesso`
verdadeiro com a grade da outra; com `getReference()` no lugar do `find()`, o id que não
existe estoura (`EntityNotFoundException`) em vez de recusar; com a `unidade` crua do POST,
o segundo.

**A correção: a consulta não tem "oito outros chamadores".** O item 3 de "O que entrou",
acima, e o comentário do `checkinAction` diziam que o recorte ficou no controller porque
`selecionarAgendaCliente` "tem oito outros chamadores". Tem um, o próprio
`checkinAction` — conferido pelo `git grep` no `d9595c42`, o commit do conserto, e no
anterior. O recorte fica no controller porque a regra é dele (`agendaDaMinhaUnidade`, com
a exceção de "Agendas unidades", que vem da sessão), e o repositório não lê sessão. O
comentário de produção foi corrigido. E o teste ganhou a chamada direta da consulta — os
dois horários do cliente, um em cada franquia, o pré-requisito dos casos da escrita —,
porque a descida do `conferir-cobertura-por-par` de 147 para 146 se apoiava só no nome
dela num comentário.

**A pergunta: "Agendas unidades" desfaz a chegada da outra franquia.** O caso "com
'Agendas unidades'" foi escrito como "o comportamento de antes, para quem pode", e trava a
regra mais larga sem que ninguém a tenha decidido: o clique no horário da minha unidade
vira também o da outra, e se a outra já registrou a chegada, ela é desfeita. Um caso novo
o diz como fato. A outra porta da mesma ação (`Adm\AgendamentosController::checkinAction`)
mexe só na família do horário clicado, só na unidade da sessão, e nunca desfaz. Registrado
como **D422**, com a pergunta no lote -- decidida em 06/10/2026 (pergunta 22: só a unidade do
horário clicado), e implementada em 07/10/2026 (no D422).

### D191 — `/adm/api` usa o mesmo código para "não entrou" e "não pode", e a tela manda refazer um login que já existe — CORRIGIDO em 21/09/2026

Achado em 20/09/2026, pela revisão adversarial da fatia da grade. O defeito que
ela apontou era meu: eu havia escrito no cabeçalho de `AgendaLigada.tsx` que o
componente `Erro` **distingue** "a sessão caiu" de "faltou permissão". Não
distingue, e não tem como.

#### A medição

Toda ação de `/adm/api` abre assim:

```php
if ($this->unidadeId === null) {
    return $this->erroPermissao('É preciso estar autenticado no ERP para usar esta API.');
}
```

`erroPermissao()` é **403 `sem_permissao`** — o mesmo par que a linha seguinte
usa para "está identificado e não tem o privilégio". São **22 ocorrências em 19
controllers**, sem exceção.

Do lado do navegador, `ErroDaApi.pedeLogin` trata `sem_permissao` como "entre de
novo". Logo os dois casos caem no mesmo ramo, e quem está logado e só não tem
`agendas/visualizar/ler` é mandado refazer um login que já tem.

**O próprio `RespostaApi` avisa disso, por escrito**, no docblock do 401:

> DIFERENTE DE 403, e a distincao importa: 401 quer dizer "entre de novo". 403
> quer dizer "voce esta identificado, e isto nao e seu", e a tela nao deve mandar
> o cliente refazer o login por causa dele.

O método certo já existe e não é chamado por ninguém nesse ramo:
`erroAutenticacao()` → 401 `nao_autenticado`.

#### Por que não foi consertado junto

Trocar os 22 pontos é mecânico, mas o **403 está congelado em cinco roteiros**:

| roteiro | o que congela |
|---|---|
| `ensaiar-permissao-adm-api.php` | `$ok = $status === 403` — em **39 rotas** |
| `ensaiar-recusas-adm-api.php` | dois casos com `'http' => 403, 'codigo' => 'sem_permissao'` |
| `medir-adm-api-no-postgres.php` | "sem sessao os onze respondem 403" |
| `definir-senha-contrato.php` | "a falta de sessao ja da 403 sozinha" |
| `conferir-rotas-sem-login.php` | a anotação da rota da grade |

E `frontend/src/api/cliente.teste.ts` tem caso congelado afirmando que
`403 sem_permissao` ⇒ `pedeLogin`. É uma mudança coordenada de API, portões e
front — não cabia dentro de "conserte os seis da fatia".

#### O que foi feito enquanto isso

A frase falsa saiu do cabeçalho, e `Erro` passou a **dizer a verdade**: a
mensagem apresenta as duas possibilidades e oferece as duas saídas, em vez de
afirmar com confiança a causa errada. Tela que não tem como saber não deve
fingir que sabe.

#### CORRIGIDO em 21/09/2026 — e havia um argumento CONTRÁRIO, escrito, que este registro não mencionava

Ao consertar, a primeira leitura achou o que o registro tinha deixado de fora:
**a escolha do 403 estava defendida por escrito**, dentro do ajudante privado de
`Adm\ComandasController`:

> *403, e nao 401. A autenticacao do ERP e a sessao atual, e quem chega sem ela
> nao esta "nao autenticado" no sentido do protocolo — nao ha desafio a devolver,
> nem cabecalho `WWW-Authenticate` que faca sentido enquanto o mecanismo de token
> nao existir. Quando ele existir, isto vira 401 com o desafio certo.*

Registrar como defeito algo que tem defesa escrita, sem citá-la, é o mesmo erro
que o D191 acusa: decidir sem olhar os dois lados.

**A defesa está factualmente certa e mesmo assim não vence.** A RFC 7235 exige
`WWW-Authenticate` no 401, e não há desafio honesto para sessão de cookie —
inventar um esquema não registrado seria pior que omitir. **Mas o 403 também é
desvio, e é o desvio que engana:** a RFC 7231 define 403 como "entendi e recuso
autorizar", pressupondo credencial apresentada e insuficiente. Usá-lo para "não
há sessão nenhuma" diz ao cliente que entrar **não adianta**, quando entrar é
exatamente o que resolve.

Então a escolha não era entre desvio e conformidade: era entre **omitir um
cabeçalho que nenhum cliente desta casa lê** e **mandar a pessoa tentar o que não
funciona**. E `docs/convencao-api.md` já tinha escolhido, em dois lugares — *"401
não autenticado, 403 autenticado e sem permissão"*, e *"mandar o cliente refazer
o login por causa dele seria mentir sobre a causa"*. O que havia era uma
**segunda verdade sobre a convenção**, morando num docblock privado que ninguém
lia junto com ela.

O destino que aquele texto previa continua valendo: quando o token existir, este
401 ganha `WWW-Authenticate: Bearer` e o desvio acaba. Não se esperou por ele
porque o dano é de hoje.

#### A superfície era melhor que o registrado, e pior do que parecia

O registro dizia "22 ocorrências em 19 controllers", e estava certo na contagem.
Medido, a distribuição:

| forma | quantos |
|---|---|
| `private function semSessao()`, método pelado, sem uma linha de motivo | **14** |
| idem, com a defesa do 403 por extenso | 1 (`Adm\ComandasController`) |
| idem, apontando para a nota acima | 1 (`Adm\VendasController`) |
| a recusa escrita à mão dentro da ação | **6** (`AgendasController` ×4, `FormasPagamentoController`, `SessaoController`) |

**Vinte e duas cópias de uma regra já tinham divergido em quatro formas antes de
alguém olhar** — e 14 delas seguiam um argumento que não sabiam existir. É a
prova, no próprio material, do que a duplicação custa.

#### O que entrou

1. **`ApiAdmController::semSessao()`**, `protected`, devolvendo
   `erroAutenticacao()` → **401 `nao_autenticado`**. Uma regra, um lugar.
2. **As 16 cópias privadas saíram**, e as 6 recusas escritas à mão passaram a
   chamar o herdado. 152 linhas removidas contra 124 acrescentadas.
3. **`RespostaApi` não foi tocado**: `erroPermissao()` continua 403
   `sem_permissao`, e as **66 chamadas** que restam nos controllers são todas de
   falta de privilégio. O mundo do aplicativo (`/api/v4`) não foi alterado — lá o
   403 está certo, porque o telefone ESTÁ autenticado e o cliente é que não é
   dele.
4. **`ErroDaApi.pedeLogin`** deixou de aceitar o código e passou a olhar só o
   status; ganhou o irmão **`semPermissao`**. Manter a cláusula do código teria
   desfeito no cliente o conserto feito no servidor.
5. **A tela voltou a afirmar a causa.** `Erro` tem dois ramos: "a sessão do ERP
   caiu — entre de novo", com o link; e "esta tela não está liberada para o seu
   acesso — peça a quem administra", **sem** o link. Pôr a saída errada ao lado
   da certa é o defeito antigo em formato menor.
6. **Um ramo invertido apareceu no caminho.** `Comandas.tsx` usava `pedeLogin`
   para mostrar *"sem permissão para ver X — fale com o gerente"*: a condição
   dizia uma coisa e o texto a oposta. Passava porque a API respondia o mesmo
   código nos dois casos, então acertava metade das vezes por acaso.

#### Os cinco roteiros que congelavam o 403

| roteiro | o que mudou |
|---|---|
| `ensaiar-recusas-adm-api.php` | o **caso 1** passou a cobrar `401 nao_autenticado`; o caso 2 segue em 403 |
| `ensaiar-permissao-adm-api.php` | só a mensagem de recusa; as **39 rotas continuam cobrando 403**, que é o caso dele |
| `medir-adm-api-no-postgres.php` | dois comentários |
| `definir-senha-contrato.php` | um comentário |
| `conferir-rotas-sem-login.php` | dois comentários, com o tamanho **remedido**: 401 `application/json`, **106 bytes** — eram 403 e 104 |

E um detalhe que o ensaio de recusas afirmava e deixou de valer: *"NENHUMA DAS 22
RECUSAS TEM CÓDIGO PRÓPRIO"*. Agora **uma tem** — a de sessão ausente é a única
identificável pelo código sozinho. As outras 21 continuam precisando do fragmento
da mensagem.

#### Falsificado dos dois lados

**No servidor**, por HTTP: devolvendo `erroPermissao` ao ajudante, `/adm/api/sessao`
e `/adm/api/comandas` voltam a **403**; restaurado, **401**. O arquivo voltou ao
sha1 `17b0c5e1`.

**No front**, pela suíte: devolvendo `|| codigo === 'sem_permissao'` ao
`pedeLogin`, **dois casos ficam vermelhos** —
`cliente.teste.ts > separa "não entrou" de "entrou e não pode"` e
`AgendaLigada.teste.tsx > sem privilégio, diz que é permissão e NÃO manda
relogar`. O arquivo voltou ao sha1 `f1edb6cd`.

Front: **88 casos**, de 87. `tsc --noEmit` limpo.

#### A LACUNA FOI FECHADA no mesmo dia — e a escrita autorizada não foi usada

Faltava a prova **em tempo de execução** de que "entrou e não pode" continua
respondendo 403. Ela foi pedida e autorizada. Ao executá-la, duas coisas:

**1. O roteiro da senha tem guarda própria, e ela recusou.**
`definir-senha-contrato.php` respondeu:

> *este banco tem 64 unidades — não é o de contrato. O roteiro TROCA SENHA, e não
> roda fora do cenário de fixtures.*

Ele protege exatamente o que a restrição do dono protege. **Nada foi escrito no
`franquiabv_dev`**, nem com autorização na mão.

**2. O cenário certo já existia, e estava fresco.** O CI roda esse ensaio contra
o servidor apontado para o banco de FIXTURES, não para a cópia de produção. E o
`franquiabv_cilocal` — que `rodar-caracterizacao-como-ci.sh` monta do zero — tinha
a marca `4fa27696…`, **idêntica à do `fixtures.sql` de hoje**. (O
`franquiabv_contrato` estava velho: `264ea148…`.)

Então o procedimento foi o do `rodar-contrato.sh`, em passos discretos e sem
confiar em `trap`: cópia do `local.php`, `dbname` para `franquiabv_cilocal`,
proxies do Doctrine limpos, ensaios, restauração. O `local.php` voltou ao sha1
`177f82c6`, e a restauração conferiu a si mesma.

**O que foi medido:**

| ensaio | resultado |
|---|---|
| `ensaiar-permissao-adm-api.php` | **39 de 39 rotas recusaram com 403** — entrando como `colab.teste`, cujo grupo não tem privilégio nenhum |
| `ensaiar-recusas-adm-api.php`, caso 1 | `sem sessao` → **401 `nao_autenticado`** |
| `ensaiar-recusas-adm-api.php`, caso 2 | `sem privilegio de fechar comanda` → **403 `sem_permissao`** |
| o ensaio inteiro | **23 de 23**, 22 recusas e 1 controle que tem de passar |

Os dois lados da distinção ficam medidos **no mesmo arquivo**, que é o que
impede uma correção futura de derrubar metade sem ninguém ver.

**E a cópia anonimizada ficou intocada, provado e não alegado:** o estado de
`senha`, `salt` e `token_web` dos dois usuários de fixture em `franquiabv_dev`
foi capturado antes e comparado depois — **byte a byte idêntico**, sha1
`a911e9ed`.

### D192 — Quem só lê a agenda não alcança o painel de detalhe, e a tela legada mostra o balão aos dois — CORRIGIDO em 21/09/2026

Criado em 21/09/2026, ao ligar o painel de detalhe do horário. **É lacuna
declarada, não desenho** — e está escrita aqui porque capacidade a menos que o
ERP de hoje tem é dívida, mesmo quando a tela nova está ganhando em tudo o mais.

#### O que acontece

`HorarioDaAgenda` põe `disabled` no botão de quem não tem
`agendas/gerenciar/escrever`. Botão desabilitado não recebe clique, então o
painel não abre para essa pessoa.

A tela legada **mostra o resumo aos dois ramos**: `tabela.phtml:62` monta um
`title` com cliente, serviço, convênio e observação para quem só lê — sem
telefone, que é o recorte de privacidade. Ou seja, hoje o colaborador que só
consulta vê o balão; na tela nova ele fica com o `title` e sem o painel.

#### Por que não foi consertado junto

Porque arrancar o `disabled` agora **devolveria `podeEscrever` ao estado
decorativo** que a revisão adversarial acusou em 20/09: ele é a única coisa que
esse bit guarda hoje, e sem ação de escrita na tela não há o que ele trave.
Trocar um defeito medido por outro não é conserto.

#### Quando ele fecha

Quando o painel ganhar **ação de escrita** — marcar, check-in, remarcar, cujos
endpoints existem desde 05/09. Aí:

1. o `disabled` sai do horário e vai para os botões de agir;
2. `podeEscrever` passa a travar a ação, e não a leitura;
3. o painel abre para todo mundo, como o balão de hoje;
4. o caso `'quem só lê não alcança o painel — lacuna conhecida, D192'` muda
   junto, de propósito, para afirmar o contrário.

O caso de teste existe **para que a lacuna não passe despercebida**. Ele é a
única coisa que impede isto de virar comportamento aceito por esquecimento.

#### CORRIGIDO em 21/09/2026 — e consertar expôs que a guarda estava no bit ERRADO

O registro acima prescrevia quatro passos, e os quatro aconteceram. Mas ao abrir
a tela para conferir, apareceu um defeito **que eu ia entregar**: a guarda do
botão usava um privilégio que **não é o que a ação exige**.

#### O que entrou, como a ficha mandava

1. **O painel ganhou AÇÃO DE ESCRITA:** registrar a chegada do cliente,
   `POST /adm/api/agendamentos/{id}/checkin`. Medido antes de ligar: a rota
   responde **401** sem sessão e **404** no verbo errado — o verbo declarado
   guarda, e o 401 é o dividendo do D191.
2. **O `disabled` saiu do horário.** `HorarioDaAgenda` **perdeu a prop
   `podeEscrever`** inteira, e `ColunaDoBarbeiro` também — ela só repassava, e
   prop morta é o estado decorativo que a revisão acusou em 20/09.
3. **O painel abre para todo mundo**, como o balão da tela legada.
4. **O caso que afirmava a lacuna virou o contrário**, de propósito, como estava
   escrito aqui.

**Por que o check-in e não marcar/remarcar:** as três existem no servidor desde
05/09, e o check-in é a única de **um clique**. Marcar e remarcar pedem uma
fatia própria.

#### O DEFEITO QUE A TELA ACHOU, e nenhum teste acharia

A primeira versão guardava o botão com `podeGerenciar`, o único bit que a grade
publicava. **Está errado.**

`podeGerenciar` é `agendas/gerenciar/escrever` e governa o **recorte** —
telefone e nome inteiro. As **ações** vivem em `Adm\AgendamentosController`, que
declara `funcionalidade = 'visualizar'`: criar, check-in, remover e remarcar
exigem `agendas/visualizar/escrever`.

Medido sobre `privilegios_usuarios` — a camada operativa, que o `Acl` lê no
login, e **não** a tabela `privilegios`, que é só o molde do grupo (D157) —
entre os **371 usuários ativos**:

| situação | usuários |
|---|---:|
| **PODEM agir, e `podeGerenciar` diz que não** | **164** |
| **`podeGerenciar` diz que sim, e o servidor RECUSA** | **94** |
| têm os dois | 87 |
| não têm nenhum | 26 |

**258 de 371 — 70% — ficariam com o botão errado.**

E não é estatística: foi provado **na tela, com dois usuários reais**, sem
escrever nada (id inexistente devolve 404 quando o privilégio passa e 403 quando
não passa, e a conferência vem antes da busca):

| usuário | privilégio de escrita | grade dizia | servidor no check-in |
|---|---|---|---|
| `user380` | `agendas/visualizar/escrever` | `podeGerenciar: false` | **PERMITE** |
| `user5` | `agendas/gerenciar/escrever` | `podeGerenciar: true` | **RECUSA** |

Exatamente invertido.

#### O conserto do conserto

O recurso da grade passou a publicar **os dois bits**, cada um com o seu nome:

    podeGerenciar             agendas/gerenciar/escrever   -> o RECORTE
    podeEscreverAgendamento   agendas/visualizar/escrever  -> as AÇÕES

O parâmetro novo de `GradeDaAgendaJson::montar()` é **obrigatório**, e não com
valor padrão: padrão silencioso é como um chamador esquece e ninguém vê. Os
cinco pontos do teste que chamam o formatador foram atualizados.

E **o aviso da tela nomeava o privilégio errado** — dizia "falta
`agendas/gerenciar/escrever`" a quem, na verdade, pode agir. Corrigido junto.

#### Falsificado

| o que se devolve | o que fica vermelho |
|---|---|
| a permissão deixa de guardar a ação | 2 casos |
| o painel volta a não abrir para quem só lê | 2 casos |
| a tela volta a ler o bit do RECORTE | **4 casos** |

Os dois casos da distinção existem porque todos os outros movem os dois bits
**juntos** — e passariam com a tela lendo qualquer um deles.

Front: **94 casos**, de 88. `tsc --noEmit` limpo.

#### O que a tela achou e o CSS confirmou

`.agenda-detalhe-acoes` **não existia** na folha de estilo: o envoltório da ação
herdaria a margem padrão de parágrafo. O jsdom não aplica CSS, então os casos
ficam verdes com a classe existindo ou não.

Conferido depois na tela viva, com sessão de verdade: painel aberto em "10:00,
agendado · marcado pela web · Cliente 15052", com cliente, serviço, telefone,
convênio e o botão **Registrar chegada** — margem de 9px, borda,
`cursor: pointer`. E `button:disabled` já existia com `cursor: progress`, que
serve ao estado "Registrando…".

#### O que ficou para depois

**Marcar e remarcar**, que pedem formulário. E o **D195**, que é a causa de raiz
do defeito acima.

### D193 — O portão da cobertura por par não enxerga chamada que mora num Service, e a extração de 16/09 tirou duas do campo de visão dele — CORRIGIDO em 22/09/2026

Achado em 21/09/2026, rodando o corredor inteiro. O portão do D177 acusou:

```
ISCadastro: esperava 87 chamadas, achei 85.
```

E estava certo — mas o motivo não é "duas chamadas a menos".

#### O que aconteceu

`ISCadastro\Controller\AgendasController::getAgenda()` montava a grade ali
dentro, com `getRepository(Agenda::class)` e
`getRepository(Usuario::class)->selecionarUsuariosAgenda(...)`. Em 16/09 a
receita foi **extraída** para `GradeDaAgendaService`, para que a tela nova e a
antiga lessem a mesma montagem, e o controller passou a chamar o serviço.

As chamadas não sumiram: **mudaram de lugar**. O glob do portão entra em
`Controller/` e não em `Service/`, então ele deixou de vê-las.

#### O que salvou o conjunto, e por acidente

O sha1 dos 179 pares **não mudou** — mas só porque `selecionarUsuariosAgenda` e
`selecionarPorColaboradorData` continuam sendo chamados dos dois controllers de
`Controller/Adm/`. Fosse a extração a única via, o par teria **sumido do
conjunto** e o portão teria acusado com muito mais razão.

Ou seja: a contagem por módulo pegou o efeito, e o conjunto não pegou — por
sorte da topologia, não por desenho.

#### Por que não estendi o glob agora

Porque estender para `Service/` **rebaseia um portão congelado**: muda o sha1,
o total, a conta de todos os módulos e a lista dos "sem menção em teste". Fazer
isso às três da manhã, sem ninguém para revisar o novo baseline, é trocar a
fechadura e perder a chave — e um baseline errado é pior que nenhum, porque
parece guarda.

#### O que o conserto precisa carregar

1. o glob passa a incluir `Service/` (e provavelmente `Regra/`, que tem
   `RegraDeAgendamento` chamando repositório);
2. o sha1 e as cinco contagens são refeitos **e conferidos par a par**, porque
   "o número subiu" não distingue par novo de par que já existia e não era
   visto;
3. os pares que aparecerem pela primeira vez entram na leitura do D177 — a de
   12/09 foi feita sobre um conjunto que não os tinha.

Enquanto isso, a arquitetura deste projeto **empurra regra para Service de
propósito** — foi o que a fatia da agenda fez. Cada extração dessas tira
chamadas da vista deste portão, e a próxima pode tirar um par inteiro.

#### MEDIDO em 21/09/2026 — a cegueira é de 35%, e o rebase continua bloqueado por 16 chamadas

A ficha acima dizia que o portão perdeu **duas** chamadas de vista. Medido:
perdeu **noventa e sete**.

Estendendo o alcance para `Service/` e `Regra/` — **26 serviços e 1 regra**
chamam `getRepository`, e o portão via **zero** deles:

| | declarado | com `Service/` e `Regra/` |
|---|---:|---:|
| pares (repositório, método) | 179 | **276** |
| pares **sem menção em teste nenhum** | 64 | **153** |
| chamadas NÃO resolvidas | 6 | **22** |
| falsos positivos da medida por nome | 26 | 27 |
| ISApi | 24 | **121** |
| ISCadastro | 85 | 108 |
| ISConfiguracao | 110 | 129 |
| ISMarketing | 16 | 23 |
| ISBase | 0 | 1 |

**97 pares novos, e nenhum perdido** — o conjunto declarado é subconjunto
estrito do real. E a parte cega não é periferia: royalties, NPS, dashboard de
colaborador, cupons, sorteio, pontuação, pacotes e vendas. O `ISApi` saltar de
24 para 121 diz o essencial — a superfície do aplicativo mora quase toda em
serviço.

##### Por que a base NÃO subiu, e isto é a parte importante

As **chamadas não resolvidas vão de 6 para 22**. São dezesseis formas de chamar
que este resolvedor não lê, todas em serviço:

| onde | quantas |
|---|---:|
| `ISApi/Service/CronJobService` | 12 |
| `ISRelatorio/Service/FranquiaService` | 3 |
| `ISCadastro/Service/AgendaService` | 1 |

Todas do tipo `$repo->metodo()` onde o `$repo` é atribuído fora do alcance do
caminhador de tokens. **Rebasear com elas por resolver congela um número que já
se sabe incompleto** — e a própria ficha acima escreveu a razão: base errada é
pior que nenhuma, porque parece guarda.

##### O que entrou, e é o que destrava o resto

Duas opções de leitura, porque **até hoje era impossível olhar sem quebrar**:

```
MOSTRAR_PARES=1            php scripts/conferir-cobertura-por-par.php
MOSTRAR_NAO_RESOLVIDAS=1   php scripts/conferir-cobertura-por-par.php
```

A primeira imprime os 179 pares; a segunda diz **arquivo e método** de cada
chamada não resolvida. O requisito 2 da ficha — *"conferidos par a par"* — era
irrealizável sem elas: a única forma de ver a lista era fazer o sha1 divergir,
isto é, quebrar o portão para poder olhar.

**A medição ficou escrita ao lado do glob**, no próprio código, para que quem
abrir o arquivo veja o tamanho do que está fora antes de decidir.

##### O que falta, em ordem

1. ensinar o resolvedor a seguir `$repo = $em->getRepository(...)` atribuído em
   propriedade ou noutro escopo — as 16;
2. só então estender o glob e refazer as cinco linhas de base;
3. ler os 97 pares novos contra a leitura do D177, que foi feita sobre um
   conjunto que não os tinha — e 89 deles **não têm teste nenhum**.

#### CORRIGIDO em 22/09/2026 — o resolvedor escopa por função, e o portão passou a ver os serviços

Os três passos que a medição de ontem deixou em ordem, cumpridos em ordem.

##### 1. Por que as 16 chamadas não resolviam — e não era o que eu tinha escrito

Ontem registrei que eram atribuições "fora do alcance do caminhador de tokens".
Lendo, a causa era outra e mais simples: **o resolvedor rastreava variáveis por
ARQUIVO, e PHP as escopa por FUNÇÃO.**

| arquivo | variável | o que ela é, método a método |
|---|---|---|
| `CronJobService` | `$repoVenda` | `Venda` em quatro métodos, `VendaPagamento` num quinto |
| `CronJobService` | `$repoNps` | `Nps` num método, `NpsResultadoUnidade` noutro |
| `FranquiaService` | `$repository` | `VendaPagamento` num método, `Royalties` noutro |

Por arquivo, cada uma virava **ambígua** — e **todas** as chamadas por ela
ficavam sem resolver, inclusive as dos métodos onde o tipo era inequívoco.

O conserto é `escoposPorToken()`: cada `function` com corpo abre um escopo, e a
atribuição só vale dentro dele. Closure abre escopo próprio, e o que ela importa
por `use` sai **contado como não resolvido** — o lado seguro; o resolvedor nunca
adivinha. E `$this->metodo()` deixou de ser tratado como variável de
repositório: `AgendaService` chamando o próprio `desmarcar()` saía como
não resolvida só porque um repositório tem método com o mesmo nome.

##### 2. O efeito, medido em cada alcance

| | declarado, antes | declarado, com escopo | estendido, com escopo |
|---|---:|---:|---:|
| pares | 179 | 179 | **285** |
| não resolvidas | 6 | 2 | **2** |

**No alcance declarado nenhum par entrou ou saiu** — cinco das seis antigas
eram ambiguidade de arquivo e passaram a resolver, e nenhuma escondia par novo,
como a leitura de 12/09 já tinha afirmado.

**Uma NOVA apareceu, e ela é verdade:** `PesquisaNpsController:165` recebe o
repositório **por parâmetro**. Por arquivo, o resolvedor a "resolvia" com a
atribuição de **outro método** — e acertava por coincidência de nome, porque os
dois chamadores passam o mesmo tipo. Por função, ele diz o que de fato sabe:
dali não dá para saber. O par continua no conjunto pela linha 143, que chama na
forma direta.

##### 3. As duas que ficam foram lidas, uma a uma

| onde | por que não resolve | esconde par? |
|---|---|---|
| `AplicativoController::findEntity` | `getRepository($repository)->find($id)` — entidade por parâmetro | **não**: `find` é do Doctrine, nunca vira par |
| `PesquisaNpsController:165` | repositório por parâmetro | **não**: o par é alcançado na linha 143 |

Essa era a condição que a ficha pôs para a base subir. Cumprida, a base subiu.

##### As cinco linhas de base, e o que cada uma diz agora

| | antes | agora |
|---|---:|---:|
| pares | 179 | **285** |
| sha1 | `c87d0124…` | `530c1686…` |
| não resolvidas | 6 | **2** |
| sem menção em teste | 64 | **160** |
| falsos positivos da medida por nome | 26 | **28** |

Por módulo: ISApi **24 → 136**, ISVenda 55 → 92, ISCadastro 85 → 109,
ISConfiguracao 110 → 129, ISRelatorio 92 → 103, ISMarketing 16 → 23,
ISBase 0 → 1, ISFinanceiro 3 → 3.

##### Falsificado nas duas direções

| o que se devolve | pares | não resolvidas | saída |
|---|---:|---:|---:|
| apago a **única** chamada de um par que só um serviço alcança | 284 | 2 | **1** |
| devolvo o escopo por ARQUIVO | 276 | 21 | **1** |

Arquivos restaurados byte a byte, conferido por `sha1sum -c`. Corredor inteiro:
EXIT=0, nenhuma falha inesperada.

##### O que isto entrega ao D177, e não fecha

**106 pares passaram a ser vigiados, e 96 deles não têm teste nenhum.** O
`VendaRepository` sozinho tem 25; `ClienteRepository`, 10; `VendaProdutoRepository`,
8; `VendaPagamentoRepository`, 5. É onde o dinheiro está somado.

Isso não é defeito do D193 — o D193 era **não ver**, e agora vê. É o número que
a leitura do D177 precisa refazer, porque a de 12/09 foi feita sobre um conjunto
que não os tinha. A lista inteira sai de:

```
MOSTRAR_PARES=1 php scripts/conferir-cobertura-por-par.php
```

### D194 — Fechar a agenda perguntava os agendamentos do dia com a hora do RELÓGIO, recebia zero, e bloqueava horário com cliente marcado — CORRIGIDO em 21/09/2026 — PARCIAL, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: medir na réplica e acertar na janela as datas de nascimento já gravadas com hora; decidir no lote se a coluna vira date no PostgreSQL.
>
> **06/10/2026:** decidido pelo dono (lote de perguntas, perguntas 12 e 13): medir na réplica e, dando mais que zero, acertar no passo 3 do corte; a coluna fica como está no PostgreSQL. Falta: a medida na réplica e o passo na janela.

Achado em 21/09/2026 **ao consertar o D189**, e não procurando por ele. Para
trocar `DATE(age.data) = :data` por uma faixa eu precisava saber o que os
chamadores passam — e foi aí que a divergência apareceu.

#### O defeito

`AgendaService::fechar()` pergunta quais horários já estão ocupados assim:

```php
$agenda = $repoAgenda->selecionarPorColaboradorData(
    $colaborador->getId(),
    \DateTime::createFromFormat('d-m-Y', $data)   // <- sem parte de hora
);
```

**`createFromFormat` sem parte de hora NÃO zera a hora: ela herda o relógio.**
`createFromFormat('d-m-Y', '06-08-2026')` às 08:58 devolve
`2026-08-06 08:58:56`.

E o MySQL, ao comparar `DATE(coluna)` com um datetime, promove a data para
meia-noite. Então `DATE(age.data) = '2026-08-06 08:58:56'` **nunca casa** —
exceto se o relógio estiver exatamente em `00:00:00`.

Medido, colaborador 200, dia 2026-08-06:

| o que o chamador passa | o que volta |
|---|---|
| `'2026-08-06'` (`stringDate`, o caminho comum) | 29 linhas |
| `dateToString3(...)` | 29 linhas |
| **`createFromFormat('d-m-Y', ...)`** → `2026-08-06 08:58:56` | **0 linhas** |
| o mesmo, com hora zerada | 29 linhas |

#### A consequência, e ela é de escrita

`fechar()` usa essa lista para montar a grade e decide o que bloquear por
`status == 1` — "este horário está livre". `Util\Agenda::getAgenda()` marca
`status => 1` quando **não acha** o horário na lista. Lista vazia, tudo livre.

Então o fechamento gravava um bloqueio em **todos** os horários da janela,
inclusive nos que já tinham cliente. O código checa `status == 1` justamente
para não fazer isso; a consulta vazia derrotava a checagem.

**Medido sem escrever nada**, reproduzindo só os dois passos de leitura do
serviço, no dia 06/08/2026:

| colaborador | o serviço julga livres | deveria julgar | bloqueios sobre horário JÁ MARCADO |
|---|---:|---:|---:|
| 200 | 34 | 5 | **29** |
| 380 | 20 | 7 | **13** |
| 998 | 30 | 12 | **18** |
| 1052 | 20 | 15 | **5** |

**65 horários com cliente**, num único dia e quatro colaboradores. A tabela tem
36 unidades.

O efeito para quem usa: o cliente marcado some da grade enquanto o fechamento
existir — há duas linhas no mesmo horário, e a de bloqueio é a que a tela mostra.
Reabrir a agenda (`abrir()` faz `remove()` do fechamento, com `ON DELETE
CASCADE`) apaga os bloqueios e o agendamento reaparece. Ou seja: o dado não se
perde, mas **o barbeiro não vê que tem cliente naquele horário** — e é
justamente nessa hora que ele precisaria ligar para remarcar.

#### Por que ninguém tinha visto

O `DATE()` engolia. Uma consulta que devolve zero não levanta erro, não escreve
aviso, e — neste caminho — produz uma grade que *parece* correta: uma agenda
vazia é um estado legítimo. E o defeito é **dependente do relógio**: à
meia-noite em ponto o código funciona.

#### O conserto

A normalização passou a morar **na consulta**, e não em cada chamador:
`AgendaRepository::diaInteiro()` aceita `string` e `DateTimeInterface`, descarta
a hora, e devolve a faixa `[dia, dia+1)`.

Por que ali e não em `fechar()`: é a consulta que define o que "um dia"
significa. Espalhar essa definição por oito pontos foi o que deixou um deles
divergir sem ninguém ver — e consertar só o `fechar()` deixaria o nono ponto
livre para repetir.

#### O teste, e por que ele é determinístico

`tests/Caracterizacao/DiaDaAgendaTest.php`, sete casos. O central monta a hora
**à mão** (`14:32:07`, `23:59:59`, `00:00:00`) em vez de depender do relógio: um
caso que usasse só `createFromFormat` seria **verde uma vez por dia sobre o
defeito**. E um caso reproduz a expressão inteira que `fechar()` usa, não uma
aproximação dela.

Falsificado devolvendo `DATE(age.data) = :data` ao repositório.

#### A DEDUÇÃO VIROU MEDIÇÃO — o elo da escrita, fechado em 21/09/2026

Este registro dizia, com todas as letras, que o elo final era **dedução**: a
consulta devolve zero (medido), `getAgenda` marca tudo livre com lista vazia
(medido), o serviço decide por esse `status` (lido) — mas a linha de bloqueio
gravada por cima do cliente não tinha sido vista.

Agora foi.

**E o arquivo que exercita `fechar()` já existia e NÃO pegava o defeito.** O
motivo é de cenário, não de código: `FecharAgendaTest` fecha a janela
**10:00–11:00**, e o agendamento da fixture é às **09:00**. As duas nunca se
cruzavam — então os quatro casos passavam antes e depois do conserto. Um teste
que exercita o caminho certo com o recorte errado não é cobertura; é a aparência
dela.

O caso novo fecha **08:00–12:00**, que é a única forma de a pergunta ser feita:

```
com o conserto:   5 casos, 23 asserções, verde
com o defeito:    Failed asserting that 2 is identical to 1
```

**Dois é o defeito inteiro numa linha:** duas linhas no horário das 09:00 onde
deveria haver uma. O bloqueio caiu em cima do agendamento do cliente. E só esse
caso fica vermelho — os outros quatro seguem verdes, o que mostra que ele mede
uma coisa e não o arquivo todo.

**O controle está no mesmo caso, e é a metade que falta na maioria dos testes
assim:** ele também afirma que a janela **bloqueou alguma coisa**. Sem isso, o
caso ficaria verde se `fechar()` simplesmente não fizesse nada — e "não faz
nada" passaria por "faz a coisa certa".

Rodado no `franquiabv_cilocal`, que a suíte monta do zero; o caso base envolve
tudo em transação e desfaz. Conferido depois: o banco ficou com os 2
agendamentos da fixture e **zero** fechamentos.

#### A ARMADILHA É DE CLASSE — e medir disse onde ela NÃO chega

`createFromFormat` sem parte de hora é trap do PHP, não peculiaridade deste
método. Varrido o projeto: as chamadas com formato só-de-data moram em
`DataHora`, e a principal é **`stringToDate()`, com 28 chamadores** — quase todos
setters de entidade.

**Mas o susto não sobrevive à medição**, e a razão é o tipo da COLUNA:

| coluna | tipo | valores com hora ≠ meia-noite |
|---|---|---:|
| `usuarios.ferias_inicio` | `date` | **0** de 253 |
| `usuarios.data_nascimento` | `date` | **0** de 2.428 |
| `clientes_pacotes.validade` | `date` | **0** de 3.475 |
| `saidas_parcelas.data_vencimento` | `date` | **0** de 15.076 |

Sete das oito colunas conferidas são `date`: o banco trunca na escrita, e a hora
do relógio morre ali. **O mapeamento diz `type="datetime"` mesmo assim** — a
segurança é acidente do esquema, não escolha. Trocar qualquer uma dessas colunas
para `datetime` na Fase 1 acorda a armadilha de uma vez.

**A exceção é `clientes.data_nascimento`**, que é `datetime` na coluna E no
mapeamento. Ali o Doctrine grava `format('Y-m-d H:i:s')`, então a hora do relógio
**chega ao banco** — por mecanismo determinístico, lido no código.

**E aqui a cópia anonimizada NÃO serve de prova.** Ela tem 242.592 datas de
nascimento e **zero** com hora — mas data de nascimento é dado pessoal, e a cópia
quase certamente as regenerou. Medir isso ali seria medir o anonimizador, não o
sistema. Fica dito que o mecanismo existe, que o dano é baixo (as consultas de
aniversário usam `MONTH()`/`DAY()`, que a hora não afeta), e que **a pergunta só
se responde na produção**.

Nada foi mudado em `DataHora`: mexer numa função com 28 chamadores por um dano
que a medição não achou seria trocar risco conhecido por risco desconhecido.

#### O NASCIMENTO SEM HORA — CORRIGIDO em 05/10/2026

O registro deixou o mecanismo dito e o conserto adiado. **O conserto:**
`Cliente::setDataNascimento()` passou a `DataHora::stringToDia()` --
`createFromFormat("!d/m/Y")`, em que o `!` zera a hora -- e o irmão também:
`UnidadeSocio::setDataNascimento()`, que grava `unidades_socios.data_nascimento`,
a outra coluna `datetime` com o mesmo setter. `stringToDate()` ficou como estava.

**A lista saiu do código, e não da memória:** os 17 pontos das entidades que chamam
`stringToDate()`, contra o tipo da coluna no `mysql.sql` e no
`postgres/schema.sql`. 14 são `date` nos dois bancos -- o gerador
(`scripts/gerar-ddl-postgres.php`) continua mapeando `date` para `date` e
`datetime` para `timestamptz` --; 2 são estas; e 1 é o ramo sem hora de
`Notificacao::setData()`, que cairia em `datetime` mas não tem chamador (a
campanha é gravada com a hora).

**O efeito da hora não era só de enfeite:** as consultas de aniversário usam
`MONTH()`/`DAY()` e não a sentiam, mas a idade do filtro por faixa etária
(`TIMESTAMPDIFF(YEAR, cli.dataNascimento, :agora)`) sentia -- no dia do
aniversário, a pessoa só fazia anos depois da hora em que o cadastro foi salvo.

**Provado** (`NascimentoSemHoraTest`, 5 casos, nos dois bancos): o dia volta à
meia-noite, com o vazio nulo e o formato que não serve falso, como em
`stringToDate()`; o cliente e o sócio guardam o dia sem hora; o cadastro pela regra
da API e pelo serviço de verdade grava `1990-03-15 00:00:00` na coluna, lida crua;
e no primeiro segundo do aniversário -- `2026-03-15 00:00:01`, escrito, e não o
relógio -- o `TIMESTAMPDIFF` dá 36. Três mutantes, vermelhos: o setter do cliente
de volta a `stringToDate()` (3 casos, nos dois bancos), o do sócio (1) e o
`stringToDia()` sem o `!` (os 5). O relógio não é injetável: o mutante só passaria
num cadastro salvo exatamente às 00:00:00.

**O que fica:** as linhas que já têm hora continuam com ela -- acertá-las é escrita
em dado de produção, e a cópia não serve de medida (o anonimizador regera as
datas, acima). Foi ao lote.

**A revisão (05/10/2026): o dia sem meia-noite.** O `!` zerava a hora no fuso do
processo, America/Sao_Paulo -- e nesse fuso a meia-noite NÃO EXISTE nos dias em que
o horário de verão começava: o relógio pulava de 00:00 para 01:00. Contados no
contêiner, de 1900 a 2030: 45 dias, de 1914 a 2018, 34 deles de 1985 em diante
(21/10/1990, 15/10/1989, 04/11/2018...). Neles, `stringToDia()` dava 01:00 -- e o
docblock e este registro diziam "a meia-noite"; o `TIMESTAMPDIFF` virava a idade só
à 01:00 do aniversário, e a consulta que o lote propõe (`TIME(...) <> '00:00:00'`)
contaria essas linhas como "com hora" depois do acerto. **O conserto:**
`stringToDia()` cria o dia em UTC. O Doctrine grava o relógio do objeto sem
converter fuso (`DateTimeType::convertToDatabaseValue` é um `format`), e em UTC a
meia-noite existe todo dia: o MySQL recebe `00:00:00` sempre. O fuso não aparece a
ninguém -- os dois getters devolvem o texto `d/m/Y`, o mesmo dia.

**O PostgreSQL não ganha isso, e o caso o escreve.** Lá a coluna é `timestamptz` e o
banco está no fuso de São Paulo: o `00:00:00` desses dias é lido como 01:00-02 -- o
mesmo instante e o mesmo dia --, e a idade só vira à 01:00. Só uma coluna `date` o
tiraria, e é mudança de esquema; foi ao lote, como opção, com a recomendação de não
fazer agora.

**Provado** (`NascimentoSemHoraTest`, mais 2 casos, nos dois bancos): 21/10/1990,
04/11/2018 e 15/10/1989 à meia-noite no `stringToDia()`, no cliente e no sócio, com
o getter devolvendo o mesmo dia; e o cadastro de 21/10/1990 pela regra da API, na
coluna lida crua -- `00:00:00` e 36 anos no primeiro segundo do aniversário no
MySQL, `01:00:00` e 35 no PostgreSQL (o limite). O mutante -- `stringToDia()` de
volta ao fuso do processo -- deixa vermelhos os dois casos no MySQL e o primeiro no
PostgreSQL, onde a coluna já era 01:00 de qualquer jeito.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 12): a opção (a), "Medir na réplica e,
dando mais que zero, acertar na janela". Entrou no roteiro do corte:
`scripts/medir-nascimento-com-hora.sql` (só contagens) é o 7 de "O que hoje bloqueia", e os dois
`UPDATE` do fim dele -- `clientes` e `unidades_socios`, o dia sem a hora -- entram no passo 3, depois da
limpeza. Ensaiado em `scripts/ensaiar-sql-do-passo-3.php` (desde a revisão de 06/10/2026, no
repositório), no 8.4.11 e no 8.0.46, com datas inventadas: 2 e 1 linhas na primeira vez, nada na
segunda, a `data_alteracao` intacta; com uma data zero com hora em
`unidades_socios`, que a limpeza não alcança, o modo estrito recusa o UPDATE inteiro, e a coluna
`anteriores_a_1000` da medida a acusa antes. No `franquiabv_dev` a medida dá 0 com hora em 242.592
nascimentos -- o anonimizador, como acima.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 13): a opção (a), "Deixar como está"
-- a coluna continua `timestamptz` no PostgreSQL, e nos 45 dias sem meia-noite em São Paulo o dia
aparece com 01:00. Nada muda no código; a troca para `date` cabe numa janela em que o esquema já vá
mudar. O que resta deste débito: a medida na réplica e o acerto na janela.

### D195 — A agenda era governada por DOIS privilégios diferentes, e a fachada nova escolheu o que nunca governou nada — CORRIGIDO em 21/09/2026

Achado em 21/09/2026, ao fechar o D192: ao ligar a primeira ação de escrita da
agenda, a guarda do botão e a guarda do servidor discordavam.

#### O que acontece

A mesma tela é governada por dois privilégios, e quem os concede não tem como
saber disso:

| o que se faz | privilégio | onde está declarado |
|---|---|---|
| ver telefone e nome inteiro | `agendas/gerenciar/escrever` | `Adm\AgendasController::gradeAction` |
| criar, check-in, remover, remarcar | `agendas/visualizar/escrever` | `Adm\AgendamentosController`, `funcionalidade = 'visualizar'` |

Não é desenho: é consequência de as ações terem ido para **outro controller**,
que declara outra funcionalidade. Ninguém decidiu que "ver o telefone" e "marcar
um horário" seriam permissões diferentes.

#### Quanto isso alcança

Medido sobre `privilegios_usuarios` — a camada operativa que o `Acl` lê no login
(15.503 linhas), e **não** a tabela `privilegios`, que é só o molde do grupo
(139 linhas, D157) — entre os **371 usuários ativos**:

| situação | usuários |
|---|---:|
| têm `visualizar/escrever` e não `gerenciar/escrever` | **164** |
| têm `gerenciar/escrever` e não `visualizar/escrever` | **94** |
| têm os dois | 87 |
| não têm nenhum | 26 |

**258 de 371 — 70% — têm um e não o outro.**

E a primeira medição que fiz disto estava errada, o que vale registrar: contei
sobre a tabela `privilegios` e achei três grupos divergentes. Aquela tabela é o
**molde**, e não o que decide. A sessão do `user200` desmentiu na hora — ela
trazia privilégios que o molde do grupo dele não concede.

#### O que isso significa para quem usa

- **164 pessoas** podem marcar, remarcar e registrar chegada, e **não veem o
  telefone do cliente** que vão atender.
- **94 pessoas** veem telefone e nome inteiro e **não conseguem marcar nada** —
  a API recusa com 403.

Nenhuma das duas é obviamente errada; o que é errado é **ninguém ter decidido**.
A tela de permissões mostra "Agendas visualizar" e "Agendas gerenciar" como se
fossem "ler" e "escrever", e não são: `visualizar/escrever` é o que autoriza
escrever.

#### O que foi feito, e o que NÃO foi

**Feito:** o recurso da grade passou a publicar os **dois** bits, com nomes que
dizem o que cada um governa, para que a tela guarde a ação pelo privilégio que a
ação exige. Ver D192.

**Não feito:** unificar os dois. Isso é decisão de produto, não de código —
qualquer escolha TIRA acesso de um dos dois grupos:

- exigir os dois para agir tiraria a ação de **164** pessoas;
- aceitar qualquer um dos dois daria o recorte de telefone a **164** que hoje
  não o têm;
- mover as ações para `gerenciar` tiraria a ação das mesmas 164;
- mover o recorte para `visualizar` daria telefone a 164 e tiraria de 94.

E a tabela `privilegios_usuarios` tem **347 linhas que divergem do molde do
grupo** (D157): parte dessas 258 pessoas pode ser decisão consciente de alguém, e
não acidente. Passar a régua por cima apagaria essas decisões.

**Precisa de você:** qual das quatro, ou uma quinta. O sistema funciona hoje; o
que não existe é alguém tendo escolhido.

#### CORRIGIDO em 21/09/2026 — e a correção foi no lugar que o registro acima não olhou

Este débito foi escrito como **decisão de produto**, com quatro opções e a frase
*"precisa de você"*. Medir mostrou que a premissa estava errada: **não são duas
escolhas legítimas.** Uma das duas é o que o ERP faz há anos; a outra nasceu por
acidente de lugar, e o bit que ela pede **nunca governou nada de verdade**.

##### O que a medição achou, e o registro acima não dizia

A tabela original dizia que `agendas/gerenciar/escrever` governa "ver telefone e
nome inteiro". Governa **três** coisas, e a terceira é a que decide:

| o que `gerenciar/escrever` governa | onde |
|---|---|
| **o escopo** — a grade inteira, ou só a sua coluna | `AgendasController.php:892` e `Adm\AgendasController::gradeAction` |
| **a privacidade** — telefone e nome inteiro | `GradeDaAgendaJson::umHorario` |
| **o AGIR** — as 13 ações de escrita do ERP | `podeGerenciarAgenda()`, via `getAcesso2('gerenciar')` |

E `agendas/visualizar/escrever` governava, no ERP inteiro, **uma única ação**:
`bloqueiosRemoverAction` — que estava com a **guarda invertida** desde a
importação inicial (D196). Ou seja: o bit que a API nova escolheu para autorizar
escrita **nunca autorizou nada corretamente** na agenda.

##### A quinta opção, que não estava na lista — e não tira nada de ninguém

A lista de quatro opções acima tinha um erro de análise, e ele é meu: a opção
*"mover as ações para `gerenciar`"* estava escrita como *"tiraria a ação de 164
pessoas"*. **Não tira.** Essas 164 pessoas **não conseguem agir na agenda hoje**
— o ERP as recusa. O único lugar onde elas podiam agir era a API nova, que
**não tem consumidor em produção**.

Então a escolha que parecia um impasse — 164 contra 94 — não era impasse:

| opção | quem perde algo que USA hoje |
|---|---|
| exigir os dois para agir | 94 |
| aceitar qualquer um dos dois | ninguém perde, mas 164 ganham o telefone |
| **alinhar a API ao ERP (`gerenciar`)** | **ninguém** |
| mover o recorte para `visualizar` | 94 perdem o telefone |

A terceira é a que entrou. Ela não é decisão de produto: é a Fase 2 **cumprindo
o que promete ser** — uma fachada. *A regra de negócio não muda de lugar.*

##### Os números do registro acima estavam errados, e o erro é meu

A medição original contou sobre `privilegios_usuarios` e **ignorou duas coisas**
que a revisão adversarial achou:

1. **Administradores passam por fora da tabela.** `Acl.php:29-30` faz
   `allow($grupo, [], [])` para todo grupo com `administrador = 1` — são **10
   pessoas**, que passam em qualquer conferência sem ter uma linha de privilégio.
2. **Cinco dos "371 ativos" são usuários de fixture meus** (`90001000`,
   `90001001`, `90001003`, `90001100`, `90001150`). Contar o cenário de teste
   como gente da barbearia é o medidor sujando a medida.

Refeito sobre os **366 usuários ativos reais**:

| | antes (errado) | medido |
|---|---:|---:|
| ativos | 371 | **366** |
| administradores | não contados | **10** |
| só `visualizar/escrever` | 164 | **164** |
| só `gerenciar/escrever` | 94 | **94** |
| os dois | 87 | **96** |
| nenhum | 26 | **12** |
| podem AGIR no ERP hoje | — | **190** |
| podiam AGIR na API nova | — | **260** |

**O racha em si — 164 e 94 — estava certo.** O que estava errado era o
denominador e os outros dois baldes: os 10 administradores estavam no balde
"nenhum" e são, na prática, "ambos".

E a conta só fechou em 366 depois de achar **onze pessoas sem uma única linha**
em `privilegios_usuarios`: dez são "Gerente geral" administradoras, e a décima
primeira é o **usuário 1377**, Colaborador, `administrador = 0`, com **6.005
acessos**. `MAX()` sobre zero linhas devolve `NULL`, e ele sumia da conta em
silêncio. Ele não pode nada na agenda — **exceto** passar pela guarda invertida
do D196.

##### O que entrou

As **6 ações de escrita** da agenda sob `/adm/api` — criar, check-in, remover,
remarcar, fechar e reabrir — passaram a exigir `agendas/gerenciar/escrever`,
pelo helper `podeGerenciarAgenda()`, que é **a mesma expressão** da tela legada.
As LEITURAS não mudaram: continuam `agendas/visualizar/ler`.

A grade continua publicando **dois nomes** — `podeGerenciar` e
`podeEscreverAgendamento` — e hoje eles carregam o mesmo bit. O nome não foi
removido de propósito: ele responde outra pergunta, e foi ler o campo errado que
custou o D192. **A redundância é dívida e está anotada aqui**: dois campos com o
mesmo valor podem divergir por engano.

##### Falsificado — e o instrumento é novo, porque nenhum existia

Nenhum instrumento desta casa distinguia os dois bits, e a razão é estrutural: o
**único login de ERP da suíte de contrato tem OS DOIS**, então qualquer uma das
quatro opções passaria. *Verde por indistinção não é verde.*

`scripts/conferir-guarda-da-agenda.php`, no corredor e na CI, confere **19 ações
de escrita** nas duas pilhas e sai com 1 quando:

| o que se devolve | o portão |
|---|---|
| uma ação da API volta a `getAcesso('escrever')` | **2 problemas** |
| a guarda volta com o sinal trocado | **2 problemas** |
| alguém acrescenta uma ação e não a declara | **para**, e diz o total novo |

A terceira não é hipótese: ela me pegou chutando 22 ações onde havia 20.

##### A pergunta que sobrava foi respondida em 21/09/2026 — e É REGRA

O dono decidiu, com estas palavras:

> *"essas 164 pessoas é regra, podem ver mas não escrever na agenda, apenas
> consultar. Apenas nome e serviço, sem o telefone do cliente."*

**O comportamento de hoje está certo e é para ficar.** Quem tem
`agendas/visualizar/ler` e não tem `agendas/gerenciar/escrever` consulta a agenda
e não age nela.

E a decisão bate, palavra por palavra, com o que a tela legada faz desde sempre
— o que confirma que a regra é antiga e foi só nunca escrita:

| | `tabela.phtml:43` — quem gerencia | `tabela.phtml:62` — quem só consulta |
|---|---|---|
| Cliente | nome inteiro | primeiro nome, se a unidade mandar |
| **Telefone** | **mostra** | **não mostra** |
| Serviço | mostra | mostra |
| Convênio | mostra | mostra |
| Observação | mostra | mostra |

**O D195 está fechado nas duas metades:** a divergência entre as pilhas era
acidente e foi consertada; o recorte que sobra é decisão, foi tomada, e é a que
já estava no código.

**E responder isso abriu o D197:** a regra vale na grade e na tela legada, e
`GET /adm/api/agendamentos` escapava dela. Ver ali.

> **Correção de registro (07/10/2026, o item CODIGO-MORTO-E-DOCS):** o conserto de 21/09
> trocou o bit no controller (`Adm\AgendasController::gradeAction` passa o de `gerenciar`
> nos dois nomes) e deixou o recurso falando do bit de antes: o `@param` de
> `GradeDaAgendaJson::montar` dava `podeEscreverAgendamento` como `agendas/visualizar/escrever`,
> e o comentário do campo dizia que as ações "exigem OUTRO privilégio". Os dois passaram a
> dizer o que vale desde 21/09 -- `agendas/gerenciar/escrever`, o mesmo bit nos dois
> nomes, e por que o nome fica --, com os números de 21/09 como história. Só comentário:
> o recurso devolve o mesmo, e a redundância anotada acima continua a mesma dívida.
> Na verificação do mesmo dia, a referência do comentário novo foi acertada: ele dava "o
> que o ERP pede há anos" como `AgendamentosController::podeGerenciarAgenda`, e esse é o
> da API nova (`Adm\AgendamentosController`, de 21/09), que copia a regra. A regra antiga
> é a de `ISCadastro\Controller\AgendasController::podeGerenciarAgenda()`
> (`getAcesso2('gerenciar')`), e o comentário agora nomeia as duas.

---

### D196 — A ação que APAGA bloqueio de agenda recusava quem podia e deixava passar quem não podia — CORRIGIDO em 21/09/2026

Achado em 21/09/2026, atacando o D195. Não estava sendo procurado.

#### O defeito

`ISCadastro\Controller\AgendasController::bloqueiosRemoverAction`, presente
assim **desde a importação inicial do ERP**:

```php
public function bloqueiosRemoverAction() {
    if ($this->getAcesso()) {              // <- SEM o "!"
        return $this->notFoundAction()->setTerminal(true);
    }
    ...
    $this->service->abrir($fechamento);    // remove() + flush()
```

O sinal está trocado. Quem **tem** `agendas/visualizar/escrever` leva 404; quem
**não tem** segue adiante e apaga. Era a **única** guarda das 22 deste arquivo
com essa forma — todas as outras são `if (!...) { recusa }` ou
`if (...) { prossegue }`.

E `AgendaService::abrir()` é `$em->remove($fechamento); $em->flush();`.

#### Medido

| | |
|---|---:|
| fechamentos apagáveis | **134.334** |
| unidades que eles cobrem | **48** |
| clicavam Remover e levavam 404 | **260** de 366 |
| passavam pela guarda e apagavam | **106** |

Os 260 recusados **incluem os 10 administradores**. Entre os 106 que passavam
está o **usuário 1377**, que não tem uma única linha em `privilegios_usuarios` e
tem **6.005 acessos** registrados.

E o botão é visível para todos: `bloqueios.phtml:85` desenha **Remover** em cada
linha da lista, **sem nenhuma conferência de privilégio** — a tela de listagem
pede só `agendas/visualizar/ler`. Quem gerencia a agenda clicava e recebia uma
página 404.

#### E havia mais dois, que são os mesmos do D190

A ação é a **gêmea** da `abrirAction`: mesma entidade, mesma operação. O D190
consertou três defeitos na gêmea em 20/09 e escreveu o comentário; esta ficou de
fora, com dois deles ainda de pé:

1. **`getReference()` devolve proxy preguiçoso**, então `empty($fechamento)` era
   sempre falso e aquele ramo era **código morto**.
2. **Nenhuma conferência de franquia.** O helper `fechamentoDaMinhaUnidade()`
   existe — foi escrito no D190 — e **não era chamado aqui**. O id vinha cru da
   rota: dava para apagar o fechamento de qualquer uma das 48 unidades sabendo
   só o número.

#### Por que nenhum instrumento pegou

- **O D150 contou esta ação como JÁ GUARDADA.** O registro dele diz, com todas
  as letras: *"Só `indexAction` (`ler`) e `bloqueiosRemoverAction` (`escrever`)
  conferiam privilégio"*. Ele olhou se **havia** guarda, não se ela guardava.
  *Motivo na dispensa não é guarda* — e "tem uma guarda" também não é.
- **O `conferir-recorte-por-unidade.php` não a vigiava.** A lista é escrita à
  mão, e nem esta ação nem a gêmea `abrirAction` estavam nela — o helper
  `fechamentoDaMinhaUnidade` era exigido por **zero** das 41 entradas.
- **Nenhum portão olha polaridade.** O `conferir-acl-como-booleano.php` pega
  `getAcl()` com menos de três argumentos; sinal trocado passa por ele.

#### O que entrou

A guarda passou a ser `podeGerenciarAgenda()` — a **mesma expressão** da gêmea e
das outras doze ações que agem. Não é `getAcesso()`: remover bloqueio reabre
horário, que é agir sobre a agenda. Mais `find()` no lugar de `getReference()` e
a conferência `fechamentoDaMinhaUnidade()`.

Varri o código inteiro atrás de outras guardas com o sinal trocado: **não há
nenhuma**.

#### Falsificado

| o que se devolve | portão | saída |
|---|---|---:|
| tiro a conferência de franquia | `conferir-recorte-por-unidade` | **1** |
| tiro a conferência de franquia | idem, consertado | 0 |
| devolvo o sinal trocado | `conferir-guarda-da-agenda` | **1** |
| troco o privilégio numa ação da API | idem | **1** |

Arquivos restaurados byte a byte, conferido por `sha1sum -c`.

#### Fica de pé, anotado

**A ação continua sendo GET.** Um link que apaga é fraqueza por si só — basta
alguém abrir a URL. Trocar para POST pede mexer no `bloqueios.phtml`, que chama
por `modal_remover(url)`. Não foi feito hoje para não misturar conserto de
guarda com mudança de tela.

#### E DEIXOU DE SER GET em 22/09/2026 — a dívida que ficou anotada acima, paga

Decisão do dono: *"ataque a remoção de bloqueio por GET"*.

**Das treze ações deste controller guardadas por `podeGerenciarAgenda()`, doze
já exigiam POST.** A `bloqueiosRemover` era a única que apagava por GET — a mesma
posição de exceção que ela ocupava na guarda invertida.

##### O que entrou

- **A ação:** `if (!$this->getRequest()->isPost() || !$this->podeGerenciarAgenda())`,
  o mesmo formato das outras doze, antes de tocar no banco.
- **A tela:** o botão da linha chama `bloqueioRemover(url)`, que põe a URL como
  `action` de um `<form method="post">`; o "Sim" do modal é o botão de envio dele.
- **O script mora NA TELA**, e o comentário diz por quê: o `modal_remover` global
  remove por GET e é usado por outras 22 telas, cujas ações ainda só aceitam GET;
  e o layout serve `geral.min.js`, que não tem passo de minificação versionado.

##### Verificado sem apagar nada

| o que | resultado |
|---|---|
| `GET …/bloqueiosRemover/3915` como `user1096`, que **pode** remover, bloqueio **da unidade dele** | **404**, e a linha continua **idêntica ao backup, byte a byte** |
| a tela servida | botão chama `bloqueioRemover`, formulário `method="post"` |
| clique real no `×` do 3915, no navegador | modal abre; `action` = `/mod-cadastros/agendas/bloqueiosRemover/3915`; o "Sim" é o envio desse formulário |

O "Sim" **não foi apertado**: seria uma escrita na cópia de produção. O caminho
POST do controller não mudou — a condição nova é falsa para POST e cai na guarda
que já estava verificada desde 21/09.

##### Os portões

- **`conferir-guarda-da-agenda.php`** ganhou a regra `exige_post` para o ERP:
  ação de escrita que não confere `isPost()` fica vermelha. Falsificado: com o GET
  de volta, **exit 1** apontando a ação.
- **`conferir-scripts-servidos.php`** acusou `bloqueioRemover` como botão morto —
  ele só olhava os `.js` servidos. Aprendeu que o `<script>` embutido **na própria
  tela** também chega ao navegador, **só para aquela tela**. Falsificado nas duas
  direções: tirando a função da tela, acusa `bloqueios.phtml`; chamando-a de outra
  tela, acusa **só** a outra.
- **`conferir-bytes-de-controle.php` pegou um erro meu no caminho**: o heredoc
  comeu a contrabarra de `\b` e gravou um `0x08` dentro da expressão regular. Ela
  seria PHP válido e deixaria de casar em silêncio. Corrigido, e as falsificações
  refeitas com a expressão certa.

##### O que isto NÃO é

**Não é proteção completa contra CSRF.** POST fecha os vetores de link — pré-carga,
`<img src>`, histórico, navegação vinda de outro site —, mas o cookie de sessão sai
sem `SameSite`, e a remoção não confere token de formulário — os cadastros
conferem, as remoções não. Ver **D198**.

---

### D197 — O recorte da agenda era contornável por outro endereço da mesma API — CORRIGIDO em 21/09/2026

Achado em 21/09/2026, logo depois de o dono confirmar que o recorte **é regra**.
Regra confirmada é regra que passa a valer em todo lugar — e foi aí que apareceu
o lugar onde ela não valia.

#### O que acontecia

A grade recorta. `Adm\AgendasController::gradeAction` chama o serviço com
`$podeGerenciar ? null : $this->usuarioId`, e a `atualizarAction` da tela legada
faz o mesmo desde sempre (`AgendasController.php:892`): **quem não gerencia vê só
a própria coluna.**

`GET /adm/api/agendamentos` **não recortava nada**. `indexAction` pedia
`selecionarPorUnidadeData($this->unidadeId, $data, ...)`, e a consulta não tinha
filtro por colaborador. Quem a grade mostra recortado recebia, pelo endereço
irmão, **o dia da unidade inteira**.

E o nome saía **inteiro**: `AgendamentoJson::resumo()` publicava
`cliente.nome` sem o corte que a grade e o `tabela.phtml:62` aplicam.

#### Medido

| | |
|---|---:|
| leem a agenda | **364** de 366 |
| leem e **não** gerenciam — a regra vale para elas | **174** |
| unidades que cortam o nome no primeiro | **5** de 64 |

#### O que NÃO estava errado, e vale escrever

**O telefone nunca saiu por aqui.** `AgendamentoJson::resumo()` publica id, data,
hora, início, estado, bloqueio, origem, cliente, colaborador, serviço, check-in,
check-out, ativo e observação — e **não** telefone. A metade da regra que o dono
mais destacou já estava cumprida. Registrar isso importa: metade certa por
acidente continua sendo metade que ninguém prendeu, e agora há caso de teste.

E `observacao` sair está **certo**: o `tabela.phtml:62`, o ramo de quem não
gerencia, também mostra Observação.

#### O que entrou

- `AgendaRepository::selecionarPorUnidadeData` ganhou `$colaboradorId = null` —
  `null` é a unidade inteira, um id é só a coluna dele.
- `indexAction` passa `$podeGerenciar ? null : $this->usuarioId`, **a mesma
  expressão** da grade e da tela legada.
- `AgendamentoJson::resumo()` ganhou `$cortarNome = false`, e o corte é feito por
  `IdealizeUtil::primeiroNome()` — a mesma função do `tabela.phtml:62`, e não um
  `explode` novo que divergiria no primeiro nome composto.

O padrão `false` é deliberado: dos quatro chamadores do `resumo()`, **três
respondem a escritas**, que exigem `agendas/gerenciar/escrever`. Quem chega ali
já tem direito ao nome inteiro. Só a listagem corta.

#### Falsificado — e o primeiro caso que escrevi era VAZIO

Devolvendo os dois defeitos, **2 casos ficam vermelhos**; sem eles, 674 testes
passam e a suíte sai com 0.

Mas a primeira versão do caso do escopo **passava com e sem o conserto**, e vale
registrar por quê: ela pedia o recorte pelo colaborador **do próprio
agendamento**, e o dia do cenário tem **um colaborador só** com agenda. Recortado
e inteiro davam o mesmo conjunto.

É o **D194 em outra roupa**: exercitar o caminho certo com o recorte errado não é
cobertura, é a aparência dela. Agora o caso pergunta as duas direções, e a
segunda é a que morde — o outro usuário da mesma unidade **não tem agenda no
dia**, então sem o recorte a consulta devolve o dia inteiro para ele.

| o que se devolve | vermelhos |
|---|---:|
| tiro o recorte por colaborador | 1 |
| tiro o corte do nome | 1 |
| tiro os dois | **2** |

Arquivos restaurados byte a byte, conferido por `sha1sum -c`.

---

### D198 — A configuração de sessão é declarada e nunca aplicada: o cookie sai sem `SameSite` e sem `HttpOnly` — CORRIGIDO em 01/10/2026 — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: em produção, ler o `php.ini` e ligar `cookie_secure` e `duracao` pelo `local.php`; a sessão no banco segue desligada (F1.5).

Achado em 22/09/2026, conferindo a premissa de segurança da troca GET → POST do
D196. A troca só protege de verdade se o navegador **não** mandar o cookie de
sessão em POST vindo de outro site — e isso depende do `SameSite`.

#### O que o código promete

`config/autoload/sessao.global.php`:

```php
'session_config' => [
    'cookie_httponly' => true,
    'cookie_samesite' => 'Lax',
    ...
```

#### O que o servidor entrega

```
GET /autenticacao/entrar
Set-Cookie: PHPSESSID=…; path=/
```

**Sem `SameSite` e sem `HttpOnly`.** E o PHP do contêiner:

| diretiva | valor |
|---|---|
| `session.cookie_httponly` | `0` |
| `session.cookie_samesite` | vazio |
| `session.cookie_secure` | `0` |
| `session.use_strict_mode` | `0` |

#### Por que a configuração é letra morta

O módulo `Laminas\Session` está registrado, e as chaves `session_config` e
`session_manager` só são lidas pela fábrica do `SessionManager` — que **ninguém
pede ao contêiner**. Não há `SessionManager`, `setDefaultManager` nem
`session_start` em `module/` ou `config/`. A sessão nasce quando o primeiro
`Container` é usado, e nasce com o **padrão do PHP**.

#### O que isso custa

- **Sem `HttpOnly`**, qualquer script na página lê o `PHPSESSID`. Um XSS em
  qualquer tela vira roubo de sessão.
- **Sem `SameSite`**, a proteção contra requisição forjada depende do padrão de
  cada navegador. O Chrome trata cookie sem o atributo como `Lax`; outros não,
  necessariamente. E as **remoções** não conferem token de formulário.

> **CORRIGIDO no mesmo dia, e o erro é meu.** A primeira versão desta ficha
> dizia que *"este sistema não tem token de formulário em lugar nenhum"*. Está
> errado: **50 arquivos** instanciam o elemento `Csrf` do Laminas — os
> formulários de cadastro e edição, inclusive o de estoque. Quem achou foi a
> leitura adversarial do D199. A minha busca terminava em `| head -5`: vi cinco
> nomes, concluí pelo todo. O certo é que os **formulários** têm token e as
> **remoções** — POST direto para a ação, sem formulário do Laminas — não.

#### Provável, e NÃO medido

O mesmo mecanismo alcança o `save_handler`: o `driver => 'banco'` da **F1.5**
(sessão no banco, preparada para a migração) mora na mesma chave
`session_manager`. Se o `SessionManager` nunca é construído, ligar o banco no
`local.php` deixaria as sessões **em arquivo, em silêncio**. Não liguei para
medir: mexer no `local.php` é a disputa que o D190 registra.

#### Produção não foi olhada

O `php.ini` de produção pode ter as diretivas ligadas por fora do código. Isso
não foi lido — decisão de 11/09/2026: nada se faz no servidor antes da janela.
O que está medido é que **o código não garante** o que diz garantir.

#### Por que não consertei junto

Aplicar a configuração muda o cookie de **toda** requisição. `SameSite=Lax` pode
quebrar um fluxo que dependa do cookie numa volta vinda de outro site — um
retorno de pagamento, por exemplo — e isso precisa ser procurado antes, não
descoberto depois. É conserto de uma linha com uma pergunta na frente.

#### A porta mais sensível a um pedido forjado — anotado em 29/09/2026

A revisão da tela nova de usuários mediu: a janela de privilégios do ERP
(`permissoesSalvar` e `permissoesRemover`) não confere o token do formulário nem a
origem, e com a sessão de um **administrador** a regra dos privilégios (D288) deixa
passar tudo — um site aberto por ele numa aba ao lado faria o navegador conceder uma
funcionalidade de rede a quem quisesse. `/adm/api` ganhou a barreira de origem (D290);
o ERP depende deste débito.

---

#### CORRIGIDO em 01/10/2026

**A pergunta, respondida antes de mudar.** Nenhum fluxo depende do cookie numa volta vinda de outro
site. O Asaas abre numa aba nova, sem URL de retorno. Não há rota de retorno de pagamento: o webhook
que existe é do WhatsApp, servidor a servidor. Os `<iframe>` do código são de vídeo -- o ERP embute,
não é embutido --, e não há CORS com credencial. As telas públicas com sessão (NPS, pesquisa,
fidelidade) abrem por link -- GET de primeiro nível, que o `Lax` deixa passar -- e postam para a mesma
origem. Nenhum script lê `PHPSESSID`, `token_web` nem `login` (procurado em `public/` e em
`frontend/src`).

**A sessão.** O `onBootstrap` do `ISConfiguracao\Module` pede o `SessionManager`, e a fábrica do
Laminas aplica a `session_config` e o põe como padrão de todo `Container`. A "linha" escondia três
coisas: sem a chave `session_storage`, a fábrica estourava (entrou `SessionArrayStorage`, o padrão de
antes); com a saída já enviada, o Laminas transforma em exceção a recusa do PHP, e o roteiro de linha
de comando que imprime antes de montar a aplicação morria -- o bootstrap pula a configuração nesse
caso; e o `use_strict_mode` passou a valer. `cookie_secure` e `duracao` só valem pelo `local.php`: o
ambiente local é http, e o `php.ini` de produção não foi lido.

**Os cookies do login e o campo oculto (a segunda rodada, no mesmo dia).** O `token_web`, que entra
sem senha, e o `login` saíam só com o caminho, e o mesmo token estava num campo oculto de toda página:
um XSS ainda roubava a sessão, só que pelo token. Agora os dois cookies saem com `HttpOnly` e
`SameSite=Lax`, e com o `Secure` da sessão (`SessaoAcl::opcoesDosCookiesDoLogin`); o campo leva só
"1" -- o JavaScript só o mandava ao `validarHash`, que ignora o corpo desde o D223.

**O `driver => 'banco'` da F1.5, medido.** A fábrica não lê `session_manager.save_handler`: pede o
serviço `SaveHandlerInterface`, que ninguém registra. Com o driver declarado, a sessão fica em
arquivo, antes e depois deste conserto -- nada liga em silêncio, e nada desliga. O topo de
`sessao.global.php` mandava declarar o driver; agora diz o que falta para ligar (e que ligar desloga
todo mundo uma vez).

**Medido e falsificado.** Por HTTP, `PHPSESSID`, `token_web` e `login` saem com `HttpOnly;
SameSite=Lax`; o id desconhecido volta trocado; a página logada não traz o token. O ensaio da sessão dá
15 de 15 no MySQL e no PostgreSQL, e `D198ConfiguracaoDaSessaoTest` tem 6 casos. Sem a chamada no
bootstrap, os casos 7 e 8 ficam vermelhos; sem o modo estrito, o 8; sem a guarda da saída, o roteiro
de linha de comando morre; com o `setcookie` de antes e o campo com o token, os casos 13 e 14. A
primeira versão do teste ficou verde sem o bootstrap: ela própria pedia o gerenciador.

**O que fica.** O `php.ini` de produção não foi lido. As remoções por GET (D199) seguem fora do
alcance do `Lax`. ~~A janela de privilégios do ERP continua sem token de formulário nem conferência de
origem: o `Lax` só a cobre nos navegadores que o aplicam.~~ (a conferência de origem entrou em
03/10/2026, abaixo) O `token_web` não expira (D77). A sessão no banco continua desligada.

#### A janela de privilégios só da mesma origem — CORRIGIDO em 03/10/2026

O item SESSAO-SENHA-EMAIL. `permissoesSalvarAction` e `permissoesRemoverAction` começam pela barreira
de origem da `/adm/api` (`OrigemDoPedido::recusaDoPedido`, o D290): o POST de outro site, de outro
subdomínio do mesmo site (que o `SameSite=Lax` não separa, e leva o cookie) e o que só traz o `Origin`
de outro host recebem 403, com `{sucesso: false, mensagem}`, antes de ler o pedido. ~~-- o JSON que a
janela já lê --~~ (revisão, 04/10/2026: a janela NÃO lê este corpo -- o `$.ajax` de
`mod-configuracoes.js` só lê `mensagem` no `success`, que o jQuery chama para 2xx, e o 403 cai no
`error`, que mostra "Erro ao salvar." ou "Erro ao remover." sem ler nada; como a janela é da mesma
origem e nunca recebe a recusa, nada muda para quem usa, e a frase serve a quem lê a resposta crua, o
teste e o ensaio. O docblock de `recusaDeOrigem` foi corrigido.) A janela é da mesma origem, e o cliente que não é navegador (sem os dois cabeçalhos)
passa, como lá. Continua sem token de formulário: a escolha entre a barreira e o token nos botões do
ERP já está no lote do dono (o D315), com a recomendação da barreira.

**Provado:** `PrivilegiosPelosControllersTest`, quatro execuções novas verdes nos dois bancos -- as três
origens de fora, com a sessão de um administrador (para quem só a origem barra), recusadas no salvar
(uma funcionalidade de rede que ficaria concedida) e no remover (uma linha que sairia), com o banco
intacto; e o controle da mesma origem, que grava e tira. Arrancar a barreira do salvar, e depois a do
remover, deixa as três de fora vermelhas, cada vez.

### D199 — Vinte e uma ações de remoção apagam por GET, e o inventário acha que seis delas não alteram nada — PARCIAL em 04/10/2026: por GET ficam só a dos banners (a ação não existe) e a dos privilégios (o D-ACL-1); as onze do cadastro passaram a POST, e o cancelamento de estoque ficou sob trava — PARCIAL em 06/10/2026 — PARCIAL em 07/10/2026 — PARCIAL em 13/10/2026 — CORRIGIDO em 20/10/2026: a dos banners; nenhuma remoção do ERP vai mais por GET

Achado em 22/09/2026, ao consertar a remoção de bloqueio da agenda (D196).

#### O padrão

A função global `modal_remover(url)`, do `geral.js`, só troca o `href` do botão
"Sim" do modal: **a remoção vira navegação, isto é, GET.** Medido casando o par
(controller, ação) de cada `onclick` — e não só o nome da ação:

| | |
|---|---:|
| telas que chamam `modal_remover` | **22** |
| pares (controller, ação) chamados | **22** |
| localizados no inventário | 21 |
| **aceitam GET** | **21 de 21** |

**Todas.** A primeira contagem que fiz, casando só o NOME da ação, dizia 23 de 27
— e os quatro "que exigem POST" eram homônimos em controllers que nenhuma dessas
telas chama. É o D177 outra vez: nome não é método.

#### O ponto cego do inventário

`scripts/inventariar-acoes.php` decide se uma ação **muta** por dois sinais:
escreve direto (`persist`/`remove`/SQL) **ou** recebe POST. Uma ação que **delega
a escrita a um serviço e aceita GET** não dispara nenhum dos dois — e sai como
leitura. Seis das 21:

| controller | ação | o que faz |
|---|---|---|
| `SaidasController` | `excluirParcela` | **apaga parcela de pagamento** |
| `CaixasController` | `retiradaCancelar` | **cancela retirada do caixa** |
| `ProdutosController` | `estoqueCancelar` | **cancela movimentação de estoque** |
| `ClientesController` | `pacoteRemover` | remove pacote do cliente |
| `NotificacoesController` | `cancelar` | cancela notificação |
| `AssistentesAdminController` | `ativarRemover` | remove/inativa |

As três primeiras mexem em **dinheiro e estoque**, e o inventário as lista como
ações que não alteram nada. O próprio `bloqueiosRemover` estava assim até hoje:
ao passar a exigir POST, a coluna `muta` dele mudou de `nao` para `sim`.

#### E um botão morto

`app-banners/index.phtml` aponta o "Remover" para `appbanners/ativarRemover`, e o
`AppBannersController` **não tem essa ação** — só `removerAction`. Medido lendo o
código, sem clicar.

#### O que NÃO foi feito, e por quê

Nenhuma das 21 foi mexida. Trocar o `modal_remover` global exige converter as 21
ações **ao mesmo tempo**, e o `geral.min.js` servido não tem passo de minificação
versionado. As seis que mexem em dinheiro e estoque merecem ser as primeiras, uma
de cada vez, como a do bloqueio foi. E o inventário precisa de um terceiro sinal
— chamada a método de serviço que escreve — para parar de chamar de leitura o que
apaga.

#### PARCIAL em 22/09/2026 — as três que mexem em dinheiro e estoque passaram a POST, e uma delas creditava o caixa em dobro

Decisão do dono: *"ataque as três remoções do D199 que mexem em dinheiro"*.

##### O que entrou

| ação | antes | agora |
|---|---|---|
| `SaidasController::excluirParcela` | GET | **POST** |
| `CaixasController::retiradaCancelar` (vendas) | GET, e **sem recusa** | **POST**, e o serviço **recusa** o que já foi cancelado |
| `ProdutosController::estoqueCancelar` | GET | **POST** |

**A função passou a morar num lugar só.** Com quatro telas removendo por POST — a
de bloqueios e estas três —, a função local que a de bloqueios tinha ganhou um
arquivo próprio, `public/layout/scripts/remover-por-post.js`, servido pelo layout.
Ele **não** mexe no `modal_remover` global, que as outras 18 ainda usam, e **não**
passa pelo `geral.min.js`, que não tem passo de minificação versionado. A tela de
bloqueios deixou o script dela e passou a usar o compartilhado.

##### O defeito que a leitura achou, e ele é de dinheiro

`CaixaService::retiradaCancelar()` soma o valor ao `valor_final` do caixa e marca a
retirada como cancelada — e **ninguém conferia se ela já estava**. A API nova
conferia antes de chamar; a porta do ERP, não. Um duplo clique no "Sim" dispara
dois pedidos, e os dois passavam.

**Quase sempre se curava sozinho**, e isso foi medido: o cancelamento redireciona
para `gerenciarAction`, que chama `atualizar()` e recalcula o caixa do zero a
partir das retiradas ativas. Ficava errado quando o pedido não seguia o
redirecionamento — e então quem lia o saldo inflado era a trava que impede
retirar mais do que o caixa tem.

**Provado vermelho → verde:** o caso novo `CaixaTest::testCancelarRetiradaJaCanceladaNaoDevolveDeNovo`,
sobre o serviço de antes, falhou com *"a segunda vez tem de ser RECUSADA"* — 1
falha em 675. Com a trava no serviço, **675 verdes**. A regra mora no serviço, e
não na ação, porque é o único lugar por onde as duas portas passam.

##### O limite desse conserto, dito sem maquiagem

A trava lê o status e grava depois, sem `UPDATE` condicional nem trava de linha.
**Onde isso importa, eu tinha escrito errado, e um adversário corrigiu:** o duplo
clique no mesmo navegador **não** corre. A sessão é gravada em arquivo
(`session.save_handler = files`, medido no contêiner), e o PHP trava o arquivo da
sessão do começo ao fim do pedido: o segundo clique **espera** o primeiro terminar,
relê a retirada já cancelada, e agora é recusado. Era exatamente o caso que creditava
em dobro antes — em sequência, não em paralelo.

A corrida que sobra é entre **sessões diferentes**: a tela num navegador e a API
noutro, dois usuários da mesma franquia, ou um administrador entrando como outro
(`autenticarComo`). Aí as duas leituras veem a retirada ativa e as duas creditam.
Fechar isso pede `UPDATE … WHERE status = 1` e conferir as linhas afetadas. O mesmo
vale para o `estoqueCancelar`.

> **26/09/2026 -- a corrida do CANCELAMENTO DE RETIRADA fechou, por outro
> caminho (D228).** Em vez do `UPDATE` condicional, `retiradaCancelar()` passou a
> travar a linha do caixa e a reler a retirada depois da trava: a segunda sessão
> espera a primeira terminar, vê a retirada já cancelada e desiste. Provado por
> `CaixaSobTravaTest`. O `estoqueCancelar` continua como está escrito acima. E se a sessão um dia for para o banco — o
`sessao.global.php` já prepara o driver, e o D198 registra que ele nunca é
aplicado —, a trava do arquivo some e o duplo clique volta a correr.

> **04/10/2026 -- a do CANCELAMENTO DE ESTOQUE fechou também, pelo caminho do
> D228 (ver o fim do D126).** `estoqueCancelar()` trava a linha do produto, relê
> a movimentação pela leitura com trava e desiste se ela já foi cancelada; as
> duas portas leem a resposta (o ERP diz *"Esta movimentação já estava
> cancelada."*, a API responde 409). Provado por `EstoqueSobTravaTest`, nos dois
> bancos. O "cancelar **entrada** de estoque não tem piso", da lista abaixo,
> continua: foi ao dono como pergunta.

##### Verificado sem alterar nada

Para cada ação, um usuário **que pode** executá-la, sobre uma linha **do escopo
dele**, com backup antes:

| usuário | GET | resultado |
|---|---|---|
| `user1270` | `excluirParcela/102` | **404** |
| `user8` | `estoqueCancelar/1` | **404** |
| `user2337` | `retiradaCancelar/22815` | **404** |

As cinco linhas envolvidas — parcela, movimentação, quantidade do produto,
retirada e `valor_final` do caixa — ficaram **idênticas ao backup, byte a byte**.
No navegador, como `user8`, o clique real no `×` abriu o modal com o formulário
POST apontando para `estoqueCancelar/1`. O "Sim" não foi apertado.

##### Os portões

- **`conferir-remocao-por-post.php`, novo.** Deriva das telas o par tela + ação e
  exige que concordem: tela em POST com ação que aceita GET, tela em GET com ação
  que exige POST (**botão morto**), tela sem o formulário. E **trava em 19** as
  remoções que ainda são por GET — só pode descer. Falsificado nas três formas.
- **`conferir-recorte-por-unidade.php`** passou a vigiar as três — não vigiava
  nenhuma. Tirando o recorte do `estoqueCancelar`, fica vermelho.

##### O que a leitura achou e NÃO foi consertado

- `retiradaCancelar` **apaga fisicamente** a `Saida` ligada (`em->remove`), com
  cascata para parcela e pagamento — o rastro financeiro da sangria some.
- O mesmo cancelamento é guardado por **`vendas/nova`** no ERP e por
  **`vendas/caixa`** na API — a família do D195.
- `retiradaCancelar` faz três `flush` sem transação.
- Cancelar **entrada** de estoque não tem piso: a quantidade vai a negativo.
- `excluirParcela` não registra quem excluiu, e reexcluir sobrescreve a data.
  **04/10/2026:** reexcluir é recusado (o D385, corrigido). Quem excluiu continua sem
  registro: pede coluna nova, como o motivo do estorno (a P5 do desenho de Contas a Pagar).

- **`excluirSaida` contorna a regra do D135.** A ação do ERP e a da API só
  recusam saída com parcela **quitada**; o serviço põe `status = 0` em toda
  parcela ativa sem perguntar `temPagamento()`. Parcela com pagamento parcial
  some do previsto e fica no realizado. Medido na cópia: **12 parcelas, em 12
  saídas, com R$ 8.308,79 pagos**, passam pela guarda. Nenhuma saída foi excluída
  até hoje — latente. **Consertado no D205**, em 22/09/2026.
- `retiradaCancelar` **apaga fisicamente** uma `Saida` que nasceu quitada — e o
  financeiro, para a mesma situação, **recusa** a exclusão (D133) e, quando
  exclui, exclui por status. As duas regras não concordam.
- O recorte do cancelamento aceita **caixa aberto de qualquer idade**, e o D28 diz
  que o caixa não atravessa o dia. Medido: **139 caixas abertos**, 2 de fixture,
  **117 de antes de 2026** — o mais antigo de 2015 —, 36 de usuário ativo.
- A exclusão em lote de parcelas (`excluirParcelas`, a usada de verdade) grava
  uma a uma e, se uma é recusada, devolve só `{sucesso: false}`: a tabela segue
  mostrando como vivas as que acabaram de ser excluídas.
  **04/10/2026 (o item FINANCEIRO-S1):** o serviço devolve as excluídas e as
  recusadas (com pagamento, já excluídas, de outra unidade ou inexistentes -- as
  duas últimas juntas, para não contar que o id existe noutra franquia); a ação
  devolve as duas listas e a frase, que antes não vinha (o aviso saía vazio); e a
  tela recarrega quando alguma saiu -- o `mod-financeiro.js` e o `.min.js`, que é
  o servido. Provado em processo (`SaidaFinanceiroTest`, `SaidasDoErpTest`); o
  clique na tela não foi visto no navegador.
  **A revisão do item (04/10/2026)** achou três restos, consertados: (1) o id inexistente não
  tinha caso -- um ramo que o pulasse calado passava; ganhou um, com e sem unidade. (2) **O que
  nem é id** (letras, fração) ia cru ao `find()`: no PostgreSQL derrubava o lote inteiro com 500
  (medido, 22P02), e no MySQL `'1.5'` voltava como a recusada de id 1; agora é recusado sem ir ao
  banco, como 0. (3) **Com o `.min.js` de antes em cache**, o lote parcial mostrava o aviso na hora
  e a mensagem guardada de novo na página seguinte: a ação só a guarda quando a tela diz que vai
  recarregar (`recarrega`, que o arquivo novo manda), e o `?cache=` do `mod-financeiro.min.js` no
  `layout.phtml` passou a 165. `SaidaFinanceiroTest` e `SaidasDoErpTest`, nos dois bancos, com um
  mutante por conserto.
- **Três dos dez administradores ativos** estão em unidades que pagam royalty —
  indício, não prova, de franqueado com o atalho de administrador, que atravessa
  todas as franquias. **Pergunta para o dono**, não defeito medido.

##### A revisão adversarial, e o que ela mudou

Três leitores independentes, um por ação, e um adversário para cada leitura com a
instrução de derrubá-la. Os **seis terminaram**, nenhum sem veredito. Os três
adversários refutaram partes das leituras — contagens de privilégio sem filtrar
usuário ativo, "sem sessão dá 404" (dá a tela de login) — e acharam o que eu
tinha perdido:

1. **O portão novo conferia se a guarda EXISTE, não se ela GUARDA.** Procurava o
   texto `isPost()`; com o sinal trocado, ficava verde. É o erro do precedente, no
   portão que nasceu para evitá-lo. **Consertado:** `formaDaGuardaPost()` lê a
   forma — *recusa* (`if (!isPost() || …) { return 404 }`) ou *envoltório*
   (`if (isPost() && …) { … }`) — e o resto é defeito com nome. **Falsificado nas
   três inversões**: `isPost() ||`, `!isPost() &&` e `if (isPost()) { recusa }`,
   cada uma vermelha com o diagnóstico certo, e o código restaurado conferido por
   sha1. O `conferir-guarda-da-agenda.php` tinha a mesma fraqueza e ganhou a mesma
   função — a mesma nos dois, cada um apontando o gêmeo.
2. **O limite do meu conserto estava com o motivo errado** — acima.
3. **O D201 foi achado por eles**, e o **D202** e o **D203** também.

O inventário (`inventariar-acoes.php`) ainda marca `recebe_post` pela presença do
texto. É descrição, não portão, mas uma guarda invertida sairia lá como "recebe
POST = sim".

**Fora deste débito:** a API também é POST sem token, e a de cancelar
movimentação nem lê corpo — basta um formulário que se envia sozinho numa página
de fora, num navegador que não aplique `SameSite=Lax` por padrão. É o D198, e a
decisão de token com prazo continua com o dono.

**Ficam 18 remoções por GET**, mais o botão morto dos banners.

**01/10/2026 -- D314:** `ClientesController::pacoteRemover` passou a POST, e a trava do
`conferir-remocao-por-post.php` desceu de 14 para 13.

#### PARCIAL em 04/10/2026 -- as onze do cadastro passaram a POST, com o estado pedido

Das 13 que ainda removiam por GET, onze não pediam decisão: a regra é a de `remover-por-post.js`, e o
molde das que **alternavam** é o do D263 dos cupons. Quem chama cada ação foi conferido antes, por grep
em `module/`, `public/`, `frontend/src`, `ensaios` e `scripts`: só a `index.phtml` de cada uma (o X e o
"Ativar"). A API nova chega aos mesmos serviços por outro caminho e não mudou.

| tela | ação | antes | agora |
|---|---|---|---|
| clientes (ISCadastro) | `ativarRemover` | GET que alternava | POST com `estado` |
| produtos, serviços, pacotes, etiquetas | `ativarRemover` | GET que alternava | POST com `estado` |
| arquivos, pesquisas, unidades | `ativarRemover` | GET que alternava | POST com `estado` |
| assistentes, mensagens (a tela só remove) | `ativarRemover` | GET que alternava | POST com `estado` |
| VIP Class | `remover` | GET que desativava | POST |

**As três peças juntas, em cada uma:** a ação começa recusando, com o 404 das outras recusas, o GET e o
POST sem `estado` ("ativo" ou "inativo"), e grava o estado pedido -- não o contrário do que leu: o
segundo envio não desfaz o primeiro. O X chama `modalRemoverPorPost`; o "Sim" do modal é o
`<form id="form_remover_post" method="post">` com `estado=inativo`; e o "Ativar", que era um **link para
a mesma ação**, é um formulário com `estado=ativo` -- sem isso, ele viraria botão morto. Na lista de
clientes o "Ativar" mora no menu da linha: o formulário fica escondido e o item continua um `a` filho do
`li` (o molde do "Sair" no layout). As recusas e as regras de antes continuam: o recorte da unidade em
produtos, serviços e pacotes (o D142), e a pesquisa do programa de fidelidade, que não sai (o D58). A
unidade é o único serviço que **alterna** (`UnidadeService::ativarRemover`): a ação só o chama quando o
estado pedido não é o de agora. O VIP Class só desativa, como antes, e não lê estado.

**O portão** (`conferir-remocao-por-post.php`): a trava desceu de 13 para **2**, e ele ganhou uma
conferência -- **o botão irmão**: nenhum `href` da tela pode apontar para uma ação que exige POST. Ele
só lia o `onclick` do modal, e o "Ativar" por link passaria verde com a ação recusando GET. Falsificado:
com o "Ativar" dos produtos de volta a link, vermelho nomeando o par; com o mesmo link e o **código** da
conferência arrancado (a condição tornada falsa), verde -- é ela que o vê. Restaurados por sha1. **O
inventário** (`docs/inventario-acoes.*`, regerado) marca as onze como ações que recebem POST, e o
`AssistentesAdmin::ativarRemover` -- uma das seis que ele chamava de leitura -- passou a contar como ação
que muda (197 para 198).

**Provado:** `tests/Caracterizacao/D199AtivarRemoverPorPostTest.php`, 57 execuções (as onze em cinco
perguntas e dois casos à parte; 4 puladas, onde não há estado ou não há "Ativar"), verdes nos dois
bancos: o GET dá 404 com a linha intacta, o POST sem estado também, o POST desativa e o segundo POST
**não** reativa, o `estado=ativo` ativa, a pesquisa da fidelidade continua recusada, e a lista
renderizada pelo `ViewRenderer` da aplicação, com o que a `indexAction` lhe passa, tem o X por POST, o
"Sim" com `estado=inativo`, o "Ativar" como formulário e nenhum link para a ação. As quatro tabelas sem
fixture (arquivos, assistentes, mensagens, pesquisas) ganham a linha dentro da transação do caso. **Os
mutantes, um de cada vez, restaurados por sha1:** cada uma das onze ações aceitando GET de novo, o X por
link em cada uma das onze telas, o "Ativar" por link (produtos, unidades, clientes), o "Sim" por link, o
"Sim" sem o estado, a ação alternando de novo, a unidade sem a guarda do estado, o POST sem estado aceito
e a pesquisa sem desativar -- todos vermelhos no caso; o portão ficou vermelho em todos os de tela e de
GET, **menos o "Sim" sem o estado**: ele não lê o `estado` do formulário, e quem o vê é o caso.
**Nada disto foi visto no navegador**: as telas são antigas, e os casos as renderizam em processo.

**O que ficou:**
- **Por GET, só duas:** o "Remover" dos banners (`appbanners/ativarRemover` não existe; escrever a ação
  é decisão) e o dos privilégios (`privilegios/remover`, o D-ACL-1, item próprio).
- **A lista de clientes** só mostra o X e o "Ativar" a quem é da unidade do cliente ou administrador, e a
  ação -- como a API nova -- não confere a unidade: o cliente é da rede (o D145). A tela e a ação
  continuam discordando nisso, como antes, e qual das duas vale foi ao dono como pergunta. E a ação
  antiga muda o estado dos dois marcadores, que a API recusa -- o D394.

#### PARCIAL em 06/10/2026 -- o inventário segue a escrita até o serviço, e lê a forma da guarda

As duas partes do inventário que este débito pedia, e que ficaram de fora em 22/09 e em 04/10.

**O terceiro sinal do `muta`.** `scripts/inventariar-acoes.php` passou a seguir a CHAMADA: monta um
índice das classes de `module/*/src` pelo tokenizador (cada método com o corpo sem comentário, a mãe,
os `use`), marca o método que escreve -- `persist`, `remove`, `flush`, `executeStatement` ou DML no
texto, com as strings vizinhas juntadas antes, porque a DQL vem em pedaços (`"UPDATE ...\Cliente cli"
. " SET ..."`) -- e propaga por ponto fixo a quem o chama. Só segue o que sabe a que classe pertence: o
serviço da fábrica do controller (pela variável ou pela posição do argumento), o
`$this->sm->get(X::class)`, o `getRepository(Entidade::class)` pelo `repositoryClass` da entidade, o
`new X(...)`, a propriedade e o apelido dela (`$servico = $this->service;`), e o `$this->metodo()` da
própria classe e das mães. A coluna nova `escreve_por_chamada` diz qual, no fim do CSV (quem lê o CSV
lê pelo nome da coluna).

| | antes | agora |
|---|---:|---:|
| ações que mutam | 198 | **232** |
| só pelo terceiro sinal | -- | 34 |
| com a escrita apontada em `escreve_por_chamada` | -- | 177 |

As 34, conferidas uma a uma no código (o trecho que casou, impresso por um roteiro à parte): 25 rotinas
do cron e nove ações de tela -- `vendas/novo` e `vendas/comanda` (o D316), `vendas/comandaAbrirAgenda`,
`vendas/indexVendas` (o D428, novo), `caixas/gerenciar`, `cupons/index` (o D264), `sorteios/index` (o
D334) e as duas mensagens da agenda (`clienteNovoMensagem`, `clienteSemRetornoMensagem`). Todas gravam
de verdade: `persist`/`flush`/`remove` ou DQL de `UPDATE`/`DELETE`. **As seis deste débito** passaram a
POST antes, e por isso já saíam como "muta"; agora o terceiro sinal as vê também pelo serviço
(`SaidaService::excluirParcela`, `CaixaService::retiradaCancelar`, `ProdutoService::estoqueCancelar`,
`ClienteService::pacoteRemover`, `NotificacaoService::cancelar > NotificacaoRepository::mudarStatus`,
`AssistenteAdminService::alterStatus`) -- a que voltasse a aceitar GET não sairia como leitura.

**`recebe_post` pela forma da guarda.** O inventário marcava pela presença do texto `isPost`, e a guarda
de sinal trocado (`if (isPost()) { recusa }`) saía "recebe POST = sim". Agora usa `formaDaGuardaPost()`,
a gêmea dos dois portões: o sinal trocado e a condição que não separa GET de POST (`isPost() ||`,
`!isPost() &&`) dão `nao`. Os outros dois "defeitos" da função não são isso aqui: ela é do portão das
remoções e exige a recusa por 404, e acusa onze ações que recusam o GET de outro jeito (JSON de erro,
`$req->isPost()`, a tela no GET de `pesquisas/responder`) -- essas seguem `sim`, como antes, e a forma vai
na coluna nova `guarda_post`. Hoje nenhuma ação tem guarda que deixe o GET passar: **a coluna
`recebe_post` não mudou em linha nenhuma**: a regra com que o medidor de relatórios escolhe do CSV dá as
mesmas 31 ações com o CSV de antes e com o de agora, e a do medidor de telas, as mesmas 295.

**A cópia é a terceira, e não deriva calada:** o gerador lê a função nos dois portões e recusa rodar
(saída 2) se as três não forem a mesma; e um defeito com texto que ele não conhece também o para, em vez
de virar "recebe POST" sem ninguém ver.

**Falsificado numa cópia fora do repositório** (`/tmp/fals-inv`), cada mutante sozinho:

| mutante | gerador novo | gerador de antes | mutante do gerador |
|---|---|---|---|
| `excluirParcela` volta a aceitar GET | `muta = sim` (`SaidaService::excluirParcela`) | `muta = nao` | sem o terceiro sinal: `nao` |
| `excluirParcela` com a guarda de sinal trocado | `recebe_post = nao`, `guarda_post` = o defeito | `recebe_post = sim` | sem a forma da guarda: `sim` |
| a árvore limpa, sem o terceiro sinal no gerador | -- | -- | `vendas/novo` e `vendas/comanda` voltam a `nao` |
| a gêmea de `conferir-remocao-por-post` diverge | saída 2, nomeando o arquivo | -- | -- |

E na árvore limpa: `inventario-acoes.md` e `.csv` regerados, `conferir-inventario-atual.sh` com saída 0
(359 ações nos dois contadores), e os dois portões gêmeos com saída 0 depois da nota da terceira cópia
no comentário deles.

**O que ele ainda não vê, medido:** o serviço que chega por PARÂMETRO (`conveniosAtualizarVencimento`
passa o `ConvenioService` ao método da rotina, e lá ele é só uma variável), e o que grava em arquivo e
não no banco (os caches de evasão e de fidelização, que continuam "não muta" -- e por isso não estão
errados). Chamada que ele não resolve não conta: o sinal pode faltar, e não inventa.

**A revisão de 06/10/2026 achou dois furos na versão acima, e os dois foram fechados:**

1. **A guarda de sinal trocado tirava o segundo sinal do `muta`.** O `muta` usava o `recebe_post` já
   decidido pela forma da guarda, e não o texto. Uma ação com `if (isPost()) { return 404; }` que
   escreve por uma chamada que o terceiro sinal não resolve saía `muta = nao` -- exatamente o "apaga por
   GET e passa por leitura" deste débito. O mutante de cima (`excluirParcela`) só saiu certo porque o
   `SaidaService` é resolvido. Agora o segundo sinal do `muta` é o texto (`isPost`, `getPost(`,
   `fromPost`), e a forma da guarda decide só a coluna `recebe_post`.
2. **As três cópias eram iguais no texto e diferentes no resultado.** A primeira linha da função era
   `semComentarios($corpo)`, e cada arquivo tem o seu: os portões apagam o comentário, o inventário põe
   um espaço no lugar. Em `getRequest()/* so POST */->isPost()`, medido com as três cópias e o
   `semComentarios()` de cada arquivo, os dois portões diziam `recusa` e o inventário `defeito: isPost()
   nao abre a condicao`. Agora a função faz a forma canônica por conta própria, só com funções do PHP
   (sem comentário e sem espaço, um espaço só entre duas palavras), e as três dizem `recusa`. O gerador
   passou a recusar (saída 2) a cópia que chame função que não seja do PHP -- texto igual só prova
   resultado igual assim -- e roda a função sobre seis amostras (`AMOSTRAS_DA_GUARDA`: o comentário de
   bloco, o de fim de linha, o espaço no lugar dele, o `envolve` e o sinal trocado). O
   `semComentarios()` do portão da agenda, que só ela usava, saiu.

Na árvore limpa nada mudou: o CSV e o `.md` regerados saíram **idênticos byte a byte** aos da versão
acima (no PHP do contêiner e no do Windows), `conferir-inventario-atual.sh` com saída 0, e as saídas
dos dois portões gêmeos iguais às de antes da troca.

**Falsificado numa cópia fora do repositório** (`/tmp/fals-inv`), cada mutante sozinho, olhando a linha
de `WhatsApp/desconectar` (ela recusa o GET e desconecta a instância por `getWhatsAppApi()`, que o
terceiro sinal não resolve):

| mutante | revisão | versão de cima | revisão com o `muta` pela forma da guarda |
|---|---|---|---|
| árvore limpa | `muta = sim`, `recusa` | `muta = sim`, `recusa` | `muta = sim` |
| `desconectar` com o sinal trocado | **`muta = sim`**, `recebe_post = nao`, `SINAL TROCADO` | `muta = nao` | `muta = nao` |
| `desconectar` com `isPost() \|\|` | **`muta = sim`**, `recebe_post = nao`, `nao separa` | `muta = nao` | `muta = nao` |

E no gerador da revisão, as três cópias mudadas juntas (o texto continua igual nas três): com a
`semComentarios($corpo)` de volta na primeira linha, saída 2, "chama `semComentarios()`, que nao e do
PHP"; sem a forma canônica, saída 2, "leu errado a amostra" do comentário de bloco.

**O que fica deste débito:** o "Remover" dos banners (a ação não existe) e o dos privilégios (o
D-ACL-1), como em 04/10.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, perguntas 18, 19 e 24): na 18, a opção (a),
"Junto com a P5, nas três escritas que desfazem" -- estornar, excluir parcela e excluir despesa ganham o
autor no mesmo commit, **se** a P5 do desenho de Contas a Pagar
(`docs/f3-telas-restantes/tela-saidas.md`; o consolidado a deixou para depois, com "não pede até a P8;
depois, pede e guarda o autor") for "pede e guarda" -- a decisão fica condicionada a ela, e até lá a
exclusão segue sem autor; na 19, a opção (b), "Deixar como está" -- o cancelamento de entrada de estoque
continua sem piso (o D126); na 24, a opção (a), "A regra da tela vale no servidor" -- desativar e ativar
cliente, só a própria unidade ou o administrador, na ação antiga e na API, com a implementação na fila.
O que fica deste débito, como em 06/10: o "Remover" dos banners e o dos privilégios.

#### PARCIAL em 07/10/2026 -- quem desativa e ativa cliente, pela resposta 24 do dono

**O conserto.** A regra da lista antiga vale no servidor: desativar e ativar um cliente, só a unidade dele
ou o administrador -- e o cliente sem unidade, só o administrador (`RegraDoCadastroDeCliente::mudaOEstado`).
As três portas a chamam:

| porta | fora da regra |
|---|---|
| `ClientesController::ativarRemoverAction` (o X e o "Ativar" da lista antiga) | 404, como as outras recusas da ação |
| `POST /adm/api/clientes/:id/desativacao` e `/ativacao` | 403 `outra_unidade`, "Só a unidade do cliente ou o administrador desativa este cadastro." -- e não 404: o `GET` do mesmo endereço mostra o cliente a todos, e negar que ele existe seria mentir. Vem depois do 409 `sentinela` |
| a lista antiga (`clientes/index.phtml`) | não desenha o botão, agora pela mesma regra e não por uma comparação própria |

A edição continua da rede (o D145). A tela nova não tem o botão de desativar cliente -- conferido: nenhum
endereço de ativação de cliente em `frontend/src` (`api/clientes.ts` só lê, cadastra e altera), e nenhum
`.teste.tsx` os pede --; quando o ganhar, a recusa da API já está lá. Junto, o D394: a ação antiga recusa
os dois cadastros do sistema, e a lista não lhes desenha o botão.

**Provado** nos dois bancos (`DesativarClienteSoDaUnidadeTest`, 8 casos): na ação antiga, o cliente de
outra franquia é 404 nos dois sentidos e o estado fica, e a unidade dele e o administrador mudam (os
controles); na API, 403 `outra_unidade` nos dois endereços e o estado fica, a unidade e o administrador
mudam, e a sentinela pedida por quem é de outra franquia continua 409 `sentinela`; a lista desenha o X
para o da unidade, não para o de outra franquia (para o administrador, sim) e nunca para o avulso. E pelo
aplicativo de verdade, no ensaio de recusas, o caso novo 35v2: o gerente pede a desativação do cliente da
unidade 90001150 e recebe 403 `sem_permissao` (o ensaio inteiro, 202 de 202; o de permissão, 61 de 61; o do
fluxo, 22 passos); com a guarda da API arrancada, o mesmo caso sai 200 e o ensaio aponta a recusa que não
aconteceu (a rodada dos casos 35, 32 de 33). O portão `conferir-recorte-por-unidade.php` vigia a regra e as duas chamadas (187
métodos; arrancada a guarda da ação, a da API, a regra trocada por `return true` ou a recusa da ação
comentada, um de cada vez: vermelho). Mutantes no caso, cada um sozinho e restaurado por sha1, todos
vermelhos: a ação sem a guarda (1), a API sem a guarda (1), a regra sempre verdadeira (3: a ação, a API e a
lista), a regra sem o administrador (3) e a lista sem a regra (1).

**Não visto no navegador:** a lista e a ação foram despachadas e renderizadas em processo.

**A quarta porta, achada pela verificação (07/10/2026).** As três portas acima não eram todas: a EDIÇÃO
(a página `editar` e o modal, `clientes/salvar`) entregava o POST cru a `ClienteService::update()`, e o
hidratador gravava o `status` que viesse nele. A edição é da rede (o D145), e por ela o gerente de uma
franquia, sem ser administrador, desativava o cliente de outra com `status=0`; e o select escondido da
página `editar`, com o estado lido ao abrir, reativava o cliente que a unidade dele tinha desativado pela
lista nesse meio-tempo (o D259). O conserto, no molde do D202 e do D259, é o D438: a edição grava só os
campos da tela, e o estado muda só pelos botões. **Provado** nos dois bancos
(`D438EdicaoDoClienteSoGravaOsCamposDaTelaTest`): o cliente de outra franquia pela página, nos dois
sentidos, e pelo modal -- a edição grava o nome, e o estado fica.

**O que fica deste débito**, como em 06/10: o "Remover" dos banners e o dos privilégios.

#### PARCIAL em 13/10/2026 -- quem excluiu a parcela e a despesa (a resposta 18, com a 140; o item CONTAS-A-PAGAR)

A 18 do dono (06/10/2026, a opção (a)) dava o autor às três escritas que desfazem -- o estorno, a exclusão da parcela e
a da despesa -- **se** a P5 de Contas a Pagar fosse "pede e guarda". A P5 virou a pergunta 140, e o dono a respondeu em
13/10/2026 com a (a): o estorno pede e guarda o motivo, "ao lado do autor da resposta 18". A condição se cumpriu.

**O conserto.** `saidas_parcelas.excluida_por` e `excluida_em_nome_de`, e `saidas.excluida_por` e `excluida_em_nome_de`:
o `CrudController::autorDaEscrita`, como a vigência do horário -- quem entrou na sessão, e a pessoa assumida só quando
difere. `SaidaService::excluirParcela`, `excluirParcelas` (que delega) e `excluirSaida` pedem o autor e falham alto sem
as duas chaves (como `VigenciaDoHorario`); as portas -- a tela antiga (`excluirParcela`, `excluirParcelas`, `excluirSaida`)
e a API (`excluirParcela`, `excluir`) -- passam o da sessão. A exclusão da despesa grava o autor nela e em cada parcela
que sai com ela; a recusada não grava nada. As colunas moram fora do mapeamento (o D473), com a migração, as cópias e o
banco sem elas no **D207**.

**Provado** em `tests/Caracterizacao/ContasAPagarRespostasTest.php` (24 casos), `D375SangriaAmarradaTest.php` (21) e
`ContasAPagarSemAsColunasTest.php` (4), novos, e nos vizinhos ajustados às assinaturas novas (`SaidaFinanceiroTest`,
`SaidaSobTravaTest`, `SaidaApiTest`, `SaidaApiEscritasTest`, `CaixaTest`, `CaixaApiTest`, `CaixaSobTravaTest`,
`ColunasForaDoMapeamentoTest`): 358 casos do filtro do item (Contas a Pagar, o caixa, as colunas fora do mapeamento e
os vizinhos), verdes no MySQL e no PostgreSQL, em série. O item CONTAS-A-PAGAR (o roadmap de 13/10/2026) tem a tabela
inteira dos mutantes; os deste débito estão abaixo.

- pelo serviço, pela tela antiga -- com a sessão personificada: `excluida_por` é o administrador que entrou, e
  `excluida_em_nome_de` o gerente assumido -- e pela API; a exclusão recusada (a parcela paga, a já excluída, a despesa
  com dinheiro) fica com o nulo; o autor sem as duas chaves falha alto, e nada sai;
- mutantes vermelhos: a parcela sem o autor (3 casos); a despesa sem o autor das parcelas (2); a despesa sem o dela (3);
  a tela antiga e a API com um autor fixo, e não o da sessão (1 cada); o autor sem conferir as chaves (1); e, no banco
  sem as colunas, os dois guardas do autor arrancados (1 cada, nos dois bancos -- no PostgreSQL, **refeitos às 02:13 de
  13/10/2026**, pela verificação: a primeira rodada deles lá corria com o autor que o `finally` abaixo deixava no banco,
  e o caso caía com ou sem o mutante; na rodada limpa, os cinco guardas das colunas caíram cada um só no seu caso);
- `SaidaSobTravaTest::testForaDaTransacaoDoTesteAExclusaoVeOPagamentoConfirmadoPorOutraConexao` grava de verdade, fora
  da transação do caso, e o `finally` dele devolvia só a situação e a data: o autor da exclusão ficava no banco da suíte,
  e o `ContasAPagarSemAsColunasTest` o leu no PostgreSQL (medido, num mutante). O `finally` passou a devolver o autor
  também, e o banco ficou limpo depois da rodada nos dois.

**O que fica deste débito**, como em 07/10: o "Remover" dos banners (a ação não existe) e o dos privilégios (o D-ACL-1).

#### CORRIGIDO em 20/10/2026 -- a dos banners (a fatia 21, BANNERS-SERVIDOR)

O X dos banners chamava `modal_remover` para um `ativarRemover` que não existia; a ação nasceu, só por POST e com o estado
pedido, e o X chama `modalRemoverPorPost` (o D105). E o `removerAction` dos banners, que nenhuma tela chama e apagava por GET
-- com o 500 do `getReference` no id que não existe (o D542) --, passou a recusar o GET como primeiro comando e a responder 404
ao id que não existe. Ele entrou em `ACOES_SO_POST` de `scripts/conferir-remocao-por-post.php` como ação sem tela, com a
quinta coluna dizendo o controller (`remover` é nome de ação em outras três telas); a entrada saiu de
`ACOES_INEXISTENTES_CONHECIDAS`; e **a trava das remoções por GET desceu de 1 para 0**.

**Provado** nos dois bancos: a linha dos banners no `D199AtivarRemoverPorPostTest`, e
`TelasDosBannersDoErpTest::testORemoverSoPorPostEO404` (o GET não apaga; o POST num id que não existe, e no maior que o
`int4`, é 404; o POST de verdade apaga a linha, e o arquivo depois dela). Mutantes: a ação aceitando GET, o remover aceitando GET
e o remover de volta ao `getReference` -- vermelhos; e, com o remover aceitando GET, o portão também sai 1.

**Nada fica deste débito**, pelo que ele mesmo registra (corrigido na verificação de 20/10/2026: a primeira versão desta
seção o deixava PARCIAL, com "a lista de clientes e a ação discordando sobre a unidade" como resto). Os três restos que ele
apontou estão fechados: o "Remover" dos banners, aqui; o dos privilégios (o D-ACL-1), em 04/10/2026, pelo D335 -- o
`PrivilegiosController::removerAction` recusa o GET como primeiro comando, e a trava do portão desceu de 2 para 1 naquele dia;
e a discordância da lista de clientes com a ação, que o dono decidiu em 06/10/2026 (a resposta 24 (a), "A regra da tela vale
no servidor") e que foi feita em 07/10/2026 (`RegraDoCadastroDeCliente::mudaOEstado` nas três portas e na quarta, a edição, o
D438; `DesativarClienteSoDaUnidadeTest`). A trava das remoções por GET está em **0**. As leituras de 22/09/2026 que não são
remoção por GET ("O que a leitura achou e NÃO foi consertado") nunca foram o assunto deste débito, e não foram relidas aqui.

### D200 — No PHP 8, a tela de parcelas nunca mostrava "Editar" nem "Remover": comparava um valor FORMATADO com zero — CORRIGIDO em 22/09/2026

**Achado ao converter o D199**, e só porque abri a tela: o botão que eu tinha
acabado de converter para POST **não aparecia** em parcela nenhuma.

##### O defeito

`saidas/parcelas.phtml` só desenha os botões de editar e remover quando a parcela
não foi paga:

```php
if (!$parcela->getQuitada() && $parcela->getValorPago() == 0)
```

`getValorPago()` sem argumento **devolve texto formatado** — `'0,00'` — porque o
padrão é `$mascara = true`. No PHP 7, `'0,00' == 0` era **verdadeiro**: a string
não numérica virava 0. **No PHP 8 é falso**: o número é que vira string, e
`'0,00'` não é `'0'`. O sistema roda em PHP 8.1, então **em nenhuma parcela os
dois botões apareciam**. Excluir parcela pela tela era impossível — só pela URL,
que era GET (D199).

##### O conserto

```php
if (!$parcela->getQuitada() && (float) $parcela->getValorPago(false) == 0)
```

O valor cru, comparado como número. Medido na tela, como `user1270`: antes, zero
botões de remoção; depois, o botão da parcela 102 aparece, chamando
`modalRemoverPorPost` para `excluirParcela/102`.

**O que isso devolve, contado:** **463 parcelas**, em 135 saídas de **18
unidades**, voltam a mostrar "Editar" e "Remover" — é o comportamento do PHP 7, que
é o que a regra escrita na tela pede. E a ação que o botão chama **não tem teste
de HTTP**: o GET que dá 404 foi medido à mão, e a suíte de caracterização chama o
serviço, não o controller.

##### A varredura da classe, e o que ela NÃO cobre

Vinte getters devolvem texto formatado por padrão (`getValor`,
`getValorFinal`, `getValorPago`, `getValorVenda`, datas…). Procurei cada um
**sem argumento** ao lado de comparação ou conta:

- **Telas: zero**, depois do conserto. A régua só vê a comparação **direta** com
  número na mesma linha; valor guardado numa variável e comparado depois passa
  por ela.
- **Servidor: 19 candidatos pelo nome, zero pelo tipo.** Os 19 foram conferidos
  pela classe do objeto: 15 são getters de **outra** classe com o mesmo nome e
  sem máscara (`ClienteCredito::getValor`, `Venda::getValorTotal`,
  `VendaPagamento::getValor`, `RoyaltiesFatura::getValor`), dois são datas
  (`->format()`), e dois são o `CaixaFechar` exibindo o valor num campo só de
  leitura — onde formatar é o certo. É a lição do D177: **nome não é método**.

**Fica em aberto:** não há portão para a classe. A régua desta varredura acusa por
nome, e 19 de 19 foram falsos positivos no servidor — um portão assim viraria
ruído. O portão certo precisa do tipo, e isso é trabalho de análise estática
(PHPStan com o nível que enxerga `string == int`), não de expressão regular.

### D201 — As rotas de ITEM do catálogo na API nova devolviam e alteravam produto de QUALQUER franquia — CORRIGIDO em 22/09/2026 — CORRIGIDO em 05/10/2026

**Achado por um leitor independente** ao ler o `estoqueCancelar` do D199: a
**outra porta** do mesmo serviço, na API, não conferia a franquia.

##### O defeito

Em `Adm/ProdutosController`, a listagem filtra por `unidade => $this->unidadeId`.
As rotas de **item** não filtravam — nem as cinco que passam por
`produtoDaRota()`, nem as duas que passam por `mudarEstado()`, que tem guarda
própria:

| rota | faz | passa por |
|---|---|---|
| `GET /adm/api/produtos/:id` | lê preço de venda, de compra, estoque | `produtoDaRota` |
| `PUT /adm/api/produtos/:id` | **edita** — inclusive a `quantidade`, absoluta | `produtoDaRota` |
| `GET /adm/api/produtos/:id/estoque` | lê as movimentações | `produtoDaRota` |
| `POST /adm/api/produtos/:id/estoque` | **movimenta** o estoque | `produtoDaRota` |
| `POST /adm/api/produtos/:id/estoque/:mov/cancelamento` | **cancela** movimentação | `produtoDaRota` |
| `POST /adm/api/produtos/:id/ativacao` | **ativa** | `mudarEstado` |
| `POST /adm/api/produtos/:id/desativacao` | **desativa** | `mudarEstado` |

**Quem alcançava:** 112 usuários ativos **não administradores**, em 54 unidades,
com o privilégio de produtos — cada um sobre o catálogo das outras franquias.

##### O conserto, e o furo no primeiro conserto

`produtoDaRota()` passou a aplicar a regra da tela do ERP —
`ProdutosController::selecionarProduto()`: a unidade da sessão, ou
administrador. Fora dela, **404**, a mesma resposta de "não existe", para não
contar que o id existe. Não há produto sem unidade na base (0 de 9.509), então a
recusa do nulo não esconde catálogo compartilhado.

**E eu tinha parado aí.** Ao montar esta tabela a partir das rotas do
`module.config.php`, e não de memória, apareceram `ativacao` e `desativacao` —
que **não** passam por `produtoDaRota()`: `mudarEstado()` tem a guarda inline de
propósito, porque o `conferir-acl-da-api.php` segue um nível só. **Medido antes
de consertar, sem gravar:** o `user8` pediu `ativacao` do produto 99, que já
estava ativo, e recebeu **409 "já está ativo"** — tinha passado pelo recorte; o
`desativacao` teria gravado. `mudarEstado()` ganhou o mesmo recorte, inline.

**Medido depois:** `user8` pelo produto 99 → **404** em ver, `ativacao` e
`desativacao`; pelo 909, que é da unidade dele → **200** em ver e **409** em
`desativacao` (já estava inativo). As duas linhas ficaram idênticas. A rota de
cancelamento já amarrava a movimentação ao produto; com o produto recortado, a
cadeia fecha.

**Portão:** `conferir-recorte-por-unidade.php` passou a vigiar `produtoDaRota`
**e** `mudarEstado`. Arrancando o recorte de `mudarEstado`, fica vermelho
nomeando-o; restaurado, sha1 idêntico.

Os convênios têm um `mudarEstado` sem recorte, e **não** é o mesmo defeito: o
convênio é da rede — pertence a várias unidades —, e a tela do ERP também ativa
e desativa sem recortar. A regra não muda de lugar.

**Fica em aberto:**
- não há teste de contrato para "produto de outra franquia dá 404" — o caso pede
  duas unidades com produto cada no banco de contrato;
- o `PUT` grava `quantidade` absoluta **sem linha em `produtos_estoque`** — agora
  só na própria franquia, mas sem rastro;
- a porta é POST sem token — o D198.

#### A prova das sete rotas — CORRIGIDO em 05/10/2026

O conserto tinha sido medido por HTTP, com um usuário de verdade, e ficou sem caso que
ficasse vermelho. `tests/Caracterizacao/D201CatalogoDeOutraFranquiaTest.php` despacha o
`Adm\ProdutosController` de verdade em processo (`ControllerEmProcesso`, o molde do
`EstoqueSobTravaTest`), com o corpo em JSON, no banco de teste.

**As sete rotas são as do `module.config.php`** (`adm-api-produtos/um`), derivadas dele e
não de memória — foi a memória que deixou `ativacao` e `desativacao` fora do primeiro
conserto. **O produto de outra franquia é de prateleira**, criado no caso na unidade
90001150 com uma entrada de estoque: o único da fixture fora da 90001000 é o serviço
90001150, e serviço já leva 404 nas três rotas de estoque pelo tipo (D171) — com ele,
arrancar o recorte deixaria as três verdes.

| caso | afirma |
|---|---|
| as sete rotas, uma a uma, no produto da outra franquia | 404 `nao_encontrado` com a frase de sempre, e o produto e o estoque dele iguais antes e depois (o `ativar` começa com o produto desativado, para que arrancar o recorte apareça como escrita, e não só como 409) |
| a outra franquia e o id que não existe | o mesmo status e o mesmo corpo, em leitura e em escrita |
| o produto da minha unidade | `ver` e `estoque` respondem |
| o administrador | alcança o da outra, nas duas guardas: lê e desativa, gravando |

**Falsificado**, um mutante por vez e restaurado byte a byte: sem o recorte de
`produtoDaRota()`, as cinco rotas dele e o caso da mesma resposta ficam vermelhos — e
gravavam de verdade: o `PUT` trocou o nome, a entrada levou o saldo a 13 e o
cancelamento o devolveu a 5; sem o recorte de `mudarEstado()`, `ativacao`, `desativacao`
e o caso da mesma resposta.

**O que estava "em aberto" acima, relido hoje:**

- o caso por HTTP no banco de contrato não foi escrito: o despacho em processo exercita a
  mesma ação, e é ele que fica vermelho sem o recorte;
- o `PUT` com `quantidade` já é recusado — "A quantidade muda pelo estoque, e não
  aqui." (`RegraDoCatalogo`, desde o 6697402d, o dos D235, D236, D238 e D239; o
  `RegraDoCatalogoTest` prende);
- a porta de escrita da `/adm/api` passou pela barreira de origem do
  `ApiAdmController::onDispatch` (29/09/2026), e o token da sessão é o D198, que tem
  registro próprio.

### D202 — Seis telas de EDITAR conferem a franquia de um id e gravam outro — CORRIGIDO em 26/09/2026, as seis; a fiação dos controllers conferida por leitura — CORRIGIDO em 05/10/2026

> **05/10/2026:** a fiação das seis telas, que estava "conferida por leitura", passou a ter caso que fica vermelho sem ela — o controller despachado em processo, com o formulário de verdade, a VIP Class inclusive (a seção "A fiação, pelo controller", no fim deste débito; a de usuários, no D204).

**Achado por um adversário** do D199, fora do alvo dele, numa tela; **a classe
foi medida depois**, por leitura. **Não medido por HTTP**: medir é gravar, e
gravar no banco de desenvolvimento não está autorizado.

`ProdutosController::editarAction()` recorta pela franquia o produto do id da
**rota** (`selecionarProduto`), e depois entrega ao serviço o **POST cru**:

```php
$data = $request->getPost()->toArray();
...
$this->sm->get(ProdutoService::class)->update($data);
```

`ProdutoService::update()` faz `getReference(Produto::class, $data['id'])` — o id
do **formulário**, que é um `Hidden('id')` — e `ClassMethodsHydrator` com o POST
inteiro. Dois defeitos na mesma linha:

- **o id é o do POST**: quem pode editar uma linha da própria franquia troca o
  campo oculto e grava uma linha de **outra**;
- **o POST cru vai ao hidratador**: toda chave com setter é gravada, não só as do
  formulário — o `$form->isValid()` valida, e ninguém usa o `$form->getData()`.

##### A classe

Quinze serviços pegam a entidade pelo `id` do arranjo que recebem. Seis ações do
ERP lhes entregam o POST cru, e nenhuma força o id — todas conferem o id da rota
e gravam o do POST:

| ação | o que se grava na linha de outra franquia |
|---|---|
| `UsuariosController::editarAction` | **login, e-mail e grupo de qualquer usuário** — ver o D204 |
| `ProdutosController::editarAction` | nome, preço de venda, de compra, quantidade |
| `ServicosController::editarAction` | nome, preço, área, etiqueta |
| `PacotesController::editarAction` | o pacote — a tela que o D142 consertou pelo id da rota |
| `ProdutosTagsController::editarAction` | etiqueta do catálogo |
| `VipClassAdminController::editarAction` | conteúdo do VIP Class |

A varredura só vê a forma `$data = $request->getPost()->toArray()` seguida de
`->update($data)`: é **piso**. Os outros nove serviços podem ter chamador com
outro nome de variável.

**E era piso mesmo:** a revisão do D205 achou uma sétima —
`SaidasController::identificacaoAction`, que entrega o POST direto a
`updateIdentificacao()`, sem passar por variável. Ver o D205. **Consertada no
D206.** E a de usuários, em 26/09/2026, junto com o D204.

##### O conserto das outras cinco, 26/09/2026

Cada serviço ganhou um `atualizar(<entidade>, $data)` que grava na entidade que
o controller conferiu pela rota; o `update($data)` que pegava a linha pelo `id`
do arranjo saiu. Onde havia hidratador sobre o POST inteiro (produto, serviço,
etiqueta), ele passou a ver só os campos que a tela mostra
(`CAMPOS_DA_EDICAO`, lidos das views) — com os mesmos valores e os mesmos
setters de antes, para a tela gravar o que gravava. O pacote e o VIP Class já
gravavam campos explícitos: neles mudou só a linha.

**Provado:** `tests/Caracterizacao/EdicaoDoCatalogoTest.php`, 4 casos — produto,
serviço (com a linha de OUTRA franquia no arranjo), pacote e etiqueta. Mutações:
a lista arrancada no produto e no serviço e a linha do arranjo de volta no
pacote, as três vermelhas pelo caso delas. A lista arrancada na etiqueta passa
verde, e é equivalente: a etiqueta só tem `nome` e `status`, e o `setId` numa
entidade já carregada não muda a linha do UPDATE.

**O que não foi medido:** o VIP Class (não há fixture dele), e a fiação dos
cinco controllers — a entidade da rota chegando ao serviço — por pedido HTTP.
Conferida por leitura, como a do D204.

**O D142 fechou metade:** conferir o id da rota foi o conserto de 09/09, e ele não
bastava — o id que chega ao banco é outro. É o mesmo erro do precedente: olhar se
há guarda, e não se ela guarda **o que é gravado**.

**O conserto é curto** — o id sai da entidade recortada, e o que vai ao serviço é
o que o formulário filtrou —, mas pede medição, e medir pede autorização para
gravar no `franquiabv_dev` ou um caso num banco descartável.

##### A fiação, pelo controller — 05/10/2026

O impedimento caiu: o `ControllerEmProcesso` despacha o controller do ERP no banco de
teste, dentro da transação que o `tearDown` desfaz. **A lista das telas saiu do grep, e
não da memória:** `D202` nos controllers do ERP devolve as cinco do catálogo, a de
usuários (o D204) e a de convênios — que não era da tabela, foi reescrita na tela nova
dos convênios (D255 a D259) e só o administrador edita. E o grep da forma
`->update(...)` com o POST nos controllers do ERP acha mais duas de pé, e nenhuma é a
forma deste débito — a rota delas também não recorta, então o campo oculto não alcança
nada que a rota não alcance: `UnidadesController::editarAction`, já desenhada como o U10
da tela de unidades (`docs/f3-telas-restantes/tela-unidades.md`; a unidade é da rede), e
`ClientesController::editarAction` (o cliente é da rede, o D145).

`tests/Caracterizacao/D202EdicaoPelaRotaNoErpTest.php`, **12 casos** (11 deste débito e
um das listas que o controller de serviços dá ao formulário — o D177), verdes nos dois
bancos. O POST é o da tela: o formulário de verdade preenchido pela linha da rota
(`setFormData`), com o token dele.

| caso | telas | o que prova |
|---|---|---|
| o campo oculto não escolhe a linha | as cinco | a rota é a minha, o `id` do POST é o de uma linha alheia — de outra franquia onde há franquia, outra etiqueta ou outro curso onde a linha é da rede —; 302, a da rota recebe o nome novo, a alheia fica como estava (nome e data de alteração) |
| a rota de outra franquia é 404 | produtos, serviços, pacotes | no GET e no POST, e a linha não muda |
| o controle | as mesmas três | o administrador edita a linha da outra franquia — a recusa é da franquia, e não de todo mundo |

As linhas alheias de produto, pacote, etiqueta e curso nascem no caso: o único item do
catálogo fora da 90001000 na fixture é o serviço 90001150, e a tela de produtos e a de
pacotes dão 404 para ele **pelo tipo** — com ele, arrancar o recorte ficaria verde. A VIP
Class, que este débito deixou sem medida porque "não há fixture dele", tem hoje o curso
90001000 da fixture; o segundo nasce no caso, e nenhuma linha entrou no `fixtures.sql`.

**Oito mutantes, um de cada vez, restaurados pelo sha1, cada um vermelho só pelo caso
dele:** nas cinco telas, a entidade passada ao serviço trocada pela do `id` do POST
(`getReference(..., $data['id'])`, a forma de antes) — a etiqueta inclusive, que o
mutante de 26/09 (a lista de campos arrancada) não distinguia; e o recorte arrancado de
`selecionarProduto`, de `selecionarServico` e do `daMinhaUnidade` do editar de pacotes.
(E quatro do caso das listas, dois em cada repositório — o filtro de `status` trocado e,
desde a revisão de 05/10/2026, arrancado; o arrancado das áreas ficava verde até o caso
ganhar a área desativada —, registrados no D177.)

**O que isto não é:** um pedido HTTP. O despacho em processo passa pela ação, pelo
formulário e pelo serviço de verdade; não passa pelo roteador nem pelas guardas de
despacho, que não são deste débito.

### D203 — As telas do ERP imprimem texto do banco sem escape, e isso anula a proteção do POST — PARCIAL em 04/10/2026: os campos de texto livre (26/09), as cinco telas que o convênio e o pacote apontaram, a forma `$x['campo']` em 49 telas e o que elas refletiam da URL; fica a forma do getter fora delas — PARCIAL em 19/10/2026: o medidor vê toda forma, e vigia as views das etiquetas — PARCIAL em 08/11/2026: o troco do PDV e os detalhes do caixa escapados, o PDF lido; fica a forma do getter nas telas que a fila troca

**Achado por um adversário** do D199, na tela que eu tinha acabado de converter.

O motivo de uma retirada de caixa é texto livre digitado por quem opera o caixa.
`CaixaService.php:80` grava o que veio no POST, e ele é impresso **cru** em
`caixas/fechar.phtml:122` e no relatório do gerente
(`ISRelatorio/…/CaixasController.php:168` e `:175`, que monta HTML por
concatenação).

**Por que importa agora:** um script na própria origem envia o formulário POST
que o D199 acabou de pôr no lugar do link — a proteção cai inteira. E, sem
`HttpOnly` (D198), o mesmo script lê o `PHPSESSID`.

**É latente:** nenhum dos **22.816** motivos gravados tem `<`.

**É uma classe, não um caso:** pelo menos **122 getters impressos sem escape em
37 das 171 telas**. É piso — a régua só vê a forma mais simples,
`<?php echo $x->getY(); ?>` — e nem todo getter é texto livre. As telas têm 78
chamadas de escape no total.

**O caminho:** as telas serão substituídas na Fase 3, e o React escapa por
padrão; até lá, os campos de **texto livre** primeiro — motivo, observação, nome
digitado — e não os 122 de uma vez.

##### O primeiro passo, 26/09/2026

Os campos de texto livre impressos crus pela forma `echo $x->getY();` com os
getters de texto livre (`getMotivo`, `getDescricao`, `getObservacao`,
`getJustificativa`, `getMensagem`) eram seis, em cinco telas: o motivo da
retirada no fechamento do caixa, a descrição do histórico de pontuação do
cliente, e as descrições das aulas e dos materiais do VIP Class (na tela do
aluno e na do administrador). Os seis passaram por `escapeHtml`, e o relatório
do gerente, que monta o HTML por concatenação, passou a escapar o motivo nos
dois lugares.

Medido na cópia, antes de escapar, para saber se algum desses textos guarda HTML
de propósito: nenhum. Os únicos com `<` ou `>` são texto comum — um "<3" em 3
descrições de pontuação e um ">" num motivo —, que escapados aparecem iguais.

**O que não foi feito:** as telas não foram abertas no navegador (o helper é o
mesmo das 78 chamadas de escape que já existiam); e os outros getters impressos
crus continuam — a busca foi só pelos de texto livre.

##### A lista e a tabela dos pacotes, 28/09/2026

Na fatia da tela nova dos pacotes: a API grava o nome que vier, `<` inclusive -- o
nome é texto, e quem o imprime é que o escapa --, e a tela antiga o imprimia cru em
três lugares: na lista de pacotes (o link de quem escreve e o texto de quem só lê) e
na tabela de serviços do pacote. Os três passaram por `escapeHtml`, e um caso
(`TelasDosPacotesDoErpTest`) renderiza as duas telas pelo `ViewRenderer` da
aplicação e confere o HTML que sai.

##### A lista dos convênios, 28/09/2026

Na fatia da tela nova dos convênios, a lista antiga passou a escapar o nome (o D254).
**O nome do convênio continua saindo cru em quatro telas** -- conferidas uma a uma,
e nenhuma é desta fatia: `vendas/tabela-produtos.phtml:7` (o motivo do desconto,
dentro de `title='...'`, onde um apóstrofo já basta), `agendas/tabela.phtml:62` (o
balão do horário), `vendas/detalhes.phtml:64` e `clientes/historico-consumo.phtml:99`.
Quem grava o nome é o administrador (o D255), e a API recusa `<` e `>` nele (o D250);
os nomes antigos foram medidos: nenhum tem `<` ou `>`.

**01/10/2026 -- mais uma tela, achada pelo D314:** a aba de pacotes do cliente
(`clientes/pacotes.phtml`, linhas 62 e 63) imprime cru o nome do pacote e o do serviço. Importa
mais agora: desde o D314 a página tem o formulário POST da remoção, e é exatamente o que o título deste
débito diz que o texto cru anula. Continua aberto, na classe.

##### 04/10/2026 -- as cinco telas apontadas, e a forma `$x['campo']`

**As cinco telas** -- a aba de pacotes do cliente, o carrinho do PDV (`vendas/tabela-produtos.phtml`), a
grade da agenda (`agendas/tabela.phtml`), os detalhes da venda e o histórico de consumo do cliente --
passaram a escapar todo texto digitado que imprimem, nas duas formas, e não só o que os achados
citavam: 36 pontos. Três contextos pediram mais que o `escapeHtml`:

- **o motivo do desconto no carrinho** vai dentro de `title='...'`, onde um apóstrofo fechava o
  atributo: `escapeHtmlAttr`;
- **o balão do horário na agenda é HTML** -- as `.tooltips` da grade nascem com `tooltip({html: true})`
  (`mod-cadastros.js`). Cada texto (cliente, telefone, serviço, convênio, observação; e a observação da
  agenda fechada) passa por `escapeHtml`, para ser texto dentro do balão, e o balão inteiro por
  `escapeHtmlAttr`, para ser texto dentro do atributo. Um escape só não basta: o navegador desfaz o do
  atributo antes de o balão ler o HTML. Os ícones e as quebras do balão continuam. **A regra vale para
  o balão que chega por AJAX e é inicializado com `html: true`, e não para toda `.tooltips`** (medido
  na verificação; ver abaixo);
- nos detalhes e no histórico entraram também os **dois motivos** (o do cancelamento e o do desconto) e
  os nomes -- `getCanceladoMotivo` e `getDescontoMotivo` são texto livre, e a lista de getters de
  26/09 não os tinha.

**A forma `$x['campo']`, que a régua dos 122 não via.** Uma régua nova (só leitura) lê os blocos
`echo`, `print` e `<?=` das views do ERP e conta cada `$x['campo']` (ou `$x->m()['campo']`) que não
está dentro de escape ou de formatação (máscara de dinheiro, data, `count`, `url`...). **No HEAD:
508 acessos em 64 das 166 telas.** Filtrados pelo nome do campo -- fora ids, somas, médias, datas e
ordenações --, **195 linhas com cara de texto**; lidas uma a uma, **132 eram texto digitado e foram
escapadas** (140 acessos, em 48 telas, mais os 15 das cinco acima) e **63 não eram**: caminho de arquivo
que o sistema gera (avatar, capas, banner), cor, ids e horas, o texto fixo da grade ("Agenda
fechada"), o link e a situação do Asaas nas faturas, HTML montado de propósito (as perguntas da
pesquisa NPS, os detalhes do relatório de caixas), as tabelas-semente do teste de estilo e do
visagismo, os nomes de grupo e de funcionalidade (que nenhuma tela edita), rótulos que uma função
devolve, e os números do relatório de NPS. Hoje a régua conta 353, e nenhuma tela subiu. **As outras
313 foram descartadas pelo nome do campo, sem leitura** -- e havia texto digitado entre elas (a
verificação, abaixo).

Entre as 48, as que mais importavam: a **página pública da pesquisa NPS** (o apelido do cliente e o
nome do colaborador; o cliente a abre sem login), a lista de **bloqueios** da agenda (o motivo, na
mesma página do formulário POST do D196), as listas de **clientes** e de **usuários** (o nome também nos
atributos `alt` e `title`, com `escapeHtmlAttr`), o **layout** (o primeiro nome de quem entrou, em toda
página), e o requisito do **VIP Class do aluno**, que vai numa string de JavaScript e que
`vipClassEduModalRequisito` põe no modal com `.html()`: escapado para HTML e, por cima, para
JavaScript (`escapeJs`).

**Provado:** `tests/Caracterizacao/D203TextoDigitadoNasTelasDoErpTest.php`, 9 casos, verdes nos dois
bancos: as cinco telas e quatro da varredura (clientes, usuários, VIP Class do aluno, pesquisa NPS)
renderizadas pelo `ViewRenderer` da aplicação, com o texto trocado no banco das fixtures dentro da
transação; o balão é lido como o navegador o entrega (o atributo desfeito pelo `DOMDocument`), e nele o
texto chega escapado uma vez. **Dezesseis mutantes, um de cada vez, restaurados por sha1, todos
vermelhos:** cada escape arrancado (o nome do pacote, o produto e o motivo do carrinho, os três da
agenda -- o de dentro, o de fora e o da agenda fechada com um só --, o cliente, o convênio e o motivo
dos detalhes, o convênio e o motivo do histórico, o nome da lista de clientes, o `title` e o `alt` da
de usuários, o requisito só com `escapeJs`, o apelido da pesquisa). **Não foi visto no navegador**: as
telas são antigas, e os casos as renderizam em processo. As outras 44 telas da varredura não têm caso
próprio: a troca é a mesma, e o `php -l` passou em todas.

##### 04/10/2026, a verificação -- o que as réguas não viam

Um revisor leu a varredura e achou nove coisas; conferidas uma a uma no código, oito estavam certas e
uma, em parte, não. Os consertos:

- **Os treze balões "MELHOR DA REDE" do painel de colaboradores** (`dashboard-colaboradores/index.phtml`)
  imprimiam crus o nome do colaborador e o estado, a cidade e o bairro da unidade num
  `data-original-title` -- 52 acessos que a régua descartou pelo **primeiro** índice
  (`['totalVendas']['nome']`, com cara de soma). Um nome com aspa fechava o atributo na tela do
  administrador. Passaram por `escapeHtmlAttr`; o nome no título de cada unidade (a forma do getter), por
  `escapeHtml`; e o mês do extrato, que vem da URL e vai numa string de JavaScript, por `escapeJs`.
- **O link do WhatsApp no relatório do consultor visagista** saía com o telefone escapado no texto e
  cru no `href` -- por uma variável intermediária (`$url`), que nenhuma régua via. `escapeHtmlAttr`.
- **A data da URL na agenda do dia.** `AgendasController::indexAction` lê `?datasolicitada=` sem validar
  e a tela a imprimia crua no título -- a página inicial da maioria dos operadores, aberta por um link.
  E o mesmo valor chega à grade: o campo da tela o leva no pedido da tabela, e `agendas/tabela.phtml` o
  imprimia cru no `data-day` e em duas strings de JavaScript dentro de `onclick`. O título passou por
  `escapeHtml`, o `data-day` por `escapeHtmlAttr` e as strings por `escapeJs` (que não deixa aspa nem
  `&` para o atributo desfazer -- um `escapeHtmlAttr` ali seria desfeito antes do JavaScript).
- **As ordenações da URL** (`?sort_nome=` e as irmãs) iam cruas para o `class` do ícone da coluna na lista
  de convênios (5) e no relatório de clientes (7): um link com `"><img ...>` montava o elemento. A régua
  as descartou como "ordenações" pelo nome. `escapeHtmlAttr`. Um grep de `$request[` em todas as views do
  ERP não acha outra.
- **As cores do colaborador na grade da agenda** são um campo de texto do cadastro, sem regra no filtro
  (10 caracteres), e iam cruas no `style`. `escapeHtmlAttr`. A varredura as tinha contado como "cor".
- **A forma do getter em telas que a varredura tocou** -- o nome do cliente nos horários marcados, o
  título na lista de pesquisas (duas vezes, na página do formulário POST do D199), o nome do colaborador
  no painel e o nome da VIP Class no relatório: `escapeHtml`. Uma régua dos getters de texto nas 54 telas
  tocadas não acha mais nenhum cru -- só ids e números.

**O que estava errado no achado:** ele dizia que o `html: true` do `geral.js` vale para toda `.tooltips`
do ERP, e que por isso todo balão pede o escape duplo da agenda. **Medido no Chrome**, numa página com os
41 scripts do layout na ordem dele: a `.tooltips` que chega com a página sai com `html: false` e mostra
o texto como texto -- o `App.init` do Metronic (`app.min.js`, carregado antes do `geral.min.js`)
inicializa todas sem opção, e o Bootstrap 3.3.7 ignora as opções da segunda chamada sobre o mesmo
elemento. A que chega depois por AJAX e recebe `.tooltip({html: true})` -- a grade da agenda, pelo
`mod-cadastros.js` -- sai com `html: true` e monta o HTML. **A regra:** balão que nasce com a página,
`escapeHtmlAttr` só (um escape a mais mostraria `&amp;` no balão); balão que chega por AJAX e é
inicializado com `html: true`, os dois escapes. Os balões do painel são do primeiro tipo, e o caso
confere que o atributo, desfeito, é o texto inteiro -- o escape duplo fica vermelho.

**Duas réguas a mais, só leitura, em todas as views do ERP.** Pela **última** chave com nome de texto
(nome, cidade, bairro, motivo, telefone, `sort_*`...), e não pela primeira: 80 acessos crus em 13 telas
antes destes consertos, e 14 em 10 depois, nenhum deles texto digitado -- um id, o texto fixo da grade,
um `htmlspecialchars` que a régua não reconhece, a descrição que o sistema monta nas faturas, as
tabelas-semente do teste de estilo, os nomes de grupo e de funcionalidade, e uma nota. Pela **variável
simples** (`echo $x`): além da data e do mês acima, dois textos digitados ficaram de fora (ver "O que
fica"); o resto é valor do sistema (URLs montadas, a legenda do radar, que o controller monta de
inteiros, ids, a mensagem da página de erro).

**Provado:** o mesmo arquivo de casos, agora com 19 execuções, verdes nos dois bancos: o painel (os
treze balões lidos como o navegador os entrega, o título e o mês), o visagista, a agenda do dia
(despachada com `?datasolicitada=` hostil e renderizada), a grade (a data no `data-day` e nos
`onclick`, as duas cores, o primeiro nome do colaborador no cabeçalho e o ramo do primeiro nome do
cliente), os horários marcados, a lista de pesquisas (quem escreve e quem só lê), o relatório da VIP
Class e as ordenações das duas listas (despachadas com a URL hostil). **O achado de cobertura** também
estava certo: só ficava vermelho o campo que o caso gravava. Agora cada campo das cinco telas tem a sua
marca (`<u>telefone</u>`, `<u>forma</u>`...): telefone, e-mail, CPF, unidade, operador, colaborador do
item, forma de pagamento e quem autorizou o fiado nos detalhes (a venda vira fiado no caso); unidade e
colaborador no histórico; o telefone na lista de clientes. **Trinta e um mutantes, um de cada vez,
restaurados por sha1, todos vermelhos:** cada escape novo arrancado (balão, título e mês do painel,
`href` do visagista, data da agenda do dia, as duas cores, o `data-day` e as duas strings da grade, o
nome nos horários marcados, os dois títulos da pesquisa, o nome da VIP Class, uma ordenação de cada
lista), o balão do painel com o escape duplo, a data do `onclick` só com `escapeHtmlAttr`, e os onze
escapes do implementador que nenhum caso via (os oito dos detalhes, os dois do histórico, o telefone
da lista), mais o primeiro nome do colaborador e o ramo do primeiro nome do cliente sem o escape de
dentro. **Nada disto foi visto numa tela do ERP no navegador**: só o comportamento do balão foi medido
no Chrome, numa página feita com os scripts do layout; os casos renderizam as views em processo.

**Um portão mudou:** o `conferir-cobertura-por-par.php` ficou vermelho com o caso novo -- `fidelizacao`
(uma das treze métricas do painel) e a entidade `Cliente` no mesmo arquivo fizeram o par
`ClienteRepository::fidelizacao` parecer coberto, e o caso não chama esse repositório. O arquivo entrou em
`COINCIDENCIAS`, ao lado do `ProfissionalParceiroTest`, com o motivo; sem a entrada, o portão fica
vermelho, e com ela, verde.

**O que fica:**
- **a forma do getter fora das 54 telas tocadas:** a régua de 26/09 (`<?php echo $x->getY(); ?>`)
  contava **108 em 33 telas** antes da verificação (110 no HEAD), e não foi refeita depois dela; nem todo
  getter é texto livre, e a varredura deles fica para as fatias das telas. Entre eles, o extrato do
  plano de carreira (`extrato-plano-carreira.phtml`), que o painel abre num modal: o nome do
  colaborador e o da unidade, crus;
- **dois textos digitados na forma da variável simples**, que nenhuma régua via: o nome de quem entrou
  na saudação do consultor visagista (`visagismo/index.phtml`, `$usuarioNome`) e os nomes dos
  colaboradores no seletor do troco do PDV (`vendas/tabela-pagamentos.phtml`, `$colaborador`) --
  achados pela régua da verificação, fora das telas tocadas e sem caso; ficam para as fatias;
- **o link das notificações** é digitado pelo administrador e vai no `href`: `escapeHtmlAttr` impede
  que ele saia do atributo, mas não recusa `javascript:` -- isso pede validar o esquema ao gravar;
- **os detalhes do relatório de caixas** são HTML montado no controller, com o nome da forma de
  pagamento (tabela do sistema) -- a forma ali é a concatenação, não a view;
- **o PDF do histórico de consumo** (`PdfClienteHistoricoConsumo`) não foi lido.


#### 19/10/2026 -- o medidor para toda forma (a fatia ETIQUETAS-ERP; o D344)

As réguas de 26/09 e de 04/10 foram rodadas uma vez, a mão, e filtravam pelo nome do campo. Ficou de pé um portão:
`scripts/conferir-escape-nas-views.php`. Ele lê o PHP pelos tokens, e por isso não conta o comentário. Em cada `echo`, `print`
e `<?=`, e em cada argumento das funções que imprimem (`printf`, `vprintf`, `print_r`, `var_dump`, `var_export`; desde a
verificação de 19/10/2026), ele olha o que de fato é impresso:

- **passa:** o literal; o que tem molde de número (`(int)`, `count`, `number_format`...; não o `max` e o `min`, que devolvem o
  próprio argumento, nem o `json_encode`, que não escapa o `<` sem as flags `JSON_HEX_*`); os ajudantes que escapam ou desenham
  o formulário (`escapeHtml`, `escapeHtmlAttr`, `escapeJs`, `url`, `formElement`, `partial`...); e os dois ramos do ternário,
  porque a condição não é impressa;
- **fica vermelho:** qualquer variável impressa fora disso, nas três formas -- `$x['campo']`, `$x->getY()` e `$x`. O id também:
  quem imprime id escreve `(int)`, e a régua não adivinha pelo nome.

As views vigiadas são uma lista fechada, que cresce com as fatias das telas. Hoje são as quatro das etiquetas
(`produtos-tags/`, 76 impressões), e nelas o id do Gerenciar ganhou o `(int)`. O `--todas` mede todas as views do ERP sem
reprovar: em 19/10/2026, **4.503 impressões em 166 views, 550 cruas em 96 views** (eram 549 antes da verificação: o
`json_encode`, o `max` e o `min` deixaram de passar, e nenhuma view do ERP chama `printf`). Esse número não é de texto digitado: entra
nele o id, a soma e a data montada à mão; a leitura de cada uma é das fatias.

**Falsificado:** com o `escapeHtml` do nome do item arrancado, com o `(int)` do id arrancado e com a unidade impressa por um
getter cru, o portão fica vermelho (saída 1); na verificação, também com o nome do item impresso por `printf('%s', ...)`, por
`\printf(...)`, por `var_dump`, por `json_encode` e por `max`. Ele não vê o HTML montado no controller, o escape errado para o
contexto nem o `.js`.

#### 08/11/2026 -- o que o corpo deixou nomeado (a fatia 120, ERP-ANTIGO-ESCAPE-E-MENSAGENS)

Os cinco itens que o "O que fica" de 04/10/2026 nomeava, conferidos um a um no código antes de mexer:

- **os nomes no seletor do troco do PDV** (`vendas/tabela-pagamentos.phtml`), crus: CONSERTADO. O nome de cada
  colaborador e o da forma de pagamento saem por `escapeHtml`, os ids por `(int)`, e os valores por `escapeHtml` em volta
  da máscara. A view entrou em `VIEWS_VIGIADAS` do `scripts/conferir-escape-nas-views.php` (12 views, 185 impressões, todas
  por escape, número ou literal). A tabela chega por AJAX ao modal de finalizar, e é desenhada pelo serviço de verdade no
  caso (`VendaPagamentoService::tabelaPagamentos`, com o troco posto na venda 90001211 das fixtures).
- **o extrato do plano de carreira** (`extrato-plano-carreira.phtml`): JÁ NÃO EXISTE. A view saiu com o Dash.
  Colaboradores antigo em 13/10/2026 (o commit `a0d9e1ec`, a resposta 137 do dono); `git grep` não acha mais o nome.
- **o link das notificações** com `javascript:`: JÁ FEITO PELO D605 (25/10/2026). O formulário antigo e a API gravam só
  `http://` ou `https://`, aparado e sem espaço (`RegraDaCampanha::linkSeguro`), e a lista da equipe só faz `href` do que
  abre (`hrefDaLista`). Medido na cópia (só contagem): 610 links gravados, 20 que não começam por `http(s)://` -- `@barbe...`
  (12), `www.` (3), `wa.me/` (2), `bit.ly` (2), `whats.` (1) --, nenhum `javascript:`; eles continuam gravados como estão
  (o D605 não os muda), e a lista os mostra como texto.
- **os detalhes do relatório de caixas** (`ISRelatorio\Controller\CaixasController::getCaixas`), HTML montado por
  concatenação e impresso cru pela view: CONSERTADO. O nome da forma de pagamento vai por `htmlspecialchars`, como o motivo
  das retiradas na mesma classe. É da tabela do sistema, e nenhuma tela o edita (a `/adm/api/formas-pagamento` só lê): o
  conserto é de defesa, e o caso o vigia.
- **o PDF do histórico de consumo** (`PdfClienteHistoricoConsumo`): LIDO, nada a consertar. Não é HTML: o texto vai ao FPDF
  1.86, que o põe numa string do PDF (`(texto) Tj`) e escapa o parêntese, a contrabarra e o CR (`Fpdf::_escape`). O caso
  confere o nome do cliente com `) Tj (` e uma contrabarra saindo como uma string só. (O `Output` da casa termina o processo
  depois de mandar o PDF, o D107: o caso pede os bytes no modo `S`.)

**Provado** (nos dois bancos): três casos novos -- `D203TextoDigitadoNasTelasDoErpTest::testOSeletorDoTrocoNoPdvEscapaOsNomes`
(uma marca para o colaborador e outra para a forma), `::testOPdfDoHistoricoDeConsumoEscreveOTextoComoTexto` e
`RelatorioDeCaixaNoErpTest::testODetalheDoCaixaEscapaONomeDaForma`. Mutantes, um de cada vez, devolvidos pelo sha1: o escape
do colaborador arrancado e o da forma arrancado (o caso e o portão vermelhos), o `(int)` do id do colaborador arrancado (o
portão vermelho) e o `htmlspecialchars` dos detalhes do caixa arrancado (o caso vermelho). O PDF não tem mutante na casa: a
proteção é do FPDF. **Não visto no navegador.**

**O que fica (a marca nova):** a forma do getter nas telas que a fila das telas que faltam vai substituir -- o `--todas` do
portão mede, em 08/11/2026, 489 impressões cruas em 78 das 151 views (eram 550 em 96 em 19/10/2026; as telas que saíram
levaram as delas), e cada fatia de tela põe a sua view na lista ou a aposenta -- e o `visagismo/index.phtml` (o
`$usuarioNome` da saudação), fora de escopo.

### D204 — Quem pode editar usuário põe qualquer usuário, inclusive a si mesmo, no grupo de ADMINISTRADOR — CORRIGIDO em 26/09/2026, a fiação do controller conferida por leitura — CORRIGIDO em 05/10/2026

> **05/10/2026:** a fiação do controller, que estava "conferida por leitura", passou a ter caso que fica vermelho sem ela, um por elo da cadeia (a seção "A fiação, pelo controller", no fim deste débito).

**Achado ao medir a classe do D202.** Conferido por leitura; **não medido por
HTTP**, pelo mesmo motivo. É o débito mais grave aberto nesta data.

##### A cadeia, linha por linha

1. `UsuariosController::editarAction` exige `configuracoes/usuarios`/`escrever`
   e que o usuário **da rota** seja da franquia de quem pede. O próprio usuário é —
   então qualquer um com o privilégio pode editar a si mesmo.
2. O formulário `UsuarioDados` oferece a um não administrador só os grupos
   `1, 2, 3, 6, 8` (`GrupoRepository.php:36`). Mas o `select` é criado com
   **`'disable_inarray_validator' => true`**, e o filtro do campo `grupo` tem só
   `NotEmpty`. A lista é da **tela**; o servidor aceita **qualquer** id.
3. `UsuarioService::update()` faz `getReference(Grupo::class, $data['grupo'])` e
   `setGrupo()`. Com `grupo = 4` — "Gerente geral", `administrador = 1` —, o
   usuário vira administrador.
4. Grupo administrador passa em **toda** guarda (`Acl.php:29-30`), de **todas** as
   franquias, e alcança o `autenticarComo`.

E com o D202 a cadeia não precisa nem ser sobre si mesmo: o `id` do POST escolhe
**qualquer** usuário, de qualquer franquia. Trocar o **e-mail** de um
administrador basta, porque o "esqueci a senha" manda o link para o e-mail
cadastrado (`AutenticacaoController.php:84-91`).

##### Quem alcança

**107 usuários ativos não administradores, em 54 unidades**, têm
`configuracoes/usuarios`/`escrever`. O alvo mais valioso são os **10
administradores ativos**.

##### O que ainda não sei

- se `novoAction` — que também usa `UsuarioDados` e chama `insert($data)` —
  cria usuário **já** administrador; a leitura diz que sim, e não conferi o
  `insert` linha a linha;
- se algum registro de acesso mostraria a troca — o grupo não tem histórico.

**O conserto** tem duas partes, e as duas são curtas: o servidor passa a impor a
mesma lista que a tela oferece — a regra continua no `GrupoRepository`, só deixa
de ser só da tela —, e o `id` gravado passa a ser o do usuário recortado (D202).
**Pede medição antes de fechar**, e medir é gravar: autorização no
`franquiabv_dev`, ou o caso montado num banco descartável.

##### O conserto, 26/09/2026

Ao ler a cadeia para consertá-la, apareceu que ela era mais larga que o título:
a edição gravava **qualquer** campo com setter que chegasse no POST, e não só o
grupo — a senha inclusive. E o recorte de `editarSenha` e das outras ações sobre
um usuário era só a unidade: quem não é administrador alcançava, pela rota, um
administrador da própria unidade. Medido na cópia, só leitura: 8 dos 10
administradores ativos estão numa unidade com alguém que edita usuário (4
pessoas).

Três peças:

| peça | onde | o que garante |
|---|---|---|
| os campos | `UsuarioService::atualizarDados` e `inserir` | a edição grava no usuário que o controller conferiu, e só os campos que a tela mostra, um a um; o cadastro também. Nada de hidratador sobre o POST |
| as listas | `UsuarioDados` | o grupo e a unidade só aceitam o que `popularCombobox($sessao)` oferece — o validador da lista do Select vale junto com o do filtro (o `setInputFilter` desliga o `preferFormInputFilter`) |
| o teto | `UsuariosController::podeMexerEm` | a unidade (D148), e quem não é administrador não age sobre administrador — em toda ação do controller sobre um usuário. A listagem já os escondia (`gru.administrador = false`); a rota, não |

**Provado:** `tests/Caracterizacao/UsuarioDadosTest.php`, 5 casos — a edição
grava no usuário conferido e sem a senha, a unidade não se troca, o cadastro só
com os campos da tela, o grupo de administrador só para administrador, a
unidade do cadastro só a própria. Quatro mutações, cada uma vermelha pelo caso
dela: as duas listas de volta ao `disable_inarray_validator`, o hidratador de
volta na edição, e o construtor que hidrata de volta no cadastro (este passou
verde na primeira rodada, e o caso ganhou uma chave de fora da tela).

**O que não foi medido:** a fiação do controller — o teto e o usuário da rota
chegando ao serviço — por pedido HTTP. Está conferida por leitura.

**O D202 fechou no mesmo dia:** as outras cinco telas de editar (produtos,
serviços, pacotes, etiquetas e VIP Class) também passaram a gravar na linha da
rota — ver o D202.

##### A revisão adversarial do conserto, 26/09/2026

Três lentes (regressão do uso normal, alcance do teto, os testes), 9 agentes,
nenhum morto. O que ela confirmou e entrou no mesmo dia:

- **As ações auxiliares da tela de usuários** — a cota-parte, a linha de
  privilégio — conferiam o usuário e não amarravam a ele o registro que
  gravavam ou apagavam. Agora o registro existente tem de ser do usuário
  conferido, ou nada se grava. E as telas de agenda e de comissão passaram a
  hidratar só os campos que mostram. Dois casos novos no `UsuarioDadosTest` (7
  no total), e três mutações vermelhas.
- O docblock do teste citava um ensaio que não existe; corrigido.

O que ficou, anotado:

- **A mensagem da lista em inglês — corrigida no mesmo dia.** Com o grupo ou a
  unidade em "Selecione", a tela mostrava "The input was not found in the
  haystack" antes de "Campo obrigatório". O `SelectDaLista` para o vazio no
  "Campo obrigatório", e o que está fora da lista diz "Opção fora da lista.";
  um caso no teste, e a troca de volta para o `Select` comum o deixa vermelho.
- **O valor filtrado é o gravado.** A edição passou a gravar o que o filtro
  devolve (`StripTags`, `StringTrim`), e não o pedido cru: um cadastro com `<`
  ou `>` num campo perde o trecho ao ser salvo. Na cópia, 0 de 2.427 — mas os
  campos são reescritos pelo anonimizador, então a medida não vale para
  produção.
- **Fora desta tela:** a API nova de usuários (`GET /adm/api/usuarios/:id`) e a
  de privilégios do usuário não aplicavam o teto. Passaram a aplicar no mesmo
  dia (404, como o de outra unidade), vigiadas pelo portão do recorte; os seis
  ensaios da API seguem verdes no banco de contrato, que não tem grupo de
  administrador -- o teto nelas foi conferido por leitura.

##### A fiação, pelo controller — 05/10/2026

`UsuariosController::editarAction` despachado em processo (`ControllerEmProcesso`), no
banco de teste e dentro da transação desfeita, com o POST que a tela manda — o
formulário `UsuarioDados` de verdade, preenchido pela ficha da rota. O grupo de
administrador nasce no caso. `tests/Caracterizacao/D204EdicaoDeUsuarioNoErpTest.php`,
**4 casos**, verdes nos dois bancos, para cinco elos (o primeiro caso mede os dois
primeiros):

| elo | o caso | o controle |
|---|---|---|
| o usuário da rota chega ao serviço (o D202) | o `id` do campo oculto é o de um usuário de **outra franquia**: a edição vai ao da rota, e o outro fica como estava (nome, e-mail, login, grupo, unidade, situação, data) | — |
| o que se grava é o que o formulário filtrou | o nome com marcas e espaços sobrando é gravado sem eles (`StripTags`, `StringTrim`) | — |
| a franquia (o D148) | a ficha de outra franquia é 404, no GET e no POST, e não muda | o administrador a abre |
| o teto | quem não é administrador não abre nem grava a ficha de um **administrador da própria unidade** — 404, e ele fica no grupo dele | o administrador a abre |
| a lista de grupos | o grupo de administrador no POST de quem não é administrador volta com a página e o campo marcado, e não grava | o mesmo POST, do administrador, grava |

**Cinco mutantes, um de cada vez, restaurados pelo sha1, cada um vermelho só pelo caso
dele:** o serviço recebendo o usuário do `id` do POST; o POST cru no lugar do
`$form->getData()`; a franquia arrancada de `unidadePermitida`; o teto arrancado de
`podeMexerEm`; e o `select` de grupos de volta ao `Select` com
`disable_inarray_validator` (o formulário de antes — o `UsuarioDadosTest` já o media no
formulário, e agora a tela de editar também fica vermelha por ele).

**O que isto não é:** um pedido HTTP — não passa pelo roteador nem pelas guardas de
despacho. Mas o banco de contrato continua sem grupo de administrador, e o despacho em
processo é o que alcança o teto com um de verdade.

### D205 — Excluir a SAÍDA escondia parcela que já tinha recebido dinheiro: a regra do D135 tinha uma segunda porta — CORRIGIDO em 22/09/2026 — PARCIAL, lido em 05/10/2026 — PREPARADO em 13/10/2026 — CORRIGIDO em 04/11/2026: a fatia 78

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: o estorno ainda desliga a marca de quitada sem condição, inclusive a da parcela quitada sem pagamento.

**Achado por um adversário** na revisão do D199, e registrado lá como não
consertado. Decisão do dono: *"ataque o excluirSaida contornando o D135"*.

##### O defeito

O D135 fechou a porta da **parcela**: `SaidaService::excluirParcela()` recusa a
parcela que já recebeu dinheiro, por `SaidaParcela::temPagamento()` — as três
perguntas: a marca `quitada`, o acumulado `valor_pago` e o rastro dos pagamentos
ativos. E escreveu: *"uma parcela excluída nunca mais representará dinheiro que
saiu"*.

A porta da **saída** desmentia. As duas ações — `excluirSaidaAction` no ERP e
`POST /adm/api/saidas/:id/exclusao` na API — conferiam só
`totalParcelasQuitadas()`, que pergunta pela **marca**. E
`SaidaService::excluirSaida()` desativava toda parcela ativa sem perguntar nada.
Dinheiro lançado nem sempre liga a marca: pagamento parcial nunca liga, e
`parcelaQuitar()` só liga se a caixa "quitar" vier marcada.

**Medido na cópia:** das 13.238 saídas ativas, 12.904 já eram protegidas por ter
parcela quitada. **12 saídas, de 3 unidades (13, 31 e 51), só ficam protegidas
com a regra nova**, com R$ 8.308,79 pagos. E eu as descrevi errado na primeira
versão deste registro, como "pagamento parcial" — a revisão mediu cada uma:

| | saídas |
|---|---:|
| pagamento parcial | 4 |
| paga por inteiro, **sem** a marca de quitada | 7 |
| um pagamento ativo de **R$ 0** (a 13289) — as três perguntas discordam | 1 |

##### O conserto

- `Saida::totalParcelasComPagamento()` conta `temPagamento()` na coleção
  **crua**, pelo motivo do D133: o dinheiro saiu, esteja a linha ativa ou não.
- `SaidaService::excluirSaida()` **recusa** quando essa contagem passa de zero —
  a regra mora no serviço, por onde o ERP e a API passam, como no
  `excluirParcela()`.
- As duas ações passaram a **ler o retorno** do serviço, em vez de decidir por
  conta própria. A mensagem diz *"parcela(s) quitada(s) ou com pagamento"* — a
  primeira versão dizia só "com pagamento", e a revisão mostrou que isso era
  falso para 335 saídas cujas parcelas são só **marcadas** quitadas, sem dinheiro
  lançado. A guarda trata as duas iguais — a parcela foi dada por paga —; o texto
  agora diz as duas.
- A API publica `totais.parcelasComPagamento` — a contagem que decide — ao lado
  de `totais.parcelasQuitadas`, que continua lá porque é fato publicado.
- **E um defeito anterior, que o D205 passava a alcançar:** o construtor de
  `SaidaParcela` inicializava `$this->parcelas`, que não existe na classe, em vez
  de `$this->pagamentos`. Parcela criada com `new` ficava com a coleção de
  pagamentos **nula**, e `getPagamentos()` quebrava nela — exatamente na resposta
  de `POST /adm/api/saidas/:id/parcelas`, que monta a parcela na mesma requisição
  em que ela nasce. Consertado.

##### Provado

- **Vermelho antes, verde depois.**
  `testExcluirSaidaRecusaParcelaComPagamentoParcial` lança um pagamento parcial
  pelo `parcelaQuitar()` real e pede a exclusão: antes do conserto, **1 falha em
  677** — *"a saída com dinheiro pago tem de ser RECUSADA"*. E
  `testParcelaRecemCriadaNaoTemPagamentoENaoQuebra` falhou com
  `Call to a member function matching() on null` antes do conserto do construtor.
  Depois dos dois, **680 verdes no MySQL e 680 no PostgreSQL**.
- **Os testes pegam o que dizem pegar.** A revisão apontou que duas mutações
  passariam verdes — a guarda contar pela coleção **filtrada**, e o JSON publicar
  a contagem antiga no campo novo —, porque no cenário as duas contagens
  coincidiam. Dois casos novos separam as contagens, e **as duas mutações,
  aplicadas, deram 2 falhas**, uma em cada caso; restaurado por sha1.
- **Pela porta real, sem gravar nada:** como `user1431`, gerente da unidade 13,
  `POST /adm/api/saidas/12911/exclusao` (R$ 300 pagos, `quitada = 0`) respondeu
  **409**, e a saída, a parcela e o pagamento ficaram **idênticos ao backup** —
  mesmo sha1.

##### O limite

A guarda lê e depois grava, sem trava — o mesmo limite escrito no D199. Entre
duas sessões, um pagamento lançado entre a leitura e o laço ainda pode ser
desativado junto. Fechar pede conferir e excluir numa só instrução, ou trava de
linha.

##### A revisão adversarial

Três leitores independentes — as outras portas, o conserto, os dados — e um
cético por achado, instruído a derrubá-lo. **Treze agentes, todos com
resultado.** Dez achados foram verificados, e **nenhum foi derrubado**; cinco do
leitor do conserto ficaram **sem veredito**, porque a verificação foi limitada a
quatro por leitor — os três que falam de código eu mesmo li e tratei acima (os
testes, o construtor, o limite).

**Registrados, e NÃO consertados aqui:**

- A tela de **identificação** da saída no ERP grava com o POST inteiro, e o
  formulário inclui o campo de situação: a saída pode ficar inativa por esse
  caminho **sem passar pela guarda deste débito**. É da classe do **D202**, e a
  varredura dele não a via, porque a chamada é direta. **Consertado no D206**,
  em 22/09/2026.
- O **estorno** de pagamento no ERP não confere a unidade nem se o pagamento já
  foi estornado; a API confere as duas. **Consertado no D207**, em 22/09/2026.
- Registrar pagamento numa parcela ou saída **já excluída** não é recusado — o
  pagamento fica numa linha que nenhuma tela mostra. **Consertado no D208**, em
  22/09/2026.
- O estorno desliga a marca de quitada **sem condição**, inclusive a de parcela
  "quitada sem pagamento".
- `excluirSaidaAction` ainda aceita GET; nenhuma tela a chama, e por isso o
  portão de remoção por POST não a enxerga.
- `valor_pago` é `double`, e a comparação é `> 0` estrita: uma sobra de estorno
  travaria a saída inteira. **Hoje não há nenhuma** — latente.
- `retiradaCancelar` apaga fisicamente a saída da retirada, com cascata no banco
  — já no D199.

> **04/10/2026 -- os três restos, no item FINANCEIRO-S1.** (1) **`excluirSaidaAction` só por POST**
> -- 404 no GET, com a despesa intacta (`SaidasDoErpTest`); está no `ACOES_SO_POST` do portão das
> remoções como ação sem tela (o D362). (2) **O limite fechou, pelo desenho do D228:** toda escrita do
> `SaidaService` trava a linha da despesa e relê o que decide -- as parcelas, pela leitura com trava,
> antes da contagem. O pagamento passa pela mesma trava, então ou ele termina antes e a exclusão o vê,
> ou espera e encontra a parcela excluída (`SaidaSobTravaTest`, com a segunda conexão segurando a trava
> e, fora da transação do teste, o pagamento confirmado por outra conexão depois da foto do MySQL). (3)
> **A sobra do `double`:** `temPagamento()` compara o pago em centavos (`round(..., 2) > 0`); 0,1 + 0,2 −
> 0,1 − 0,2 deixava 5,55e-17 e travava a despesa (`SaidaFinanceiroTest`, com o controle de um centavo e
> de meio centavo).
>
> **A revisão do item, no mesmo dia, achou dois irmãos.** (4) **A tela antiga** decidia a caixa do lote,
> o Editar e o Remover comparando o pago cru com zero (`parcelas.phtml`): a parcela com a sobra, que o
> serviço deixa editar e excluir, aparecia sem os três. Agora a tela pergunta
> `SaidaParcela::temValorPago()`, a mesma regra de `temPagamento()` (`SaidasDoErpTest`, a view
> renderizada, com o controle de um centavo; latente: nenhuma parcela assim na cópia). (5) **O `PUT`
> parcial da API reenviava o tipo lido antes da trava** -- `saidaDaRota()` lê a despesa sem trava, e o
> `PUT` só da descrição desfazia a troca de tipo que outra sessão gravara enquanto ele esperava. O
> serviço só troca o tipo quando ele vem, e a API deixou de mandar o atual (`SaidaSobTravaTest`).

#### PREPARADO em 13/10/2026 -- o estorno não desfaz a quitação com motivo (a resposta 139; o item CONTAS-A-PAGAR)

**Decidido pelo dono em 13/10/2026** (pergunta 139 da página do lote, a opção (b), e **não** a recomendada): "Quitar com
motivo -- continua o 'dar por quitada' sem pagamento, com motivo obrigatório numa coluna nova, e o R$ 0 é recusado; o
estorno não desfaz a quitação com motivo". Vale para as duas telas. O R$ 0 está no **D208**; o esquema, no **D207**.

**O conserto:**
- `saidas_parcelas.quitacao_motivo`, fora do mapeamento (o D473). `SaidaService::quitarSemPagamento($parcela, $motivo)`
  exige o motivo -- `RegraDaSaida::motivo`: aparado, de 1 a 100 caracteres, sem emoji (as tabelas são `utf8mb3`) -- e o
  grava; recusa também a parcela **já quitada** (a tela antiga só oferece o "Quitar" na aberta, e o motivo numa quitada
  pelo pagamento a faria passar por "quitada com motivo").
- **O estorno não desfaz a quitação com motivo** (`SaidaService::estornar`): a parcela dada por quitada sem pagamento,
  com motivo, depois de ter recebido parte em dinheiro, continua quitada quando aquele pagamento é estornado; a quitada
  pelo pagamento (a caixa "quitar", a sangria) reabre, como sempre. Era o resto deste débito: o estorno desligava a marca
  sem condição. As 339 quitadas sem pagamento de antes não têm motivo, e seguem como eram.
- **A tela antiga:** o modal de pagar (`index.phtml` e `parcelas.phtml`) ganhou o campo "Motivo, para quitar sem
  pagamento" (`SaidaParcelaPagar`, `motivo_quitacao`), que só o botão vermelho "Quitar" lê. O `mod-financeiro.js` e o
  `.min.js`, que é o servido, mandam o motivo e limpam o campo ao abrir o modal de outra parcela; o `?cache=` foi a 166.
  A ação recusa sem motivo ("Para quitar sem pagamento, informe o motivo.") e diz "Parcela excluída ou já quitada não
  pode ser quitada." na recusa do serviço.
- **A API** não tem o "dar por quitada" -- é a S2 do desenho (`POST …/quitacao-sem-pagamento`, com a tela nova, a 7ª da
  ordem) --, e a regra mora no serviço: quando o recurso nascer, passa por ela.

**PREPARADO, e não CORRIGIDO:** o código está pronto, e a coluna está nos bancos da suíte e nas duas cópias locais; falta
a produção, no passo 0 do corte, e a base do teste do BVIP, na próxima cópia exportada. Sem a coluna, a quitação
acontece sem guardar o motivo, e o estorno a desfaz como antes -- sem erro.

**Provado** em `tests/Caracterizacao/ContasAPagarRespostasTest.php` (24 casos), `D375SangriaAmarradaTest.php` (21) e
`ContasAPagarSemAsColunasTest.php` (4), novos, e nos vizinhos ajustados às assinaturas novas (`SaidaFinanceiroTest`,
`SaidaSobTravaTest`, `SaidaApiTest`, `SaidaApiEscritasTest`, `CaixaTest`, `CaixaApiTest`, `CaixaSobTravaTest`,
`ColunasForaDoMapeamentoTest`): 358 casos do filtro do item (Contas a Pagar, o caixa, as colunas fora do mapeamento e
os vizinhos), verdes no MySQL e no PostgreSQL, em série. O item CONTAS-A-PAGAR (o roadmap de 13/10/2026) tem a tabela
inteira dos mutantes; os deste débito estão abaixo.

- o serviço recusa o motivo vazio, de espaços, que não é texto, com emoji e com 101 caracteres, e guarda o de 100,
  aparado; recusa a parcela já quitada; o estorno dos R$ 300 de uma parcela dada por quitada com motivo a deixa quitada,
  e o da quitada pelo pagamento a reabre; a tela antiga pede o motivo e guarda o que vem; as duas telas desenham o
  campo; e, no banco sem a coluna (fingido pela resposta guardada da `ColunaNoBanco`), a quitação acontece sem guardar,
  e o motivo escrito à mão não segura a quitação no estorno;
- no front, `frontend/src/telas/contasAPagarDaTelaAntiga.teste.ts` (5 casos) roda o `.min.js` e o `.js` inteiros sobre
  um jQuery de mentira: o "Quitar" manda o motivo, o modal de outra parcela o limpa, e o layout pede o `.min.js` com o
  cache de pelo menos 166;
- mutantes vermelhos: quitar sem exigir o motivo (2 casos); sem gravá-lo (3); a já quitada aceita (1); o estorno
  desfazendo a quitação com motivo (1, nos dois bancos); a tela antiga sem passar o motivo (1); cada modal sem o campo
  (1 cada); os dois guardas do motivo arrancados (1 cada, nos dois bancos); e, no front, o `.min.js` sem mandar o
  motivo, sem limpá-lo, o `.js` sem mandá-lo e o cache de volta a 165 (1 cada).

**04/11/2026 -- a API ganhou o "dar por quitada" (a fatia 77, SAIDAS-LEITURA; a S2 de `docs/f3-telas-restantes/tela-saidas.md`).**
`POST /adm/api/saidas/:id/parcelas/:parcela/quitacao-sem-pagamento`, com `{"motivo": "..."}` obrigatório (422 com o campo sem ele, de
espaços ou acima de 100), passa pelo mesmo `SaidaService::quitarSemPagamento` da tela antiga, sob a trava da despesa: 409 na já quitada
e na excluída, 404 de outra unidade e da parcela de outra despesa na rota, 403 de quem só lê. Pela 139 (b), vale também para a parcela
sem dinheiro nenhum -- o desenho a recusava "até a P1", e a P1 foi respondida. Provado no `SaidaApiLeiturasTest` (nos dois bancos) e
no `scripts/ensaiar-saidas-adm-api.php` (nos dois bancos de contrato, conferido no banco: a marca, o motivo e nenhum pagamento novo).
**A marca não muda**: o que segura o PREPARADO é a coluna na produção (o passo 0 do corte) e na base do teste do BVIP, e a tela nova
pede o motivo só na fatia 78 (a T2), que fecha este débito.

**04/11/2026 -- a tela nova pede o motivo do "dar por quitada" e o mostra (a fatia 78, SAIDAS-ESCRITAS; a T2 de
`docs/f3-telas-restantes/tela-saidas.md`).** O que o PREPARADO esperava da tela está feito: Contas a Pagar na tela nova dá por
quitada qualquer parcela não quitada com o motivo obrigatório (`FormularioDoMotivo`, a frase "Informe o motivo." antes de pedir,
e o 422 do servidor no campo), pela mesma `POST .../quitacao-sem-pagamento` da S2; e a despesa publica o motivo guardado
(`quitacaoMotivo`, `SaidaParcelaRepository::motivosDaQuitacao`, nulo no banco sem a coluna), que a tela mostra embaixo do selo e
que faz o botão do estorno dizer "a parcela continua quitada". E a outra porta deste débito também é da tela agora: excluir a
despesa só aparece com `parcelasComPagamento` zero, dizendo quantas parcelas saem junto, e o servidor recusa de novo (409). Provado
no `ContasAPagarTelaNovaTest` e no `ContasAPagarSemAsColunasTest` (nos dois bancos), no `ContasAPagarEscritas.teste.tsx` e pelo
oráculo no contrato; os mutantes do motivo vazio aceito, do excluir oferecido com dinheiro e dos motivos não publicados (com e
sem a guarda da coluna), vermelhos (o roadmap de 04/11/2026). **A marca é CORRIGIDO**, como no D375: a coluna está nas duas
cópias locais e no passo 0 do corte (`scripts/migracao-saidas-motivos-e-autores.sql`), e sem ela o código segue sem guardar,
sem erro; a base do teste do BVIP a ganha na próxima cópia exportada (o D473).

### D206 — A tela de identificação da despesa gravava com o formulário inteiro — CORRIGIDO em 22/09/2026 — CORRIGIDO em 05/10/2026

> **05/10/2026:** o envio do formulário do ERP, que "não foi exercitado", passou a ter caso que fica vermelho sem o conserto (a seção "O envio do ERP, pelo controller", no fim deste débito).

**Achado pela revisão adversarial do D205**, e registrado lá como o próximo da
fila. Decisão do dono: *"ataque a tela de identificação da saída"*.

##### O defeito

A tela de identificação edita duas coisas da despesa: o **tipo** e a
**descrição**. Mas ela entregava o formulário inteiro ao serviço, e o serviço o
passava a um hidratador, que chama um setter para **cada** campo recebido.
Duas consequências:

- **a situação da despesa mudava por aqui.** O formulário tinha um campo de
  situação desenhado dentro de uma linha escondida por CSS. Por ele a despesa
  ficava inativa sem passar pela exclusão — que é onde mora a guarda do D205.
- **o registro gravado era escolhido pelo formulário.** O serviço pegava a
  despesa por `getReference($data['id'])`, o campo oculto, e não pela da página,
  que é a que a ação confere. É a família do D202, e esta tela não estava na
  tabela dele porque a chamada era direta.

O cadastro de despesa nova tinha a mesma forma — o arranjo inteiro ia ao
construtor, que hidrata —, mas o construtor força a situação para ativa logo
depois.

**Medido na cópia:** nenhuma despesa está inativa — zero. A porta nunca foi usada.

##### O conserto

- `SaidaService::insertIdentificacao()` grava **descrição, tipo e unidade**, e
  nada mais.
- `SaidaService::updateIdentificacao($saida, $data)` **recebe a despesa** — a
  que o chamador conferiu — e lê só tipo e descrição. A descrição é opcional
  porque a API aceita `PUT` parcial. O hidratador saiu do serviço.
- Os dois chamadores passam a despesa recortada: o ERP a de `selecionarSaida()`,
  a API a de `saidaDaRota()`. A lista fechada que a API já montava perdeu o `id`.
- A tela do ERP passou a entregar ao serviço o que o **formulário filtrou**
  (`$form->getData()`), e não o POST cru — no cadastro e na edição. O filtro
  declarado (`StripTags`, `StringTrim`) só servia para validar. Medido: nenhuma
  descrição gravada tem `<`, e 6 têm espaço sobrando nas pontas, que a próxima
  edição apara.
- O formulário, o filtro e a tela perderam o campo de situação. A situação só
  muda pela exclusão.

##### Provado

- **Vermelho antes, verde depois.** `testAIdentificacaoNaoMudaASituacaoDaSaida`,
  na **primeira versão**, escrita na assinatura antiga do serviço, mandou a
  situação inativa junto com a edição: **1 falha em 681** — *"a situação não é da
  identificação"*. Com o conserto, o teste passou a chamar a assinatura nova e a
  exigir também que um `id` diferente no arranjo **não** mude outra despesa. A
  revisão apontou que o comentário do teste sugeria um vermelho que esta versão,
  contra o código antigo, não produziria — ele pararia antes, num erro de tipo. O
  comentário agora diz exatamente o que foi observado.
- **O cadastro e a troca de tipo ganharam teste**, a pedido da revisão: o
  caminho reescrito do cadastro não tinha nenhum, e o cenário tinha um tipo só,
  então o teste da edição nunca via o tipo mudar. Um segundo tipo é criado dentro
  do teste. **Falsificado:** tirando os dois `setTipo()` do serviço, os dois casos
  ficaram vermelhos; restaurado por sha1.
- **A primeira versão do teste falhou pelo motivo errado**, e isso ficou
  registrado para não se repetir: `setDescricao()` normaliza o texto
  (`normalizarNome()`), e o teste comparava com a grafia crua. Corrigido o
  teste — o comportamento da casa ficou.
- **O filtro tem teste próprio:** tirar o campo do formulário e esquecer a
  entrada obrigatória no filtro faria a tela parar de salvar, calada.
  `testOFiltroDaIdentificacaoValidaSoTipoEDescricao` foi **falsificado**: com o
  filtro antigo no lugar, vermelho; restaurado por sha1, verde.
- **684 verdes no MySQL e 684 no PostgreSQL**, corredor verde.
- **A tela abre**, como o gerente da unidade 13, só com leitura: a edição da
  despesa 12911 e o cadastro novo respondem 200, com tipo e descrição, sem o
  campo de situação e sem erro.

**O que não medi por HTTP — e por quê.** Salvar grava, e os usuários sintéticos
do banco de contrato **não têm o privilégio de saídas**. Por isso:

- o envio do formulário do ERP não foi exercitado;
- a revisão pediu para rodar `medir-adm-api-no-postgres.php`, o único que chama
  `PUT /adm/api/saidas/:id`. Rodei, contra o banco de contrato — nenhum 5xx —, e
  **o `PUT` de saídas respondeu 403, na guarda**. O caminho novo não foi
  exercitado por ele, nem aqui nem, pela mesma razão, no CI. O revisor tinha lido
  que daria 200; medido, dá 403.

O filtro, o serviço e os dois chamadores estão cobertos pela suíte e pela
leitura; os dois envios, não. Dar ao usuário de contrato o privilégio de saídas
fecharia isso, e é mudança de cenário que afeta outros ensaios — não entrou.

##### A revisão

Dois leitores independentes — o que o conserto pode ter quebrado, e outros
caminhos de gravação da mesma área — e um cético por achado. **Seis agentes,
todos com resultado. Nenhuma regressão.** O leitor de regressões conferiu o
cadastro e a edição pelas duas portas, o `PUT` parcial da API, o formulário, o
filtro e o token, e os outros chamadores; mediu que nenhuma despesa tem tipo nulo
e que nenhuma está inativa.

Os três pontos fracos que ele achou eram de **prova**, e foram tratados acima: o
cadastro sem teste, o tipo que nunca mudava no teste, e o comentário que prometia
um vermelho diferente do observado. O quarto era de **desenho** — a tela
entregava o POST cru — e também entrou.

**O outro leitor** confirmou que o **estorno de pagamento** no ERP ainda grava
sem as conferências que a API faz — já registrado no D205 como aberto. É o
próximo desta área.

##### O envio do ERP, pelo controller — 05/10/2026

O que ficou sem medida em 22/09 — o envio do formulário do ERP — é despachado em
processo (`ControllerEmProcesso`), no banco de teste e dentro da transação desfeita,
com o token do formulário. O `PUT` da API, que naquele dia deu 403 no banco de contrato,
já tem caso em processo desde 04/10/2026 (`SaidaApiEscritasTest`, o `atualizar`).
`tests/Caracterizacao/D206D207SaidasPelaRotaNoErpTest.php`, os três primeiros casos,
verdes nos dois bancos:

- **a despesa gravada é a da rota, com o que o formulário filtrou:** o POST leva o `id`
  da despesa de **outra unidade** (a 90001100 da fixture) no campo oculto e a situação
  inativa que o formulário perdeu; a minha muda só a descrição — sem as marcas e sem os
  espaços sobrando —, continua ativa, e a da outra unidade fica como estava;
- **a rota de outra unidade não alcança a despesa dela.** `selecionarSaida()` devolve
  nulo, e a ação segue o ramo de despesa nova: a tela abre vazia, e o POST **cadastra**
  uma despesa na minha unidade — o comportamento de sempre, que não vaza nem grava na
  outra; o caso o diz como fato. E é o único despacho do ramo de **cadastro** na suíte:
  a descrição vai com marca e espaços sobrando, e a gravada é a filtrada;
- **o controle:** o administrador grava na despesa da outra unidade.

**Quatro mutantes, um de cada vez, restaurados pelo sha1, todos vermelhos:** a despesa do
`id` do POST no lugar da recortada (o primeiro caso; e o do administrador, sem `id` no
POST, cai com erro); o POST cru no lugar do `$form->getData()` na edição (o primeiro
caso: a descrição chega `' <B>Aluguel</B> Renomeado '`) e no cadastro (o segundo caso:
nenhuma despesa nasce com a descrição filtrada); e o recorte arrancado de
`selecionarSaida()` (o segundo caso: a tela abre com a despesa da outra unidade).

**O cadastro estava sem prova até a revisão de 05/10/2026.** O segundo caso mandava
`'Tomada Pela Rota'`, sem marca nem espaço, e o POST cru no lugar do `$form->getData()`
do ramo de cadastro deixava a despesa nascer igual — o `unidadeId` é sobrescrito pela
sessão, e `insertIdentificacao` só lê descrição, tipo e unidade. Medido: com esse
mutante, os 97 casos de saídas (este arquivo, `SaidasDoErpTest`, `SaidaSobTravaTest` e
`SaidaFinanceiroTest`) ficavam verdes. Nenhum outro teste despacha esse ramo. Agora o
caso manda `'  <b>Tomada</b> Pela Rota  '` e confere a gravada, `'Tomada Pela Rota'`, e
o mutante fica vermelho no MySQL e no PostgreSQL.

### D207 — O estorno de pagamento no ERP não conferia a unidade nem se o pagamento já tinha sido estornado — CORRIGIDO em 22/09/2026 — PARCIAL, lido em 05/10/2026 — PARCIAL em 05/10/2026 — PREPARADO em 13/10/2026 — CORRIGIDO em 04/11/2026: a fatia 78

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: o ERP exige o motivo do estorno e não o grava em lugar nenhum; pede coluna nova, a P5 do desenho de Contas a Pagar.

> **05/10/2026, depois da nota:** a recusa do pagamento de OUTRA unidade, que estava "coberta pela leitura e pelo portão, não por pedido real", passou a ter caso que fica vermelho sem ela (a seção "O estorno de outra unidade, pelo controller", no fim deste débito). Falta só o motivo, que pede a coluna da P5 — esquema, e decisão do dono.

**Achado pela revisão adversarial do D205**, e confirmado de novo pela do D206.
Decisão do dono: *"ataque o estorno de pagamento no ERP"*.

##### O defeito

O estorno marca o pagamento como estornado e **subtrai o valor** do que a
parcela tem pago. A API conferia três coisas antes: a unidade da parcela, que o
pagamento é daquela parcela, e que ele **ainda não foi estornado**. A ação do
ERP — chamada pela tela de parcelas, em `mod-financeiro.js` — pegava o pagamento
pelo id do envio e não conferia nenhuma. E o serviço também não recusava o que
já estava estornado: cada estorno repetido subtraía o valor outra vez.

**Medido na cópia:** 126 estornos, em 97 despesas. **Nenhum repetido:** nenhuma
parcela tem valor pago negativo, e em nenhuma o pago difere da soma dos
pagamentos ativos. Latente.

##### O conserto

- `SaidaService::estornarPagamento()` **recusa** o pagamento já estornado e
  devolve falso — a regra mora no serviço, por onde o ERP e a API passam.
- `pagamentoEstornarAction` recorta o pagamento pela unidade, com o mesmo
  ajudante das ações irmãs da tela — `selecionarParcela()`, sobre a parcela do
  pagamento — e diz *"Este pagamento já estava estornado."* quando o serviço
  recusa.

##### Provado

- **Vermelho antes, verde depois.**
  `testEstornarDuasVezesNaoDevolveOValorDuasVezes`: antes do conserto, **1 falha
  em 685** — *"o segundo estorno do MESMO pagamento tem de ser RECUSADO"*;
  depois, verde. Na forma final, **686 no MySQL e no PostgreSQL**, corredor verde.
- **Pela porta real, sem gravar nada:** como o gerente da unidade 13, o estorno
  do pagamento 11047 — da própria unidade, já estornado em 2024 — foi
  **recusado** com a mensagem nova, e o pagamento e a parcela ficaram
  **idênticos ao backup**, mesmo sha1.
- **A guarda de unidade está no portão**, e o portão foi consertado no caminho.
  `conferir-recorte-por-unidade.php` passou a vigiar a ação. Arranquei a guarda
  para ver o vermelho — e **o portão continuou verde**: ele procurava o nome do
  ajudante no texto do método, e o comentário que explica a guarda o citava.
  Agora ele procura no código **sem comentários**, pelo tokenizador do PHP: sem
  a guarda, vermelho, nomeando a ação; restaurado por sha1, verde. As 48
  entradas antigas continuaram verdes na leitura nova — nenhuma dependia só de
  comentário.

**O que não medi por HTTP:** a recusa de pagamento de **outra** unidade. Ela está
coberta pela leitura e pelo portão, não por pedido real.

##### O limite

A recusa lê e depois grava, sem trava — o mesmo limite escrito no D199 e no D205:
dois pedidos simultâneos de sessões diferentes ainda poderiam estornar o mesmo
pagamento duas vezes.

> **04/10/2026 -- fechado, pelo desenho do D228 (o item FINANCEIRO-S1).** O estorno trava a despesa,
> relê a parcela e o pagamento com trava, desiste se ele já foi estornado e subtrai do pago RELIDO. A
> API passou a ler a resposta do serviço -- conferia antes e a ignorava. Provado por
> `SaidaSobTravaTest`: o estorno que outra sessão fez no meio é recusado no serviço e com 409 na API, e
> os R$ 300 que outra sessão lançou na parcela ficam depois do estorno dos R$ 1.500. E o estorno continua desligando a marca de quitada sem
condição — registrado no D205, fora deste conserto.

##### A revisão

Dois leitores independentes — o conserto, e as outras ações de parcela e
pagamento do ERP — e um cético por achado. **Oito agentes, todos com resultado.
O conserto não quebrou nada.** Dos achados sobre ele, dois eram de prova e
foram tratados:

- **A marca do portão era genérica demais.** As três ações irmãs escrevem
  `selecionarParcela($request['id'])`; copiar essa linha para o estorno passaria
  o id do *pagamento* como id de parcela — e, com o nome presente, o portão
  continuaria verde. A marca agora é `selecionarParcela($pagamento->getParcela()`.
  **Falsificado:** com a linha copiada das irmãs, vermelho; restaurado, verde.
- **O teste não provava "sem gravar nada"**: depois do primeiro estorno o pago
  já é zero. `testEstornoRepetidoNaoDesfazOPagamentoNovo` paga a parcela **de
  novo**, quitada, e só então repete o estorno velho: sem a recusa, ele
  desligaria a quitação nova e zeraria o pago. **Falsificado:** tirando a recusa
  do serviço, os dois testes do estorno ficaram vermelhos; restaurado por sha1,
  **686 verdes no MySQL e no PostgreSQL**.

O achado de que a API ignora o retorno do serviço foi **refutado** — ela confere
o estado antes, no mesmo objeto. E um, anterior ao D207, fica registrado: o ERP
**exige** o motivo do estorno e não o grava em lugar nenhum.

##### O estorno de outra unidade, pelo controller — 05/10/2026

O que estava "coberto pela leitura e pelo portão" — e o portão
(`conferir-recorte-por-unidade.php`) lê o **nome** do ajudante no código, e não o que
acontece — é despachado em processo (`ControllerEmProcesso`), no banco de teste e dentro
da transação desfeita, como `mod-financeiro.js` manda: `id` e `motivo`. O pagamento da
outra unidade nasce no caso (R$ 300 na parcela 90001100 da fixture, da unidade 90001100).
`tests/Caracterizacao/D206D207SaidasPelaRotaNoErpTest.php`, os dois últimos casos, verdes
nos dois bancos:

- **o pagamento de outra unidade não se estorna:** a resposta é a do id que não existe
  (`{sucesso: false, mensagem: "Erro ao localizar pagamento."}`), e o pagamento e a
  parcela ficam como estavam — ativo, R$ 300 pagos —, sem mensagem de sucesso guardada;
- **os controles:** o pagamento da minha unidade se estorna (o valor sai do pago), e o
  administrador estorna o da outra.

**O mutante** — a guarda `selecionarParcela($pagamento->getParcela()->getId())` arrancada
— deixa o primeiro caso vermelho: `sucesso: true`, e o pagamento da outra unidade
estornado. Restaurado pelo sha1.

**O que resta, e por isso o título continua PARCIAL:** o motivo do estorno, que a tela
exige e ninguém grava. Guardá-lo pede a coluna nova da **P5** do desenho de Contas a
Pagar (`docs/f3-telas-restantes/tela-saidas.md`) — esquema, e decisão do dono, já no
lote de perguntas junto com o autor da exclusão.

*Atualizado em 11/10/2026 (o item RESPOSTAS-SEM-RODADA):* o "junto com o autor da
exclusão" acima já não vale como estava. **O autor foi respondido** -- pergunta 18 da
página do lote, a opção (a), "Junto com a P5": quem estornou, e quem excluiu a parcela
e a despesa, ganham o autor junto com a resposta da P5 de Contas a Pagar, e até lá a
exclusão segue sem autor (o D199). **O motivo foi à página em 11/10/2026 como a
pergunta 140** (`motivo-do-estorno`: pede e guarda numa coluna nova, ao lado do autor;
não pede, como a API; ou pede e joga fora, como a tela antiga hoje -- a recomendação é
a primeira), e a coluna nova, nas cópias locais, é a da pergunta 83 (`esquema-em-bloco`),
que também não tem resposta. Nada muda no código até elas; o estado do débito é o mesmo.

*Atualizado em 12/10/2026 (o item ESQUEMA-NAS-COPIAS):* a 83 veio, a opção (a) -- a coluna que não
apaga linha entra nas duas cópias locais quando a resposta a pede, com a sua migração no passo 0. A coluna
deste débito deixa de esperar autorização à parte, e segue esperando a resposta da 140 (o motivo) e da P5
(o autor, pela 18); nada foi aplicado por ela.

#### PREPARADO em 13/10/2026 -- o motivo e o autor do estorno (as respostas 140 e 18; o item CONTAS-A-PAGAR)

**Decidido pelo dono em 13/10/2026** (pergunta 140 da página do lote, a opção (a), a recomendada): "O estorno pede e
guarda o motivo, numa coluna nova, ao lado do autor da resposta 18". A API passa a pedir o motivo também. Com ela se
cumpre a condição da 18: o estorno, a exclusão da parcela e a da despesa ganham o autor (a exclusão está no **D199**).

**O conserto:**
- `saidas_parcelas_pagamentos.estorno_motivo`, `estornado_por` e `estornado_em_nome_de`, fora do mapeamento (o D473).
  `SaidaService::estornarPagamento($pagamento, $motivo, $autor)` exige o motivo (`RegraDaSaida::motivo`) e o autor (as
  duas chaves de `CrudController::autorDaEscrita`), e os grava no pagamento estornado, na mesma transação, depois do
  `flush`.
- **A tela antiga** já exigia o motivo e o jogava fora: agora ele vai ao serviço, com o autor da sessão, e a recusa
  diz o que falta ("Informe o motivo.", ou o teto). **A API** (`estornarPagamentoAction`) passou a pedir
  `{"motivo": "..."}`: sem ele, 422 com o campo.
- **A despesa da sangria** (o estorno que cancela a retirada, e o caixa já fechado) está no **D375**.

**O esquema, que só acrescenta** -- as oito colunas do item, numa migração só,
`scripts/migracao-saidas-motivos-e-autores.sql`, pela resposta 83 do dono (a opção (a)): `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`. Inteiros sem chave estrangeira,
como `usuarios_horarios.gravado_por`; o motivo `varchar(100)`; nulas em toda linha de antes. Idempotente (cada tabela só
recebe as colunas se a primeira delas não existir), `ALGORITHM=INSTANT`, a décima primeira do laço do passo 0 do corte e
do laço da cópia do ensaio geral, com a linha dela no `procedimento-de-corte.md`. Está no `mysql.sql`, no
`postgres/schema.sql` (a saída do `gerar-ddl-postgres.php`, com as oito linhas a mais e nada mais) e no
`scripts/migrar-dados.sh` (a saída do `gerar-migracao-dados.php`: as colunas no `SELECT` e no `\copy` das três tabelas);
os dois geradores reproduziam os arquivos commitados byte a byte antes da mudança. **Fora do mapeamento**, no molde da
`ColunaNoBanco`: as entidades `Saida`, `SaidaParcela` e `SaidaParcelaPagamento` são lidas por Contas a Pagar inteira, e
mapeadas pediriam as colunas em toda leitura -- a base do teste do BVIP, que só as ganha na próxima cópia exportada,
cairia. Quem as usa passa pelos três repositórios (`guardaOMotivoDaQuitacao`, `guardaQuemExcluiu`, `guardaOEstorno`), e
no banco sem elas nada grava nem lê, sem erro.

**Na cópia, as escritas** (13/10/2026). Conferido antes, só com leitura: as oito colunas **não existiam** na
`franquiabv_dev` (no MySQL, 7, 9 e 8 colunas nas três tabelas; no PostgreSQL, 8, 10 e 9, contando o `inquilino_id`) e
existiam nos bancos da suíte, montados do `mysql.sql` e do `schema.sql` novos; nenhuma transação no `INNODB_TRX`, nenhum
processo no MySQL além do agendador, nenhuma sessão de cliente no `pg_stat_activity`, nenhum PHP no contêiner além do
`php-fpm` (lido no `/proc`), e o `local.php` na `franquiabv_dev`.

- **MySQL** (8.4.11), às 01:05:29: a própria migração, como o laço do ensaio a roda (`docker compose exec -T mysql mysql
  -uroot … franquiabv_dev < scripts/migracao-saidas-motivos-e-autores.sql`), saída 0, e a conferência dela: 13.238
  despesas, 15.076 parcelas e 13.902 pagamentos, nenhum com motivo ou autor. No `binlog.000048`, desde a posição
  anotada antes (44919560), **três eventos só**, um `ALTER TABLE ... ALGORITHM=INSTANT` por tabela, terminados em
  44921729; 2 s com a conferência, medidos de fora. Na segunda vez, às 01:05:41, "colunas ja existem, nada a fazer" nas
  três, e o binlog parado. O `SHOW CREATE TABLE` de antes e o de depois das três diferem nas linhas das colunas novas.
- **PostgreSQL** (esquema `bvip`), às 01:06:01, como `franquiabv`, o dono das tabelas: as linhas que o gerador emite para
  as oito colunas, numa transação só, com a conferência de ausência dentro dela, pelo `psql` do contêiner com o roteiro na
  entrada (`docker exec -i franquiabv-postgres-1 psql -U franquiabv -d franquiabv_dev < <o roteiro>`). Saída 0; na
  segunda vez, "alguma das colunas ja existe: nada feito". O roteiro, inteiro:

  ```sql
  \set ON_ERROR_STOP on
  BEGIN;
  SET LOCAL search_path = bvip, public;
  SET LOCAL lock_timeout = '10s';
  SELECT count(*) = 0 AS ausentes_antes
    FROM information_schema.columns
   WHERE table_schema = 'bvip'
     AND (table_name, column_name) IN (
          ('saidas', 'excluida_por'), ('saidas', 'excluida_em_nome_de'),
          ('saidas_parcelas', 'quitacao_motivo'), ('saidas_parcelas', 'excluida_por'), ('saidas_parcelas', 'excluida_em_nome_de'),
          ('saidas_parcelas_pagamentos', 'estorno_motivo'), ('saidas_parcelas_pagamentos', 'estornado_por'),
          ('saidas_parcelas_pagamentos', 'estornado_em_nome_de')) \gset
  \if :ausentes_antes
  \else
    \echo 'alguma das colunas ja existe: nada feito'
    ROLLBACK;
    \quit
  \endif
  ALTER TABLE saidas
      ADD COLUMN excluida_por                     integer DEFAULT NULL,
      ADD COLUMN excluida_em_nome_de              integer DEFAULT NULL;
  ALTER TABLE saidas_parcelas
      ADD COLUMN quitacao_motivo                  varchar(100) DEFAULT NULL,
      ADD COLUMN excluida_por                     integer DEFAULT NULL,
      ADD COLUMN excluida_em_nome_de              integer DEFAULT NULL;
  ALTER TABLE saidas_parcelas_pagamentos
      ADD COLUMN estorno_motivo                   varchar(100) DEFAULT NULL,
      ADD COLUMN estornado_por                    integer DEFAULT NULL,
      ADD COLUMN estornado_em_nome_de             integer DEFAULT NULL;
  COMMIT;
  ```
- **A conferência, só leitura, contra os bancos da suíte:** no MySQL, a forma das três tabelas -- as colunas (posição,
  tipo, nulabilidade, padrão, conjunto, comparação, extra, chave e comentário), os índices, as chaves estrangeiras com as
  regras e as opções da tabela, 46 linhas do `information_schema` -- igual linha a linha à da `franquiabv_cilocal`. No
  PostgreSQL, as colunas (com o padrão e a identidade), os índices, as restrições, o RLS ligado e forçado, as políticas e
  os privilégios das três, 91 linhas, iguais aos da `franquiabv_pgtestes`; e a diferença de antes para depois na cópia
  são as oito linhas das colunas, e nada mais. As oito nulas nas 13.238, 15.076 e 13.902 linhas dos dois bancos. A
  pergunta do código: os quatro guardas respondem `true` contra a `franquiabv_dev` do MySQL (medido em processo, pelo
  PHP do contêiner), e no PostgreSQL a consulta da `ColunaNoBanco`, com o `search_path` do `bvip_app`, conta 1. Os três
  `SELECT` da carga do corte para as três tabelas, com as colunas, respondem no MySQL (medidos só eles, por leitura, num
  `COUNT(*)` por fora). Nada de `DELETE`, `UPDATE` de dado nem `ANALYZE`.
- **O passo 0 ensaiado**, num banco descartável montado do dump de produção (`git show 3b1f05c:mysql.sql`,
  `contas_descartavel_passo0`): o laço inteiro, as onze, duas vezes, as 22 com saída 0, e a estrutura do banco inteiro
  (1.419 linhas do `information_schema`) igual à da `franquiabv_cilocal`, menos a `iframe_bi`, que só desloca a ordem das
  colunas de `unidades`. O banco foi apagado em seguida.

**Os portões que mudaram:** o `conferir-passo-zero-do-corte.php` sai 0 com as onze nos dois laços, e 1 com o laço do
procedimento sem a décima primeira, com o laço da cópia sem ela, e com o `mysql.sql` dizendo `varchar(90)` num motivo
(três cópias dos arquivos). O `conferir-schema-tool.sh` foi de 6 a 14, com o motivo no roteiro (as oito fora do
mapeamento são, para o `schema-tool`, colunas a apagar). O `verificar-schema-postgres.php` e o
`conferir-tipos-do-mapeamento.php` passaram a exigi-las sem mudança neles, porque a lista sai das chamadas do código (o
D475): 0 contra as duas cópias, com as onze colunas lidas. O `ColunasForaDoMapeamentoTest` ganhou as oito na lista. O
inventário das ações foi regerado (duas linhas: a escrita por chamada do estorno e do cancelamento da retirada), e o
`conferir-inventario-atual.sh` sai 0.

**Provado** em `tests/Caracterizacao/ContasAPagarRespostasTest.php` (24 casos), `D375SangriaAmarradaTest.php` (21) e
`ContasAPagarSemAsColunasTest.php` (4), novos, e nos vizinhos ajustados às assinaturas novas (`SaidaFinanceiroTest`,
`SaidaSobTravaTest`, `SaidaApiTest`, `SaidaApiEscritasTest`, `CaixaTest`, `CaixaApiTest`, `CaixaSobTravaTest`,
`ColunasForaDoMapeamentoTest`): 358 casos do filtro do item (Contas a Pagar, o caixa, as colunas fora do mapeamento e
os vizinhos), verdes no MySQL e no PostgreSQL, em série. O item CONTAS-A-PAGAR (o roadmap de 13/10/2026) tem a tabela
inteira dos mutantes; os deste débito estão abaixo.

- o serviço recusa o estorno sem motivo (vazio, de espaços, nulo, com 101), e guarda o motivo aparado e quem estornou;
  a tela antiga guarda o que vem e diz o que falta; na personificação, `estornado_por` é o administrador que entrou e
  `estornado_em_nome_de` o gerente; a API dá 422 com o campo nos quatro corpos sem motivo que sirva, e 200 com ele,
  guardado; e, no banco sem as colunas, o estorno acontece sem guardar nada;
- mutantes vermelhos: o estorno sem exigir o motivo (2 casos); sem gravar o motivo e o autor (4, nos dois bancos); o
  autor da tela antiga pelo `usuarioId` (1: a personificação); a API sem pedir o motivo (1); e o guarda do estorno
  arrancado (1, nos dois bancos).

**O que fica:** a produção, no passo 0 do corte; a base do teste do BVIP, na próxima cópia exportada daqui, pela mão do
dono -- até lá, nela, o estorno pede o motivo e não o guarda, sem erro --; as colunas fora do mapeamento, que são o
**D473** (mapeá-las depois da exportação); e a API ainda não publica o motivo nem o autor no extrato (`SaidaJson`), o
que entra com a tela nova (a T1 do desenho). Nada foi aberto num navegador.

**04/11/2026 -- o resto do "O que fica" (a fatia 78, SAIDAS-ESCRITAS).** A tela nova estorna com o motivo obrigatório (o
`FormularioDoMotivo`, com o que o estorno faz dito antes de confirmar), e a API passou a publicar no extrato o motivo e quem
estornou (`estornoMotivo` e `estornadoPor`, o nome de quem entrou -- `SaidaParcelaPagamentoRepository::estornos`, pela guarda da
coluna; nulos no ativo, nos 126 estornos de antes e no banco sem as colunas), que a tela mostra ao lado do "estornado em". Na
despesa da sangria, as frases do serviço (o caixa fechado, a 171 (a); o caixa de outro operador, a 175 (a); o outro dinheiro)
chegam no 409 e vão à região de status. Provado no `ContasAPagarTelaNovaTest` (os motivos lidos de volta pela API, nos dois
bancos), no `ContasAPagarSemAsColunasTest` (escritos à mão na coluna que o banco "não tem", não aparecem) e pelo oráculo no
contrato (o motivo e o `estornado_por` no banco, e os dois na resposta). **A marca é CORRIGIDO**: fica a produção no passo 0 do
corte e a base do teste do BVIP na próxima cópia (o D473), sem erro nos dois enquanto isso -- o mesmo de D375, já CORRIGIDO.

**04/11/2026 -- a verificação da fatia 78: quem estornou sai do extrato.** A primeira rodada publicava o NOME de quem estornou
(`estornadoPor`), e a verificação o tirou: a tela antiga nunca mostrou o autor, e as respostas 18 (a) e 140 (a) mandam GRAVAR o
autor, e não mostrá-lo a quem lê a despesa -- mostrar é escolha do dono, registrada no **D711**, com a pergunta no lote. O autor
continua gravado em `estornado_por` (afirmado no `ContasAPagarTelaNovaTest`, que também confere que nenhuma resposta traz a chave
nem o nome); o `SaidaParcelaPagamentoRepository::estornos` lê só o motivo. As respostas das escritas da parcela (pagar, dar por
quitada, alterar, excluir) trazem os motivos como a despesa, agora com caso (`testAsRespostasDasEscritasDaParcelaTrazemOsMotivos`).
O "O que fica" deste débito não muda: a marca continua CORRIGIDO; o que espera o dono é o D711.

### D208 — Lançar e pagar parcela pelo ERP não validam o que recebem — CORRIGIDO em 22/09/2026; a retirada negativa, sobra da revisão, em 03/10/2026 (a outra sobra é o D385) — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 13/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decidir se a tela antiga continua aceitando pagamento de R$ 0, que a API recusa; um teste fixa o comportamento até a decisão.

**Achado pela revisão do D207**, olhando as ações irmãs do estorno. Conferido por
leitura; **nada executado**.

- **O número de parcelas não é validado, e texto faz o laço não terminar.** O
  filtro do formulário de parcelas não tem entrada para `numero`, e
  `SaidaService::insertParcelas()` repete `for ($i = 1; $i < $data['numero']; $i++)`.
  No PHP 8, inteiro comparado com texto não numérico compara como texto — e
  `99999 < "abc"` é **verdadeiro** (conferido no próprio PHP). Um valor não
  numérico grava parcelas até o tempo máximo da requisição.
- **Pagar com a data fora de `dd/mm/aaaa`** grava o valor pago na parcela e não
  grava o pagamento: o pago e o extrato passam a discordar.
- **Pagar aceita valor zero ou negativo**, e estornar um pagamento negativo
  **soma** ao pago.

Sem verificação independente, e mais leves: editar parcela aceita valor previsto
zero ou negativo; excluir uma parcela já excluída regrava a exclusão e diz
sucesso.

**O conserto** é validar na origem — o filtro do formulário e o serviço, como a
API já faz com as dela — e cada item pede teste que falhe antes.

#### CORRIGIDO em 22/09/2026 — pedido do dono: *"ataque o D208"*

##### O conserto

A regra mora em `SaidaService`, por onde a tela antiga e a API passam, e confere
**antes de gravar qualquer coisa**:

| o quê | regra |
|---|---|
| valor | sem a máscara, é **número** e **não é negativo**; vazio não vale como zero |
| data | `dd/mm/aaaa` **existente** — `31/02` não vira mais `03/03` |
| número de parcelas | inteiro de **1 a 99** — o que a tela já sugere; o maior caso real é 51 |
| número e período | andam juntos: 12 sem período é recusado, como a API já fazia; `0` também |
| período | um dos conhecidos |
| situação | parcela e despesa **ativas** para lançar, pagar, quitar e editar |
| observação do pagamento | até **100 caracteres**, o tamanho da coluna |

E o pagamento passou a ser gravado **de uma vez** com o pago da parcela, num
`flush` só: se o banco recusar um, nenhum dos dois fica. Antes eram dois, e o
primeiro já tinha gravado.

A tela antiga passou a respeitar a recusa — o lançamento dizia "sucesso" sem
olhar o retorno — e a dizer o motivo. A API ganhou o mesmo teto, número inteiro
de verdade (`2.5` virava 2), valor conferido em centavos (`0.001` virava R$
0,00) e a observação; e passou a responder 409 quando o serviço recusa, em vez
de responder como se tivesse gravado.

##### O que NÃO mudou, de propósito — e é pergunta para o dono

**Pagamento de R$ 0 continua aceito pela tela antiga.** Há 26 ativos na cópia, em
4 unidades, o último em 02/2026 — parece prática, e não erro. A API recusa zero;
a tela antiga, não. Um teste fixa isso até a decisão. **Valor negativo passou a
ser recusado:** eram 4 na cópia, 2 ativos, o último em 2023, com cara de ajuste
de pagamento a maior — o caminho para isso é o estorno. Se for prática a manter,
a regra é uma linha.

##### Provado

- **Vermelho antes, verde depois**, em duas rodadas: o teto gravava **101
  parcelas em vez de 1**; data inexistente, valor negativo, número sem período,
  número zero, despesa e parcela excluídas e valor vazio eram aceitos; e a
  observação longa, **medida isolada** porque estoura e fecha o gerenciador,
  deixava o pago sem o pagamento — *"Data too long for column 'observacoes'"*.
- **O número não numérico não foi rodado contra o código antigo, de propósito:**
  o laço não termina e a suíte, na linha de comando, não tem limite de tempo. O
  motivo está provado pelo próprio PHP — `99999 < "abc"` é verdadeiro.
- **Os testes pegam o que dizem pegar:** a revisão apontou que dava para arrancar
  metade das conferências com a suíte verde. Com os casos novos, arrancar a de
  valor e data do lançamento, a de valor da edição, ou trocar `>` por `>=` no
  teto, dá **3 vermelhos**; subir o teto para 1000 e deixar a data aceitar tudo,
  outros **3**. Restaurado por sha1.
- **701 verdes no MySQL e no PostgreSQL**, corredor verde, e a tela de parcelas
  abre.

##### A revisão

Dois leitores e um cético por achado; **oito agentes, todos com resultado. Nenhuma
regressão** para o que a tela e a API mandam hoje. O que ela confirmou e entrou
acima: os testes que faltavam, número e período juntos, o pago sem pagamento pela
observação, a parcela excluída. O que ela achou e **fica registrado**:

- **o fim de mês nas parcelas mensais — o D209**;
- *(sem verificação independente)* a retirada de caixa com lançamento no
  financeiro grava parcela e pagamento **negativos**, por outro caminho, fora
  desta validação;
- *(sem verificação independente)* excluir parcela ou despesa já excluída regrava
  a data e diz sucesso.

O teste do número não numérico foi apontado como capaz de travar a suíte se a
guarda regredir, e o cético **derrubou** o achado.

#### 03/10/2026: a retirada da revisão, medida -- a máscara sai certa, e o negativo passava

O levantamento suspeitou da volta na máscara de `CaixaService::registrarRetirada` com `financeiro =
1`: a parcela recebe o valor, o previsto recebe `getValorPago()` -- FORMATADO -- e o pagamento
também (a forma do D125 e do D200). **Medido, a volta sai certa e não se mexeu nela:** `applyMask` e
`removeMask` são inversas no formato brasileiro, com o milhar e os centavos. Na cópia, as **8.187**
retiradas com conta a pagar têm o pagamento igual à retirada, todas; as **17** parcelas que divergem
têm o previsto igual e o pago zerado dias depois (16 excluídas) -- o estorno, e não este caminho.

**O que a frase da revisão dizia era o negativo, e ele passava.** A tela velha testava `empty()` no
valor sem a máscara, e `"-50.00"` não é vazio; o serviço só comparava com o saldo, e negativo é sempre
menor. Medido no teste, com o caixa em R$ 5.000,00: *"-50,00"* com `financeiro = 1` deixava o caixa
em **R$ 5.050,00**, a retirada em -50,00, uma saída do tipo 5 e a parcela e o pagamento de **-50,00**
-- o que o D208 passou a recusar no `SaidaService`. A API do caixa nunca o mandou (o decimal do
protocolo não tem sinal); a tela velha, sim, num POST escrito à mão (a máscara do campo não deixa
digitar o sinal). Na cópia, nenhuma retirada negativa.

**O conserto:** o serviço recusa a retirada negativa antes de gravar, pelas duas portas, e a tela
velha diz o campo, como diz para o valor vazio (*"- Valor"*). **O zero não mudou:** as duas portas o
aceitam hoje (zero na cópia), e o pagamento de R$ 0 da tela velha é a pergunta do D208 ao dono.

**Provado:** `D208RetiradaNoFinanceiroTest`, 6 casos verdes nos dois bancos: o milhar e os centavos
intactos na retirada, na parcela (pago e previsto) e no pagamento, pela porta da tela velha e pela da
API, e um valor de cinco centavos; o negativo recusado no serviço e na tela, com o retrato intacto; e
o controle da tela, a retirada positiva com a conta a pagar. Antes do conserto, os dois casos do
negativo ficavam vermelhos e os três da máscara já eram verdes. Dois mutantes, cada um vermelho: a
recusa do serviço arrancada e a da tela arrancada.

**A outra sobra da revisão continua aberta, e saiu daqui:** excluir parcela ou despesa já excluída
pelo ERP regrava a data e diz sucesso. Este conserto tratou só da retirada, e a verificação dele
confirmou a sobra no código de hoje; para não sumir sob o "CORRIGIDO" deste título, ela é o **D385**.

#### CORRIGIDO em 13/10/2026 -- o pagamento de R$ 0 recusado na tela antiga (a resposta 139; o item CONTAS-A-PAGAR)

**Decidido pelo dono em 13/10/2026** (pergunta 139 da página do lote, a opção (b)): "o pagamento de R$ 0 é recusado (na
tela antiga, como a API já faz)". O R$ 0 era a segunda porta para encerrar a parcela sem dinheiro (26 ativos na cópia,
em 4 unidades, o último em 03/02/2026); a que fica é o "Quitar" com o motivo (o **D205**). Os que já existem ficam.

**O conserto.** `SaidaService::parcelaQuitar` passou a pedir pelo menos um centavo (`valorDoPagamentoValido`, em
centavos como a API: 0,004 seria gravado R$ 0,00). O previsto da parcela (lançar e editar) continua aceitando zero, como
sempre. A tela antiga diz o motivo: `'0,00'` não é vazio e passa a conferência da ação, o serviço recusa, e a frase passa
a dizer "Confira o valor (maior que zero) … Para encerrar a parcela sem dinheiro, use o Quitar, com o motivo." O caso
que fixava o zero até a decisão (`testPagamentoDeZeroContinuaAceitoAteDecisao`) virou o contrário.

**Provado** em `tests/Caracterizacao/ContasAPagarRespostasTest.php` (24 casos), `D375SangriaAmarradaTest.php` (21) e
`ContasAPagarSemAsColunasTest.php` (4), novos, e nos vizinhos ajustados às assinaturas novas (`SaidaFinanceiroTest`,
`SaidaSobTravaTest`, `SaidaApiTest`, `SaidaApiEscritasTest`, `CaixaTest`, `CaixaApiTest`, `CaixaSobTravaTest`,
`ColunasForaDoMapeamentoTest`): 358 casos do filtro do item (Contas a Pagar, o caixa, as colunas fora do mapeamento e
os vizinhos), verdes no MySQL e no PostgreSQL, em série. O item CONTAS-A-PAGAR (o roadmap de 13/10/2026) tem a tabela
inteira dos mutantes; os deste débito estão abaixo.

- `SaidaFinanceiroTest::testPagamentoDeZeroERecusado` (`'0,00'`, `'0'` e `'0,004'`, nenhum pagamento gravado e a parcela
  aberta) e o controle de um centavo; `ContasAPagarRespostasTest::testATelaAntigaRecusaOPagamentoDeZero`, pela ação;
- o mutante, o serviço de volta ao `valorValido`, deixa os dois vermelhos.

**O que fica, e foi ao lote:** a retirada do caixa de R$ 0,00 com "lançar no financeiro" -- que as duas telas do caixa
aceitam -- cria um pagamento de R$ 0 por outro caminho, que não passa pela regra (zero na cópia). É o **D480**.

### D209 — Parcelas mensais a partir do dia 29, 30 ou 31 pulam um mês — CORRIGIDO em 13/10/2026

**Achado pela revisão do D208**; confirmado no próprio PHP. Não consertado.

`SaidaService::insertParcelas()` calcula cada vencimento somando `+1 month` ao
**anterior**. `strtotime('+1 month')` a partir de 31/01 dá **03/03** — fevereiro
não tem dia 31, e o PHP transborda. E como a próxima parte de 03/03, todas as
seguintes ficam no dia 3:

```
31/01/2026  03/03/2026  03/04/2026  03/05/2026
```

Quem lança doze parcelas no dia 31 perde um mês inteiro, e todas as outras
vencem no dia errado.

**O conserto proposto:** cada vencimento calculado a partir do **primeiro** mais
`i` meses, com o dia limitado ao último dia do mês — 31/01, 28/02, 31/03, 30/04.
Muda os vencimentos das parcelas lançadas daqui em diante, e por isso é registrado
antes de mexer: se a casa preferir "sempre o último dia do mês" ou "sempre o dia
28", a regra é outra.

#### CORRIGIDO em 13/10/2026 -- a resposta 142 do dono (o item CONTAS-A-PAGAR)

**Decidido pelo dono em 13/10/2026** (pergunta 142 da página do lote, a opção (a), a recomendada): "O vencimento mensal
fica no mesmo dia, limitado ao último do mês: 31/01, 28/02, 31/03, 30/04". Vale para os lotes lançados daqui em diante
nas duas telas; os já lançados não mudam (na cópia, 8 ou 9 dos 200 lotes mensais escorregaram, em 2 unidades, o último
em 02/2024).

**O conserto.** `SaidaService::insertParcelas`, no período `mensal`, calcula cada vencimento a partir do **primeiro**
(`SaidaService::mesesDepois`: conta do dia 1 do mês do primeiro, que nunca transborda, e só então põe o dia, cortado no
último do mês de chegada). Os períodos em dias seguem somando à anterior, que dá o mesmo. A regra é pública, porque a
tela nova mostra os vencimentos antes de gravar pela mesma regra (a P3 do desenho). A tela antiga e a API passam pelo
serviço; o comentário do `criarParcelasAction` diz a regra.

**Provado** em `tests/Caracterizacao/ContasAPagarRespostasTest.php` (24 casos), `D375SangriaAmarradaTest.php` (21) e
`ContasAPagarSemAsColunasTest.php` (4), novos, e nos vizinhos ajustados às assinaturas novas (`SaidaFinanceiroTest`,
`SaidaSobTravaTest`, `SaidaApiTest`, `SaidaApiEscritasTest`, `CaixaTest`, `CaixaApiTest`, `CaixaSobTravaTest`,
`ColunasForaDoMapeamentoTest`): 358 casos do filtro do item (Contas a Pagar, o caixa, as colunas fora do mapeamento e
os vizinhos), verdes no MySQL e no PostgreSQL, em série. O item CONTAS-A-PAGAR (o roadmap de 13/10/2026) tem a tabela
inteira dos mutantes; os deste débito estão abaixo.

- o lote de 31/01/2026 vence 31/01, 28/02, 31/03, 30/04 e 31/05; o de 31/12/2027 atravessa o ano e cai em 29/02/2028; o
  de 30/01 volta a 30 depois de fevereiro; os controles: o dia 10 fica no 10, e o quinzenal de 31/01 soma dias (31/01,
  15/02, 02/03); e a regra pública diz o mesmo;
- dois mutantes vermelhos: o mensal encadeado de volta (1 caso) e o mês depois sem o limite do último dia (2).

### D210 — O menu novo escondia do administrador nove destinos que o ERP mostra — CORRIGIDO em 22/09/2026

**Achado ao comparar o desenho do Claude Design com o front**, lendo o código.

O menu do front (`frontend/src/telas/menu-do-erp.ts`) foi extraído do
`layout.phtml` por varredura, e a varredura só enxergava o `getAcl` do próprio
`<li>`. Em nove destinos a guarda estava um bloco acima, e eles ficaram com
`privilegio: null` — "não medido". A regra do menu esconde o que não foi
medido, e isso vale para **todo mundo**: o administrador recebe a lista completa
de privilégios da sessão e mesmo assim não via Contas a Pagar (Nova e
Gerenciar), Privilégios, Versão, Push, Serviços, Faturas, Docs APIs e o Acesso
Yungas — o único link externo do menu.

A regra estava certa; o dado é que não tinha sido medido. Nada no servidor
estava aberto: era o front mostrando **menos** que o ERP antigo.

#### CORRIGIDO em 22/09/2026 — pedido do dono: *"aplicar o frontend no react para como foi desenhado no claude desing"*

##### O conserto

As nove guardas foram lidas no `layout.phtml`:

| destino | guarda no ERP |
|---|---|
| Contas a Pagar › Nova e Gerenciar | `menuAcesso("financeiro")`, que é só `getAcl(financeiro, saidas, ler)` (`SessaoAcl.php:271`) |
| Configurações › ACL › Privilégios | `getAcl(configuracoes, acl, ler)` |
| Configurações › Aplicativo › Versão e Push | `getAcl(configuracoes, appConfiguracoes, ler)` |
| Configurações › Serviços | `getAcl(configuracoes, produtos, ler)` |
| Acesso Yungas | `getAcl(configuracoes, yungas, ler)` |
| Faturas | `$usuario['gerente']` — **papel**, não privilégio |
| Docs APIs | `$usuario['administrador']` — **papel**, não privilégio |

Os dois últimos não têm linha de privilégio que os represente. O item de menu
ganhou uma segunda forma de guarda, `exige: 'gerente' | 'administrador'`, e a
sessão da API passou a mandar `usuario.gerente` — `grupos.gerente`, que
`SessaoAcl::setAcl()` já guardava e `SessaoJson` não publicava. Administrar não
é gerenciar: são colunas diferentes do grupo, e o menu copia o ERP em vez de
"corrigir".

O rodapé do menu, que contava os não medidos, passou a contar o que some porque
o **papel** não foi informado — o caso de uma prancheta que passa privilégios sem
papéis.

##### Provado

- `tests/Caracterizacao/SessaoApiTest.php` (novo, 5 casos): a chave `gerente`,
  a independência de gerente e administrador, e — de carona — a promessa que o
  cabeçalho de `SessaoJson` fazia e nenhum teste conferia: o token web e a chave
  de WhatsApp não saem na resposta. Arrancada a linha `gerente`, **3 vermelhos**
  pelo motivo certo; restaurado pelo sha1.
- No front, a catraca "9 não medidos" virou "todo destino tem exatamente uma
  guarda", mais as sete guardas uma a uma e os dois papéis. Arrancar o repasse
  dos papéis no `ConsoleLigado` deixa 1 vermelho; trocar a regra do papel por
  "mostra sempre" deixa 2.

### D211 — A comanda aberta publicava desconto zero com itens descontados dentro — CORRIGIDO em 23/09/2026

**Achado pelo levantamento da Fase 2 do desenho**, lendo o código e medido com
SELECT na cópia.

`ComandaJson::resumo` publicava `descontoTotal` lendo a coluna
`vendas.desconto_total` — e o único lugar que escreve essa coluna é a
finalização (`VendaPagamentoService::finalizar`). Enquanto a comanda está
aberta a coluna vale o zero do construtor, então **toda comanda aberta da API
dizia `"descontoTotal": "0.00"`**, com R$ 10 ou R$ 100 de desconto nos itens.
A tela do PDV mostrava "Desconto R$ 0,00" onde havia desconto.

Medido na cópia, vendas desde 01/01/2025 com item descontado:

| estado | com item descontado | coluna diverge dos itens | coluna zerada |
|---|---|---|---|
| cancelada | 1.295 | 704 | **698** |
| concluída | 62.867 | 109 | 0 |

A cópia não tem comanda aberta nenhuma (o corte é de 16/08/2026), e por isso o
defeito aparece nas **698 canceladas ainda abertas**: comandas que nunca
passaram pela finalização, e que enquanto estiveram abertas mostravam desconto
zero. Nas concluídas a coluna é gravada e nunca sai zerada; as 109
divergências são item mexido depois — não entram neste débito.

#### CORRIGIDO em 23/09/2026 — pedido do dono: *"aplicar o frontend no react para como foi desenhado no claude desing"*

Na comanda **não concluída** o desconto sai da soma dos itens; na concluída
continua a coluna, que é o que foi gravado ao fechar e o que os relatórios
somam. O teste põe os dois números diferentes de propósito, para dizer qual
vale em cada estado.

Junto, e pela mesma leitura, o recurso ganhou três campos que o desenho da
comanda pede (B7):

- `quantidadeItens` — soma das quantidades, a conta dos relatórios da casa;
- `valorBruto` — `valorTotal + valorDesconto` por item: a sessão de pacote, que
  grava os dois zerados, e os débitos somados por `POST /debitos` não entram;
- `fechadaEm` — só na concluída, e é a data de alteração. **A abertura de uma
  comanda concluída se perdeu na gravação:** a finalização reescreve
  `data_criacao` com o instante do fechamento, e o `abertaEm` de uma concluída é
  o fechamento. Recuperar a abertura exige coluna nova (D28); a cancelada não
  tem data nenhuma, porque o cancelamento manual não grava.

A listagem soma os itens da página inteira numa consulta só
(`VendaProdutoRepository::somarItensPorVenda`), em vez de carregar os itens de
cada comanda; um teste confere que as duas contas dão o mesmo número.

##### Provado

Arrancada a leitura dos itens (voltando à coluna), **1 vermelho** — o caso da
comanda aberta; trocada a soma do bruto da listagem por `SUM(valorTotal)`, **1
vermelho** — o de concordância. Restaurados pelo sha1. **712 testes nos dois
bancos.**

### D212 — Lançar item pela API aceitava cobrar fora da tabela e desconto sem regra — CORRIGIDO em 23/09/2026 (API); a tela velha fica registrada — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 12/10/2026: o PDV antigo recusa como a API (a resposta 91)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: na tela velha do PDV, exigir o motivo do desconto e travar o valor unitário no servidor; muda as 32 lojas, decisão do dono.

**Achado pelo levantamento da Fase 2 do desenho**, lendo o código e medido com
SELECT na cópia.

`POST /adm/api/comandas/:id/itens` confiava no que recebia, e o serviço por
trás dele (`VendaProdutoService::insert`, o mesmo da tela velha) também:

- **Valor unitário livre.** Na tela velha o campo é desabilitado para todos
  (`ISVenda\Form\Venda`), e a regra "ninguém cobra fora da tabela" morava só no
  navegador. A API aceitava qualquer valor de qualquer pessoa com
  `comandasNova/escrever` — 180 pessoas, 10 delas administradores —, inclusive
  `"0.00"`. Um item de graça assim não aparece no relatório de descontos, que lê
  `valor_desconto`. Na cópia não há sinal de uso: dos 31.135 itens vendidos
  entre 17/07 e 16/08/2026, os 43 fora da tabela são de produtos alterados
  depois da venda.
- **Desconto sem motivo.** O motivo só era exigido pelo JavaScript.
- **Forma desconhecida** tratada como valor, e **porcentagem acima de 100**
  aceita.
- **Desconto maior que o item ignorado em silêncio:** resposta 201, item
  gravado cheio.
- **Produto ou colaborador inexistente:** o serviço chama `getUnidade()` sobre
  nulo, e a resposta era **500** com a pilha do PHP.

**O motivo que falta não é hipótese: está na cópia.** Nas vendas concluídas com
item descontado, pela tela velha:

| ano | itens com desconto | sem motivo | unidades |
|---|---|---|---|
| 2025 | 56.247 | 25.926 (46%) | 26 |
| 2026 (até 16/08) | 28.990 | 13.764 (47%) | 21 |

Nenhum desses descontos passa do valor do item. A exigência do navegador não
segura quase metade dos casos.

*Correção de 11/10/2026 (o D-b de `docs/f3-relatorios/spec-descontos.md`, seção 4,
que a especificação mandava registrar aqui e não estava; o item
RESPOSTAS-SEM-RODADA):* **a leitura acima está errada.** Os 25.926 itens de 2025 e os
13.764 de 2026 "sem motivo" não são desconto manual que escapou do JavaScript da tela
velha: são desconto de **convênio**, que o caminho da agenda grava sem motivo (o D363,
o D-a da mesma especificação). Recontado na cópia em 11/10/2026, só `SELECT` de
contagem, nas vendas finalizadas de 01/2025 a 08/2026, nos itens com desconto e sem
motivo: **39.689 itens (R$ 552.240,15) em venda com convênio, e 0 em venda sem
convênio** -- a especificação dava 39.690 e R$ 552.260,15, com um item das fixtures
dentro. Com isso, "exigir o motivo na tela velha" tem **0 casos medidos** de desconto
manual sem motivo; a pergunta de exigir o motivo e o preço da tabela no servidor do
PDV antigo foi à página como a pergunta 91 (`motivo-e-preco-no-pdv-antigo`), sem
resposta em 11/10/2026, e é ela que o PARCIAL do título espera. O comentário de
`ajusteDaLinha.ts` que repete o engano vai com o D363, como a especificação diz.

#### CORRIGIDO em 23/09/2026 NA API — pedido do dono: *"aplicar o frontend no react para como foi desenhado no claude desing"*

As regras moram em `ISVenda\Regra\RegraDoItemDaComanda`, sem banco nem sessão,
e o controller as aplica antes do serviço:

- produto e colaborador têm de existir — 422;
- desconto exige motivo, forma `valor` ou `porcentagem`, até 100% e nunca maior
  que o item — 422, com o campo;
- motivo até 100 caracteres, que é o tamanho da coluna — 422;
- **fora do preço de tabela, só gerente ou administrador — 403.** É o critério
  que o desenho liga a `podeEditarValor` e o mesmo que o controller já usa para
  autorizar fiado. Não é privilégio novo: uma funcionalidade nova nasce com
  zero linhas. Quem não é gerente cobra menos pelo desconto, que tem motivo e
  aparece no relatório.

**A tela velha continua como está.** Exigir o motivo lá, ou travar o valor no
servidor, muda a rotina das 32 lojas — é decisão do dono, e o número acima é o
que ela precisa para ser tomada.

##### Provado

- `RegraDoItemDaComandaTest` (novo, 10 casos). Arrancadas as cinco regras,
  **6 vermelhos**, cada um no seu caso; restaurado pelo sha1.
- `ensaiar-recusas-adm-api.php` ganhou os casos **24 a 29**, contra o banco de
  contrato: desconto sem motivo, acima de 100%, maior que o item, produto
  inexistente, fora da tabela por quem não é gerente — o colaborador de fixture
  recebe "Comandas nova" só durante o ensaio — e um controle: o mesmo
  colaborador, no preço de tabela, com desconto e motivo, passa. **29 de 29.**
  Arrancadas as três ligações do controller, os casos 24 a 26 e 28 voltam a
  responder **201** e o 27 volta a **500** — exatamente o comportamento de
  antes.

**Decidido pelo dono em 12/10/2026 (a resposta 91, opção a, a recomendada):** o servidor do PDV antigo recusa como a
tela nova -- desconto sem motivo, acima de 100% ou maior que o item, e preço fora da tabela por quem não é gerente; só o
pedido montado fora da tela passa a ser recusado, com a frase indo ao operador como as outras do PDV. Na cópia, quem usa
a tela não sentiria: os sem motivo são os do convênio vindo da agenda (a pergunta 90), e o desconto do convênio não é
recusado.

#### O conserto (12/10/2026, o item PDV-E-CADASTRO)

`VendasProdutosController::adicionarAction` confere o item com a regra da API (`recusaDoDescontoOuDoPreco`, sobre
`RegraDoItemDaComanda`) DEPOIS das duas guardas da venda -- a de outra franquia continua recebendo a recusa que não conta
nada dela, e a fechada a da situação -- e ANTES da venda nova e do `atualizarCliente`, que já gravam. A recusa vai no
`mensagem` com `sucesso: false`, que o `pdvCarrinhoAdicionarProduto` mostra no aviso, como as outras do PDV:

- **o desconto**, pela mesma conta da API (`camposDoDesconto`, que as duas portas chamam), com a frase do PDV
  (`recusaDoDescontoNoPdvAntigo`, uma por linha): "Informe o motivo do desconto." (a do próprio JavaScript, o
  `pdvValidarMotivoDesconto`; o motivo só de espaços conta como nenhum), "O desconto é maior que o valor do item." (o
  serviço o ignorava calado: o item entrava cheio, e a tela dizia "Adicionado") e "O motivo do desconto vai até 100
  caracteres." (a coluna; no MySQL estrito o `flush` derrubava o pedido). O acima de 100% o PDV já recusava
  (`validarFormulario`, "Desconto inválido"), e continua;
- **o preço de tabela**: fora dele, só gerente ou administrador (`foraDaTabela`, `podeCobrarForaDaTabela`, o critério da
  API e do fiado), com a frase da API, que passou a morar numa constante das duas portas
  (`RegraDoItemDaComanda::FRASE_FORA_DA_TABELA`). O campo do valor é travado na tela e preenchido pelo preço do catálogo:
  quem usa a tela manda sempre o de tabela.

Duas diferenças da API, e as duas são do PDV antigo: a forma "reais" vale valor, como no serviço (que trata como valor
tudo o que não é "porcentagem"); e a marca "CUPOM VIP:" não é reservada, porque é o próprio PDV que a escreve ao
escolher o produto do cupom (`marketingCupomPesquisar`, com o motivo de `RegraDoCupom::motivoNoPdv`), e o fechamento
gasta o cupom por ela. O desconto do convênio não passa pela regra: não vem no pedido -- o serviço o calcula e grava
"Convênio X". O produto que não existe segue o caminho de antes (não há preço de tabela a comparar). O serviço não
mudou.

Dois arquivos de caso mandavam um preço que o campo travado não manda, e mudaram: o `D316EscritasEmVendaFechadaTest`
lançava o corte a 50,00 (agora a 89,90, o de tabela, e os dois totais de 150,00 foram a 189,90) e o
`D450CentavoDoItemRetiradoTest` lança a 45,90 pelo gerente (agora a sessão do caso diz que ele é gerente).

**Provado** em `D212DescontoEPrecoNoPdvAntigoTest` (novo, 15 casos), com os campos que o `pdvCarrinhoAdicionarProduto`
manda: cada recusa sem nada gravado (nem a venda nem o item) -- sem motivo, o motivo só de espaços, maior que o item,
o motivo maior que a coluna, as duas juntas, o acima de 100% de antes, e fora da tabela por quem não é gerente (0,00,
50,00, vazio e 99,90) --; e os controles: o desconto igual ao item, o desconto com motivo em valor e em porcentagem
(79,90 e 80,91 gravados), o motivo do cupom que o PDV escreve, o desconto do convênio sem motivo no pedido (17,98 com
"Convênio Convenio Teste"), o preço de tabela por quem não é gerente, e o gerente e o administrador fora da tabela. A
ordem é a da API: o desconto antes do preço. Verdes no MySQL e no PostgreSQL, em série, com os vizinhos (ver o roadmap,
12/10/2026, PDV-E-CADASTRO).

**Falsificado** (`medir-mutantes-pdv-cadastro.py`, no rascunho do item: um mutante por vez, o arquivo devolvido e
conferido pelo sha256 depois de cada um, contra os 181 casos do filtro do item; o mesmo vermelho no MySQL e no PostgreSQL):

| mudança | vermelho |
|---|---|
| o PDV sem a chamada da recusa | as recusas do `D212`; o primeiro erro é o do motivo maior que a coluna, que o banco recusa no `flush` (o MySQL estrito com o 1406, o PostgreSQL pelo `varchar(100)`) -- fecha o `EntityManager`, e o resto da rodada cai em cascata |
| a forma "reais" sem virar valor (a conta da API, que só conhece "valor") | 7 do `D212`: todo desconto em reais, recusado ou não, vira "forma desconhecida" |
| a marca do cupom reservada também no PDV | 1: o motivo do cupom que o PDV escreve |
| sem a recusa do preço fora da tabela | 1: fora da tabela por quem não é gerente |
| o gerente sem passar fora da tabela | 4: o gerente do `D212` e os três do `D450` que lançam a 45,90 pelo gerente |
| a regra sem exigir o motivo (as duas portas) | 6: quatro do `D212` e dois do `RegraDoItemDaComandaTest` (a API) |

#### A verificação (12/10/2026): o motivo com espaços, a ordem e a quantidade

- **O motivo com espaços nas pontas derrubava o pedido.** A regra mede o motivo sem eles (`trim`), e o serviço grava o que
  vier (`VendaProdutoService::calcularLinha`): 100 letras entre dois espaços passavam na regra e eram 102 caracteres na
  coluna de 100 -- o `flush` caía (o 1406 no MySQL estrito; o `varchar(100)` no PostgreSQL), com a venda temporária vazia
  deixada para a pessoa quando o lançamento a abria. A API não tinha o defeito: apara antes de gravar. Agora o
  `adicionarAction` apara o motivo antes de tudo, como a API, e o que se mede é o que se grava. Na tela não acontecia (o
  campo vai até 50); antes do D212 dava o mesmo 500. Caso novo: `testOMotivoComEspacosNasPontasEGravadoAparado`.
- **A ordem da recusa no `adicionarAction` só estava presa contra a guarda da unidade** (o `D237RestosDoPdvEDaAgendaTest`),
  e por acaso; o docstring de um caso dizia mais do que ele conferia. Casos novos: a venda concluída e a cancelada recebem
  a recusa da situação, e não a do desconto nem a do preço; sem `venda_id`, o recusado não abre a venda em andamento (o
  controle: com o motivo, o mesmo pedido a abre) -- se abrisse, o lançamento seguinte ouvia "Existe outra venda em
  andamento"; e a venda sem cliente, recusada, não ganha o cliente do pedido.
- **Nenhum caso lançava mais de um.** O bruto é o valor vezes a quantidade: dois cortes a 89,90 são 179,80, e 100,00 de
  desconto cabem; 179,81, não (`testODescontoEComparadoComAQuantidadeInteira`).

**Falsificado** (`medir-mutantes-pdv-cadastro-verif.py`, no rascunho do item: um mutante por vez, o arquivo devolvido e
conferido pelo sha256, contra os 190 casos do filtro do item; o mesmo vermelho no MySQL e no PostgreSQL):

| mudança | vermelho |
|---|---|
| o PDV sem aparar o motivo | o motivo com espaços nas pontas -- o `flush` recusado; o resto da rodada cai em cascata do `EntityManager` fechado |
| a recusa antes da guarda da situação | 1: a venda concluída e a cancelada |
| a recusa depois da venda nova e do `atualizarCliente` | 2: sem `venda_id`, e a venda sem cliente |
| o bruto sem a quantidade | 1: a quantidade inteira |

**O que fica:** o desconto maior que o item depois do convênio, ou no item da sessão do pacote, segue ignorado calado nas
duas portas -- a regra compara com o preço de tabela, e o serviço tira o desconto do que sobrou (o **D477**, no lote). E
nada foi aberto num navegador: o aviso do PDV com as frases novas não foi visto.

### D213 — Cancelar pela API respondia o estado de venda de outra unidade, e cancelar comanda aberta devolvia crédito que nunca saiu — CORRIGIDO em 23/09/2026

**Achado ao ler o cancelamento para a comanda do desenho**, confirmado no código
e medido com SELECT na cópia.

- **Crédito inventado.** `VendaService::cancelar()` devolvia ao saldo do cliente
  os pagamentos em crédito do cliente e do programa de fidelidade (formas 5 e 6)
  de **qualquer** venda. Só que o crédito sai do saldo na finalização
  (`VendaPagamentoService::finalizar`); receber numa comanda aberta só grava o
  pagamento. Cancelar uma comanda aberta paga em crédito devolvia o que nunca
  tinha saído. O caminho existe pelas duas telas: a antiga cancela qualquer
  venda pelo id, com a senha do gerente, e a nova oferece "cancelar comanda" na
  comanda aberta. A rotina noturna que cancela comandas abandonadas não passa
  pelo serviço, e não estorna nada. **Na cópia não dá para medir:** não há
  nenhum pagamento em crédito em `vendas_pagamentos`, de venda nenhuma.
- **O estado de venda alheia.** `POST /adm/api/vendas/:id/cancelamento`
  respondia antes de conferir qualquer coisa: 409 "já está cancelada" para uma
  venda cancelada de outra unidade, 422 "informe as credenciais" para uma viva.
  Quem só tinha a sessão descobria que a venda existia, e em que estado.
  `GET /adm/api/vendas/:id` já respondia "não encontrada" nesse caso.
- **Motivo sem limite.** A coluna é `varchar(100)`, e um motivo maior virava
  500 — depois de a senha do gerente já ter sido conferida. Um motivo só de
  espaços passava.

#### CORRIGIDO em 23/09/2026 — pedido do dono: *"aplicar o frontend no react para como foi desenhado no claude desing"*

- o estorno do crédito só roda na venda **finalizada** (`status = 1`);
- venda de outra unidade é **404**, com a mesma guarda do `verAction`
  (administrador passa);
- o motivo vai aparado e até 100 caracteres — **422**, antes da senha.

O que não mudou: a credencial do gerente continua conferida contra a unidade
**da venda**, e a venda de crédito — que nasce concluída — continua estornando o
valor comprado.

##### Provado

- `CancelarVendaComCreditoTest` (novo, 2 casos): a comanda aberta paga em
  crédito é cancelada sem mexer no saldo, e a venda finalizada devolve os
  R$ 50,00 e os R$ 30,00. Voltar ao `else` de antes deixa o primeiro vermelho;
  desligar o estorno de vez deixa o segundo; restaurado pelo sha1.
- `ensaiar-recusas-adm-api.php` ganhou os casos **36 a 38**, contra o banco de
  contrato. **38 de 38.** Com o controller de antes, os três voltam a
  responder o que respondiam: 409 "já está cancelada", 422 "informe as
  credenciais" e 422 "login ou senha não conferem".

### D214 — Com convênio, o motivo do desconto do PDV é apagado pelo nome do convênio — ANOTADO em 23/09/2026 — PARCIAL em 14/10/2026 (o D477)

**Achado ao extrair a conta da linha para o ajuste de item (B9)**, lendo
`VendaProdutoService::calcularLinha` — a mesma conta que o lançamento fazia
dentro do `insert()`.

A ordem é esta: a linha recebe o motivo digitado; se o cliente tem convênio
ativo e o item é serviço, o convênio desconta e **grava o próprio nome no
motivo**, por cima; e só então o desconto do PDV é somado ao valor do desconto
— sem tocar no motivo. O resultado é uma linha com os dois descontos somados e
um motivo só, o do convênio. O motivo que a pessoa digitou some.

Isso esvazia a regra do D212 para cliente de convênio: a API exige o motivo, e o
serviço o descarta.

**Medido na cópia**, nas vendas concluídas, comparando o desconto da linha com a
porcentagem do convênio **atual** do cliente (ele pode ter mudado desde a venda,
então é aproximação):

| ano | linhas com motivo de convênio | com desconto do PDV somado | valor somado |
|---|---:|---:|---:|
| 2025 | 9.884 | 460 | R$ 5.976,07 |
| 2026 (até 16/08) | 5.608 | 260 | R$ 2.386,64 |

**Por que não foi consertado junto:** a conta é do serviço compartilhado, e
mudar o que ele grava muda o que as 32 lojas registram pela tela antiga.

**Direção do conserto:** gravar os dois motivos — o do convênio e o digitado —,
respeitando o tamanho da coluna; ou separar o desconto do convênio numa coluna
própria, que também tiraria da tela nova a adivinhação de qual parte do
desconto é do convênio — hoje ela reconhece o convênio pelo prefixo "Convênio " do motivo.

#### PARCIAL em 14/10/2026 -- a resposta 177 (o D477, o item CADASTRO-E-CONVENIO)

**O que mudou:** a primeira direção acima, pela resposta 177 do dono (opção "c"), feita no D477 -- no item em que o
convênio deu desconto, o desconto do balcão grava "Convênio X; <o motivo digitado>", cortado em 100
(`RegraDoItemDaComanda::motivoDoConvenioComOBalcao`, chamada por `VendaProdutoService::calcularLinha`), nas duas portas,
no lançamento e no ajuste. O exemplo do corte de 89,90 com o convênio de 20% e 10,00 de desconto "Cliente da casa" grava
"Convênio Convenio Teste; Cliente da casa" (`D477DescontoAteOValorDoItemTest`). A comanda marca a linha
(`descontoDoBalcaoNoConvenio`, pelo motivo e pelo valor contra a porcentagem do convênio, a conta da medição acima), e a
tela nova recusa o "+" e o "−" nela. O banco continua sem a coluna própria do desconto do convênio (a segunda direção:
esquema, não feito). Pelo pedido do dono: *"continue de forma autonoma a resolver todos os debitos que  tiver resposta"*.

**O que resta -- o PARCIAL do título:**

- **o motivo do cupom** ("CUPOM VIP: ..."), no item do convênio, segue trocado pelo nome do convênio, e o cupom não
  queima: é o **D277**, no lote (CADASTRO-E-CONVENIO, depois da resposta 177);
- **o convênio de nome longo:** o corte é o da coluna inteira, como a opção da 177 diz, e o nome de 89 caracteres ou mais
  não deixa caber o digitado (o de 75 a 88, só em parte, para um motivo de 15 caracteres) -- o motivo fica o do convênio,
  e a linha segue marcada pelo valor. Na cópia, um convênio ativo de 100 caracteres, sem cliente ativo nele, e um de 77,
  com 16 clientes ativos, que deixa 12 caracteres para o digitado (ver o D477);
- **as linhas gravadas antes de 14/10/2026** ficam com o motivo de antes (só as comandas abertas eram ajustáveis, e
  vêm marcadas pelo valor).

### D215 — Fechar a agenda pela API aceitava motivo maior que a coluna, e respondia 500 — CORRIGIDO em 23/09/2026 (API); a tela antiga em 05/10/2026

**Achado ao ligar o formulário de fechamento do desenho (A5.8)** a
`POST /adm/api/agendas/fechamentos`, lendo `conferirFechamento`.

`agendas_fechamentos.motivo` é `varchar(100)`, e o endpoint só conferia se o
motivo estava vazio. Um motivo maior passava pela validação, pela contagem dos
agendamentos do período, e morria no banco: **500**, sem o envelope de erro. O
motivo também ia gravado sem aparar — com a conta do vazio feita sobre ele
aparado.

A tela antiga (`Form\AgendaFechar`) também não limita o campo. **Na cópia o
defeito nunca aconteceu:** o maior motivo tem 95 caracteres, em 134.334
fechamentos — é latente, e a tela nova seria a primeira a alcançá-lo.

#### CORRIGIDO em 23/09/2026 — pedido do dono: *"aplicar o frontend no react para como foi desenhado no claude desing"*

- o motivo é aparado e vai até 100 caracteres — **422** com `campos.motivo`,
  antes de contar o período;
- é o aparado que se grava e que volta no 201;
- o campo da tela nova tem `maxLength` 100.

A tela antiga não mudou.

##### Provado

- `ensaiar-recusas-adm-api.php` ganhou o caso **45**, contra o banco de
  contrato: com o controller de antes, **500 (sem erro no corpo)**; com o
  conserto, 422 `validacao` com o campo `motivo`. **45 de 45.**

#### A TELA ANTIGA — CORRIGIDO em 05/10/2026

O registro dizia "a tela antiga não mudou", e ela também não limitava o campo. Não
era latente só na teoria: **medido** antes do conserto, nos dois bancos, cem letras
com um espaço no fim passavam pela validação (que só perguntava `empty()` sobre o
texto cru) e morriam no `flush` do fechamento — *"1406 Data too long for column
'motivo'"* no MySQL, *"value too long for type character varying(100)"* no
PostgreSQL: um 500 depois de a tela dizer "pode fechar". E três espaços passavam
como motivo.

Agora, em `RegraDeFechamento` (D111):

- o motivo é **aparado**, e o vazio continua sendo `empty()` sobre ele — só espaços
  é *"- Motivo"*, e o `"0"` continua recusado, como sempre foi;
- acima de **100 caracteres** (contados como a coluna conta, e não em bytes: cem
  `é` cabem), *"- Motivo com até 100 caracteres"* — recusa de campo, com `horarios`
  zero: aviso, e não o modal de "deseja continuar?", e a confirmação não a vence;
- `fecharAction` passa ao serviço **o aparado** (`RegraDeFechamento::motivo`), e é
  ele que vai para `agendas_fechamentos.motivo` e para a observação de cada bloqueio;
- o campo do modal (`Form\AgendaFechar`) tem `maxlength` 100, da mesma constante.

##### Provado

`FechamentoDaTelaAntigaTest` (15 casos; 6 do conserto): o de 101 recusado e não
gravado, o aparado gravado (motivo e observação), o só de espaços recusado e o
`maxlength` do formulário ficaram vermelhos contra o código de antes, nos dois bancos
— o só de espaços por cascata do 500 do caso anterior, no mesmo processo —, e o de 100
gravado já passava: fica como o controle do limite. E `RemarcacaoDaTelaAntigaTest::testAPaginaDesenhaOTokenDentroDoModal`
lê o `maxlength="100"` no `index.phtml` desenhado de verdade. Mutantes: sem o teto,
sem aparar, o serviço com o bruto e sem o `maxlength`, cada um com o seu vermelho.
**Não aberto no navegador.**

### D216 — Remarcar aceitava horário fechado, fora da grade e malformado, apagava a chegada, e gravava sem transação — CORRIGIDO em 23/09/2026 (API); na tela velha, o dia redesenhado e o token em 05/10/2026, e o resto segue registrado — PARCIAL, lido em 05/10/2026 — PARCIAL em 12/10/2026 — CORRIGIDO em 07/11/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: na tela antiga: horário fechado, fora da grade, pedido malformado, chegada perdida, família descompassada e sem transação; o D217 e as decisões do dono.

> **Lido de novo em 12/10/2026, na verificação do item da agenda antiga:** a tela antiga e o D217 deixaram de faltar (as seções de 12/10/2026, abaixo). Falta: as decisões do dono -- remarcar para o passado, a origem que vira "web" e a lista de serviços do colaborador de destino, que nunca tinham ido ao lote e foram em 12/10/2026 -- e a comanda aberta, que é o D221.

**Achado ao preparar a remarcação da tela nova**, lendo
`AgendasController::remarcarSalvarAction`, a
`AgendamentosController::remarcacaoAction`, o `AgendaService` e a
`RegraDeAgendamento`, e medido com SELECT na cópia.

As três portas — o modal da tela antiga, o arrastar da tela antiga e a API —
remarcam do mesmo jeito: **criam a família nova e só depois apagam a velha**,
sem transação, e conferem só o que a `RegraDeAgendamento` confere — quantos
horários o serviço pede, horário ocupado por outro cliente, horários seguidos.
Ficava de fora:

- **O horário fechado.** A consulta de "ocupado"
  (`selecionarPorColaboradorDataHora`) faz INNER JOIN com cliente e serviço, e
  as 1.163.299 linhas de fechamento da cópia não têm nenhum dos dois: a regra vê
  a agenda fechada como livre. A grade lê com LEFT JOIN — a grade e a regra
  discordam sobre o mesmo horário.
- **O horário fora da grade, o almoço, as férias e o dia sem agenda.** São
  conferidos ao montar a lista de horários do modal, e nunca ao gravar. Hora
  fora da grade ("10:15" numa agenda de meia hora) vira agendamento que a grade
  não desenha: `buscarAgenda` compara texto.
- **O pedido malformado.** Hora que não é hora derruba com **500** (o
  `new DateTime` lança); "10:00:00" não cabe em `hora varchar(5)`; observação
  acima de 255 também não — a classe do D215. E **hora repetida grava duas
  linhas no mesmo horário**: a conferência de "seguidos" pula as iguais.
- **A família descompassada.** A regra compara o pedido com os `periodos` do
  serviço HOJE, e nunca com o tamanho da família. Quando a família tem mais
  linhas que isso, a lista menor passa, e o `desmarcar` apaga a família
  inteira: o serviço de uma hora vira de meia, sem erro. Na cópia, desde
  01/09/2025: 11 famílias de serviço com `periodos = 1` e duas linhas, e 1 com
  `periodos = 2` e três.
- **A chegada e a saída se perdem.** Remarcar recria as linhas com
  `checkin` e `checkout` zerados. No arquivo da cópia, das 4.046 exclusões pelo
  painel que têm cara de remarcação (critério no D218): **621 eram de quem já
  tinha chegado** — em 171 a linha nova continua sem a chegada — e 31 de
  agendamento já pago. E 492 agendamentos do aplicativo viraram "web".
- **O agendamento não pode se sobrepor a si mesmo.** A conferência roda com a
  família velha na tabela: empurrar um serviço de dois horários um horário para
  a frente é recusado como "ocupado" — pelo próprio agendamento. No arquivo, das
  101 remarcações de serviço de dois horários no mesmo dia e colaborador,
  nenhuma caiu sobre o próprio lugar.
- **Sem transação.** Uma remarcação de dois horários são umas seis gravações
  separadas; uma falha no meio deixa as duas famílias, ou nenhuma.
- **Só na tela antiga:** a unidade do colaborador de destino não é conferida;
  a resposta redesenha a grade de HOJE, e não a do dia que se vê; e o `Csrf` do
  formulário `AgendaRemarcar` nunca é validado — a ação lê o POST cru.

#### CORRIGIDO em 23/09/2026 NA API — pedido do dono: *"aplicar o frontend no react para como foi desenhado no claude desing"*

A ordem virou **apagar, conferir, marcar**, numa transação —
`ISCadastro\Service\RemarcacaoDeAgendamento`. Com a família velha fora, ela não
ocupa mais o próprio lugar; e se a conferência recusa, o rollback a devolve —
e devolve também as linhas que o serviço gravou em `agendas_exclusoes`, pela
mesma conexão. A raiz da família e o colaborador de destino ficam travados
(`SELECT ... FOR UPDATE`), sempre nessa ordem.

O que só a API exige mora em `ISCadastro\Regra\RegraDaRemarcacao`, sem banco nem
sessão, como a `RegraDoItemDaComanda` do D212:

- hora em `HH:MM`, sem repetir, em ordem; observação até 255 — **422**;
- horário fechado, fora da grade, férias e dia sem agenda — **422**, com os
  códigos de `HorariosLivresJson` (`ferias`, `agenda-fechada`) e
  `horario-indisponivel`;
- quem já chegou ou já saiu, a família de tamanho diferente do que o serviço
  pede hoje, e o agendamento sem serviço — **409**: não é o pedido que está
  errado, e repeti-lo não muda nada;
- a linha de agenda fechada não é agendamento — **404**, como a de outra
  unidade;
- o colaborador de destino tem de estar ativo e aparecer na agenda — **404**. O
  servidor antigo não conferia: só a lista da tela o escondia;
- remarcar para o mesmo lugar responde **200** e não grava nada — recriar as
  linhas levaria embora a origem, a data de criação e o id por nada.

`AgendaService` e `RegraDeAgendamento` não mudaram: são da tela antiga das 32
lojas e do contrato congelado do aplicativo (D5).

**Ficaram como a tela antiga faz, até o dono decidir:** remarcar para o
passado vale (um quarto das remarcações do arquivo foi para um horário que já
tinha passado — correção de quem atendeu e quando); a origem volta a "web"; a
lista de serviços do colaborador de destino (`usuarios_servicos`, 29
colaboradores) não é conferida; a comanda aberta não é tocada (D221).

**E a tela nova não sabia quantos horários o agendamento tem.** A grade não
desenha horário fora do passo de hoje — o colaborador que atendia de 30 em 30 e
passou a 20 —, e a família que tem um horário assim parecia menor: o pedido
levava horários a menos, e o servidor recusava com `periodos`. **Consertado em
24/09/2026:** a grade publica, em cada horário de agendamento, as horas da
família inteira (`familia`) — o `GradeDaAgendaService` já lia as linhas do dia,
inclusive as fora do passo, e agrupá-las não custa consulta. A janela e o
arrasto levam essas horas, e a duração de agora sai delas: 09:00 e 09:20 são 40
minutos, e não 2 × 30. Mutações: as 4 peças do servidor e as 10 da tela, uma
a uma, deixam cada uma o seu caso vermelho. Uma revisão adversarial da mudança
(5 agentes, nenhum morto) confirmou 1 achado e refutou 2: o corte das 23:30 que
passa da meia-noite grava as 00:00 no MESMO dia, e a janela dizia "às 00:00
(2820 min)" — a lista passou a começar depois da maior folga do relógio, e a
duração a virar o dia. **Fica de fora, e é anterior a esta mudança:** a família
com um horário fora do passo que JÁ TEVE a chegada registrada (as 105 famílias
misturadas do D219) continua recebendo o "Remarcar", e o servidor recusa com
409 — a grade publica as horas da família, e não o estado de cada uma.

##### Provado

- `RegraDaRemarcacaoTest` (novo, 18 casos) e `RemarcacaoDeAgendamentoTest`
  (novo, 17 casos), nos dois bancos. A prova do rollback nos testes de
  caracterização pede pontos de salvamento: a suíte roda cada teste dentro de
  uma transação que ela desfaz, e sem eles o rollback de dentro só marca a de
  fora. A trava é medida com uma segunda conexão segurando a linha.
  Arrancadas uma a uma, as 13 regras da classe pura e as 11 peças do serviço
  deixam cada uma o seu caso vermelho; restaurado pelo sha1.
- **A revisão adversarial de 24/09/2026** — quatro revisores e um cético por
  dimensão, nenhum agente morto — confirmou 15 achados e refutou 4. No servidor:
  o agendamento sem serviço ainda dava 500; a resposta dizia o dia seguinte para
  quem ia às 23:30, porque o `agendar()` anda o `DateTime` da última linha
  depois de gravá-la; e um caso de teste chegava ao "sumiu" por um id que o
  Doctrine anula, e não por outro pedido ter apagado a família. No ensaio: o
  caso 48 ficava verde sem a guarda da unidade. E a trava que este débito dizia
  proteger a API contra ela mesma não protege o check-in, o desmarcar nem o
  marcar — está no D219, e foi consertada na API no mesmo dia. Os outros estão
  consertados aqui, cada um com o caso que fica vermelho sem o conserto.
- `ensaiar-recusas-adm-api.php` ganhou os casos **47 a 59**, contra o banco de
  contrato — o único lugar onde o controller roda de verdade. O caso 55 é **a
  prova do rollback por HTTP**: o destino recusa depois de a família ter saído,
  e o ensaio lê no banco que o agendamento continua lá e que o arquivo de
  exclusões não ganhou linha. **59 de 59.** Com nove
  mutantes do controller e do serviço, cada um deixa o seu caso vermelho: sem
  a guarda da linha sem cliente, o caso 47 responde 409 em vez de 404; sem a
  forma do pedido, "9h" e a observação de 256 caracteres voltam a **500**; sem
  a regra do destino, a hora fora da grade e o horário fechado passam com
  **200**; sem a unidade do destino, o 48 chega até a regra do dia; com a ordem
  antiga, o controle 59 é recusado; e com `commit` no lugar do rollback, o caso
  55 responde o mesmo 422 com a mesma frase — **só o banco o acusa**, com o
  agendamento sumido. Restaurados pelo sha1.
- `ensaiar-permissao-adm-api.php` ganhou a rota: **40 de 40** recusadas a quem
  não tem o privilégio. `ensaiar-fluxo-adm-api.php` ganhou o passo **5b**:
  remarca para o horário seguinte e registra a chegada no id NOVO.

#### 05/10/2026 — OS DOIS ITENS "SÓ NA TELA ANTIGA" QUE NÃO ERAM DECISÃO, E O BOTÃO DA TELA NOVA

Dos três itens "só na tela antiga", a unidade do destino já tinha saído no D237. Os
outros dois saíram agora; o resto da tela antiga — horário fechado, fora da grade,
pedido malformado, a chegada perdida, a família descompassada, sem transação —
continua como está, porque a `RegraDeAgendamento` e o `AgendaService` são do contrato
congelado do aplicativo (D5) e das 32 lojas; e o D217 (o terceiro horário do arrastar)
continua com o dono.

**O dia que se vê.** `remarcarSalvarAction` redesenhava a grade de HOJE: quem
remarcava olhando o dia 20 via a grade trocar pela de hoje. Agora o
`agendaRemarcarSalvar` (`mod-cadastros.js`) manda `datasolicitada` — o campo de data
da página; o `data` do formulário é o dia de DESTINO, que pode ser outro —, e a
resposta desenha esse dia. Sem ele (o JS velho em cache), ou com o que não é dia,
hoje, como antes. **O irmão também:** `remarcarDragAndDropSalvarAction` redesenhava
hoje — inclusive na recusa, que é quando o bloco solto precisa voltar ao lugar —, e
agora desenha o dia onde se soltou (o `data` da célula é o `data-day` da grade aberta).
A forma do dia é conferida antes do `DataHora::dataValida`, que estoura com texto (D420).
`mod-cadastros.js` subiu para `?cache=165` no layout; não há `.min.js` dele.

**O token.** O `Csrf` do `Form\AgendaRemarcar` nunca era conferido — e nem desenhado:
o `index.phtml` não punha o campo na página, e o `serialize()` do modal não tinha o que
mandar. Agora a página o desenha dentro do formulário, e a ação o confere antes de
tudo (*"O formulário expirou: recarregue a página e envie de novo."*, a frase do
D407). ~~Só no modal~~ **E no arrastar** (a verificação, abaixo): o primeiro conserto
deixou `remarcarDragAndDropSalvarAction` — a outra porta da mesma remarcação —
aceitando `{id, data, hora}` sem token. Agora o `agendaRemarcarDragAndDrop` manda o
mesmo campo (lido do `form_agenda_remarcar`, na mesma página), e as duas ações o
conferem por um ajudante só (`recusaDoTokenDoRemarcar`). A recusa do arrastar devolve a
grade do dia, ao contrário da do modal: o bloco já foi solto na célula nova, e a tela
só sai do "Salvando agendamento.." quando a resposta troca o `#tabela`. **O prazo de fábrica do Laminas (300 segundos) teria quebrado o balcão**: a
agenda fica aberta o dia inteiro, e todo remarcar depois dos primeiros cinco minutos
seria recusado. O token passou a valer a sessão (`timeout` nulo), e com **sal
próprio**: o prazo é do recipiente na sessão, e não do token — com o sal de fábrica,
qualquer formulário do ERP aberto noutra aba poria os 300 segundos no recipiente
comum e levaria o token da agenda junto. O custo é pequeno e fica dito: cada abertura
da página acrescenta um token (uns 100 bytes) à sessão, que vive o quanto ela viver —
o `atualizar` da grade não redesenha a página, e não acrescenta.

**O botão da tela nova — o "Fica de fora" de 24/09.** A família com um horário fora
do passo de hoje que já teve a chegada (as 105 misturadas do D219) recebia o
"Remarcar", e o servidor recusava com 409: a grade publicava as horas da família, e
não o estado delas. Agora `GradeDaAgendaService` diz, por raiz, se algum horário da
família — desenhado ou não — chegou ou saiu, e o recurso publica `remarcavel` em cada
horário de agendamento (aditivo, como `familia`). A pergunta é a mesma do servidor, e
não uma cópia: `RegraDaRemarcacao::oQueOAtendimentoImpede`, que saiu de
`conferirFamilia` sem mudar. A família descompassada fica de fora do bit de
propósito — ela recusa só o remarcar, e o remédio que a frase dela ensina é
desmarcar. A tela nova (`seMove`, em `modeloDaAgenda`) esconde por ele o "Remarcar", o
"Desmarcar" (o desmarcar da API recusa a chegada igual) e o arrasto. **O bit é a
pergunta do ATENDIMENTO, e não a promessa de que o remarcar passa** (a verificação
achou os comentários prometendo mais): a família de tamanho diferente do que o serviço
pede hoje e o agendamento sem serviço saem `remarcavel: true`, e o remarcar deles
continua respondendo 409 — a janela da tela nova mostra a frase do servidor. Os
comentários do recurso, do serviço, do `HorarioDaApi` e do `seMove` passaram a dizer
isso, e um caso prende a fronteira.

##### Provado

- `RemarcacaoDaTelaAntigaTest` (11 casos, em processo, nos dois bancos): sem o token,
  com o token errado e com o token de OUTRO formulário, nada se mexe e a resposta não
  traz a grade; com ele, remarca; **seis minutos depois** (o relógio da sessão,
  `$_SERVER['REQUEST_TIME']`, adiantado) continua valendo, e continua valendo depois
  de outro formulário com o token de fábrica ser aberto; a página de verdade
  (`indexAction` despachado e o `index.phtml` desenhado) tem o campo dentro do
  `form_agenda_remarcar`, com o valor do formulário; a resposta desenha o dia pedido
  e não o de hoje; sem o dia, e com `aa/bb/cccc`, `31/02/2026` e `2026-12-24`, hoje; e
  o arrastar desenha o dia onde se soltou. Os dois testes do D237 que remarcam
  passaram a mandar o token.
- `GradeDaAgendaApiTest` ganhou 5 casos: sem chegada, `true`; a chegada no horário
  fora do passo (que a grade não desenha, e o desenhado continua `marcado`), `false`;
  a saída, `false`; ~~bloqueio e livre sem o bit~~ (o caso era vazio — a coluna dele
  não tinha o mapa, e repetia o seguinte; refeito na verificação, abaixo); e quem monta
  a coluna sem o mapa não o inventa.
- Mutantes, um de cada vez, no MySQL, e restaurados pelo sha256: o token não conferido, o
  `Csrf` de fábrica, sem prazo com o sal de fábrica, com sal e o prazo de fábrica, a
  página sem o campo, o modal e o arrastar redesenhando hoje, o dia sem a forma
  conferida, o recurso sem o bit, o serviço dizendo sempre `true` e a regra sem a
  conferência da chegada — cada um com o seu vermelho.
- Tela nova: `AgendaLigada.teste` ganhou 3 casos (o painel sem Remarcar e sem
  Desmarcar com a chegada que só o servidor vê, e com a chegada registrável; o bit
  verdadeiro mantém o botão; o bloco que não se arrasta), `modeloDaAgenda.teste` 5
  (`seMove`) e `agendas.teste` 1 (a tradução); os 4 mutantes da tela (o `seMove` sem o
  bit, a tradução que o perde, o painel e o arrasto pelo desenhado) ficam cada um
  vermelho. **A tela nova não foi vista no
  navegador por mim**, nem a antiga: o envio do JS foi medido com um jQuery de
  mentira (o corpo leva o token do `serialize()` e o `datasolicitada` codificado), e
  o resto é o despacho em processo. ~~A medição do JS ficou fora do repositório~~ (virou
  caso na verificação, abaixo).

#### 05/10/2026 — A VERIFICAÇÃO DO CONSERTO: o arrastar sem token, o bit que prometia mais, o caso vazio e o JS sem catraca

Uma verificação adversarial da mudança confirmou sete achados, todos de gravidade baixa.
O que foi feito com cada um:

1. **O arrastar remarcava sem token** (dois achados, o mesmo defeito). O conserto pôs o
   `Csrf` só no modal; `remarcarDragAndDropSalvarAction` — o mesmo `agendar`, a outra
   porta da mesma remarcação — seguia aceitando `{id, data, hora}` cru, e este registro
   dizia "o token" sem dizer que o irmão continuava aberto. **Consertado:** o
   `agendaRemarcarDragAndDrop` manda o `security` do `form_agenda_remarcar`, e as duas
   ações conferem pelo mesmo ajudante (`recusaDoTokenDoRemarcar`). A recusa do arrastar
   traz a grade do dia, para o bloco voltar ao lugar e a tela sair do "Salvando".
   `RemarcacaoDaTelaAntigaTest` ganhou 2 casos (sem token: nada se mexe e a grade volta;
   o token de outro formulário não serve), vermelhos antes do conserto; o caso do dia
   onde se soltou passou a mandar o token.
2. **O bit `remarcavel` prometia mais do que confere.** É a pergunta do atendimento, e
   não a de que o remarcar passa: a família descompassada e o agendamento sem serviço
   saem `true` e dão 409. A exclusão é de propósito (eles se desmarcam, e o mesmo bit
   esconde o Desmarcar), e a janela da tela nova mostra a frase do 409. **Feito:** os
   comentários (`GradeDaAgendaJson`, `GradeDaAgendaService`, `HorarioDaApi`, o tipo
   `Horario` e o `seMove`) dizem o que o bit cobre e o que não cobre, e
   `testAFamiliaDescompassadaSaiRemarcavelEORemarcarAindaRecusa` prende a fronteira: a
   família do banco com dois horários num serviço de um sai `true`, e
   `conferirFamilia` a recusa. Se o bit passar a cobrir o tamanho, ele fica vermelho.
3. **O caso "bloqueio e livre sem o bit" era vazio.** Montava a coluna sem o mapa, e o
   bit calculado antes do retorno do bloqueio passava verde — e o mapa de verdade TEM
   a linha do fechamento (`true`, família de si mesma). **Refeito:** o caso monta a
   coluna com o mapa (e um controle: o mesmo id, como agendamento, recebe o bit), e
   `testOFechamentoDeVerdadeNaoDizRemarcavel` passa pela receita com um fechamento no
   dia e confere que o mapa do serviço tem a linha dele.
4. **Nada ficava vermelho se o `mod-cadastros.js` deixasse de mandar o
   `datasolicitada`** (nem o token). **Feito:** `frontend/src/telas/remarcarDaTelaAntiga.teste.ts`
   roda as duas funções DO ARQUIVO SERVIDO (lido pelo `import.meta.glob`, como o
   `menu-do-erp.teste.ts` lê o `module.config.php`) com um jQuery de mentira sobre o DOM
   do happy-dom: o modal manda o token e o dia que se vê; o arrastar manda o token; a
   recusa troca a grade pela da resposta; e o layout serve o arquivo numa versão de cache
   a partir de 165 (o piso: com o token exigido, o arquivo velho em cache recusaria todo
   remarcar), lendo só o código do layout. Não prova o jQuery de verdade nem o navegador.
5. **A suíte PHP rodou só os vizinhos** (400 casos), e o resumo podia ser lido como a
   suíte inteira. **Feito:** a suíte inteira, nos dois bancos, depois destes consertos
   (os números estão no roadmap).
6. **O D420 contava linhas como chamadas.** São quinze chamadas em onze linhas;
   corrigido lá e na pergunta do lote.

**Mutantes novos**, um de cada vez, no MySQL, restaurados e conferidos pelo sha256, todos
vermelhos: o arrastar sem conferir o token; a recusa do arrastar sem a grade; o ajudante
do token sempre aceitando (os 5 casos de token do modal e do arrastar); o bloqueio
publicando o bit (os 2 casos do bloqueio); o livre publicando o bit; e o bit passando a
cobrir o tamanho da família. No front, contra o caso novo: o modal sem o
`datasolicitada`, o arrastar sem o token, o arrastar lendo o token de outro seletor, o
layout de volta ao 164 e a linha do layout só comentada.

**O que fica:** as outras escritas da tela antiga da agenda — marcar, desmarcar, a
chegada, fechar, abrir, remover bloqueio — seguem sem token e sem a barreira de origem
(`OrigemDoPedido`), como os botões do D260; o `SameSite=Lax` do cookie de sessão é o
que as cobre. Não é deste item.

#### 12/10/2026 — A TELA ANTIGA REMARCA PELA DA TELA NOVA, e leva a chegada — pedido do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 108 do dono** (o LOTE-11-10, a opção "a"): *"A tela antiga grava como a nova -- a trava nas
quatro escritas (marcar, chegada, desmarcar, remarcar), e o remarcar e o arrastar pelas recusas da tela nova,
numa operação só, com o arrastar gravando os três horários seguidos. Diferente da tela nova, quem já chegou
continua podendo ser remarcado, e a chegada vai junto para o horário novo: é a correção de quem atendeu e
quando."* Era o que faltava aqui: na tela antiga, o horário fechado, o fora da grade, o pedido malformado, a
chegada perdida, a família descompassada e a falta de transação (a trava está no D219, o arrastar no D217).

##### O conserto

O modal (`remarcarSalvarAction`) e o arrastar (`remarcarDragAndDropSalvarAction`) deixaram de "marcar a nova,
depois apagar a velha" com a `RegraDeAgendamento` só: os dois passam por
`AgendasController::remarcarComoATelaNova`, que é a `RemarcacaoDeAgendamento` da API -- trava a raiz e o
destino, apaga a velha, confere, marca, ou desfaz tudo -- com um modo novo, `$levaOAtendimento`, só da tela
antiga:

- **o atendimento não recusa**: a família passa pela `RegraDaRemarcacao::conferirTamanho` (que saiu de
  `conferirFamilia` sem mudar) e não pela pergunta da chegada; a família descompassada continua recusada, com a
  frase da tela nova ("desmarque e marque de novo");
- **a chegada e a saída vão junto**: as linhas novas recebem a chegada e a saída das velhas, horário por
  horário, na ordem das horas -- a família com só um horário chegado continua assim, e nenhuma mensagem de "Seu
  Cliente Chegou!" sai (quem a manda é o `checkin()`, e ninguém chegou de novo).

Antes da remarcação, a tela antiga confere o que a tela nova confere: a forma do pedido
(`RegraDaRemarcacao::conferirPedido` -- hora em HH:MM, sem repetir, em ordem; observação até 255, sem emoji), na
frase desta tela ("Informe os campos obrigatórios: - Um horário veio repetido"); o dia que não é dia ("- Data", e
não um 500); e o destino que pode receber o agendamento -- ativo e na agenda,
`RegraDaMarcacao::colaboradorRecebe`, o 404 da API --, que a lista do modal já era a única a esconder. A linha de
agenda fechada (sem cliente) e o agendamento sem serviço, num POST escrito à mão, deixaram de dar 500.

Passou a valer, então, na tela antiga: o horário fechado, o fora da grade, as férias e o dia sem agenda recusam;
a hora repetida e a malformada recusam; o agendamento anda sobre o próprio lugar (era "10:20 não está mais
disponível", pelo próprio agendamento); o mesmo lugar não grava nada; a recusa depois de a família sair a devolve,
com o mesmo id e sem linha no arquivo de exclusões; e a chegada e a saída vão junto. O JS não mudou: as respostas
têm a mesma forma (`sucesso`, `mensagem`, `tabela`), e o arrastar continua recebendo a grade também na recusa.

**Fica como estava**, e não é desta resposta: remarcar para o passado vale; a origem vira "web"; a lista de
serviços do colaborador de destino não é conferida; a comanda aberta não acompanha (D221) -- as mesmas decisões
que a API deixou com o dono em 23/09/2026 (acima). E o aplicativo, que o dono deixou como está.

##### Provado

- `AgendaAntigaComoANovaTest` (novo, 24 casos, com pontos de salvamento: o rollback de dentro vale dentro do
  teste), as ações da tela antiga despachadas em processo, nos dois bancos: o modal recusa o horário fechado, o
  fora da grade, a hora repetida, a família descompassada (que fica com os dois horários) e o destino fora da
  agenda; anda sobre o próprio lugar; no mesmo lugar não grava; a recusa depois da remoção devolve a família com o
  id e sem rastro; leva a chegada, a saída e a chegada de cada horário da família misturada; o arrastar leva a
  chegada e devolve a grade na recusa do horário fechado; e o remarcar espera a trava da raiz que outra conexão
  segura, parando no `FOR UPDATE`. `RemarcacaoDeAgendamentoTest` ganhou 2 casos (o modo novo leva a chegada e a
  saída de cada posição, relidas do banco; e continua recusando a família descompassada), `RegraDaRemarcacaoTest`
  1 (`conferirTamanho` não olha o atendimento), e o `D237RestosDoPdvEDaAgendaTest` passou a pôr na agenda o
  colaborador de destino do seu controle -- o gerente da fixture é `nenhuma`, e o modal nunca o listou.
- Os vizinhos: 702 casos (as quatro escritas, a remarcação, o fechamento, o reabrir, o recorte por unidade do D237,
  o check-in do D190 e do D422, o modal dos horários marcados do D436, a grade, o Profissional Parceiro, a barreira de
  origem), verdes no MySQL e no PostgreSQL, em série -- 701 e o que pede o histórico da produção, pulado como sempre.
- Os mutantes, um de cada vez, no MySQL, restaurados e conferidos pelo sha256: estão na tabela do D219 (12/10/2026).

#### 12/10/2026 — A VERIFICAÇÃO: o título, as guardas sem caso e o "sumiu" na tela

A verificação adversarial do item achou aqui:

1. **O título dizia CORRIGIDO, e o corpo, que as decisões do dono continuam.** Remarcar para o passado, a origem que
   vira "web" e a lista de serviços do colaborador de destino ficaram "como a tela antiga faz, até o dono decidir" em
   23/09/2026, e nunca foram ao lote; a comanda aberta é o D221. **Feito:** o título voltou a PARCIAL, a linha "Lido de
   novo" diz o que falta, e a pergunta foi ao lote (`AGENDA-ANTIGA-E-REGUA (a verificação, o D216)`), recomendando que
   a origem fique a de antes.
2. **Três guardas novas sem caso**, que esta seção dava como provadas: a linha de agenda fechada no modal e no
   arrastar (o "deixou de dar 500"); o agendamento sem serviço no modal (a unidade do serviço, quando o colaborador
   muda) e no arrastar (os períodos, em `horasDoArrasto`); e o ramo da observação da forma do pedido ("sem emoji",
   "com até 255"). **Feito:** `AgendaAntigaComoANovaTest` ganhou um caso para cada -- o modal e o arrastar com o id da
   linha de fechamento recebem "Não foi possível localizar o horário." e o bloqueio fica; o sem serviço remarcado para
   outro colaborador, e o arrastado, recebem a frase da remarcação ("O agendamento não tem serviço, e não se remarca:
   desmarque e marque de novo."), e nada se mexe; o marcar com emoji e o remarcar com 256 caracteres recebem as
   frases desta tela, e nada se grava.
3. **O "sumiu" da remarcação não tinha caso na tela** (o achado é do D219, com a mesma frase): sem o `case SUMIU` de
   `remarcarComoATelaNova`, o modal respondia "Remarcado com sucesso." sem gravar nada. **Feito:** o modal e o arrastar
   do agendamento que outro pedido apagou dizem a frase de atualizar a agenda, e o arrastar devolve a grade.
4. **O filho órfão** (o D219, abaixo): o remarcar dele, que a trava respondia "sumiu" para sempre, passou a ser 409
   com o remédio -- *"O primeiro horário deste agendamento não existe mais, e ele não se remarca: desmarque e marque de
   novo."* --, na tela antiga e na API (`RemarcacaoDeAgendamento::ORFAO`).

Os casos, os números e os mutantes estão no D219 (a verificação, 12/10/2026).

#### 07/11/2026 — A ORIGEM FICA A DE ANTES (a fatia 107, AGENDA-ORIGEM-E-GRADE) — pela resposta 156 (b) do dono, sob o pedido *"Atualize o roadmap e depois Continue de forma autonoma"*

**A resposta 156** (`remarcar-passado-origem-destino`, a opção "b", 12/10/2026): *"A origem fica a de antes, e o resto
como está"* -- o agendamento do aplicativo continua dizendo "aplicativo" depois de remarcado; remarcar para o passado
continua valendo, e o colaborador de destino continua sem a conferência da lista de serviços. Era a última decisão que
faltava aqui; a comanda aberta é o D221, à parte.

**Conferido antes:** o defeito existia. `RemarcacaoDeAgendamento::remarcar` montava o pedido sem a origem, e
`AgendaService::agendar` gravava `setOrigem("web")` fixo. No `franquiabv_dev`, só SELECT de contagem: 1.834.778 agendamentos
"web", 1.000.580 "app" e 1.428 sem origem (todos raízes); nenhum filho com origem diferente da raiz.

**O conserto.** `AgendaService::agendar` ganhou o quarto argumento `string $origem = 'web'` -- o marcar continua "web",
que é o que o ERP escreve --, e a remarcação passa a da RAIZ velha, lida antes do `desmarcar`. Vale para as três portas,
que passam todas pela `RemarcacaoDeAgendamento`: a API (a tela nova), o modal e o arrastar da tela antiga. A origem vazia
das 1.428 raízes antigas fica vazia ("a de antes", qualquer que seja). **A origem é argumento, e não chave do pedido**, ao
contrário do que a triagem propunha (`$request['origem']`): o marcar da tela antiga (`AgendasController::agendarAction`)
entrega o POST cru ao serviço, e um `origem=app` escrito à mão gravaria agendamento do balcão como se fosse do aplicativo.
O passado e o destino ficam como estavam (a mesma 156). O aplicativo não usa o `agendar` (grava por `AplicativoService`).

##### Provado

- `RemarcacaoDeAgendamentoTest` ganhou 3 casos: a família do aplicativo remarcada pelo serviço que a API usa continua
  "app" nas duas linhas, relidas do banco; a mesma família remarcada pela ROTA da tela nova
  (`POST /adm/api/agendamentos/:id/remarcacao`, o `Adm\AgendamentosController::remarcacaoAction` despachado em processo
  pelo `ControllerEmProcesso`) sai com 200, a resposta publica "app" em cada horário e o banco também; e, levando o
  atendimento (o modo da tela antiga), a origem vazia continua vazia. O caso da rota entrou pela verificação da fatia,
  que achou o primeiro dito "pela API" chamando o serviço direto, sem o despacho.
- `AgendaAntigaComoANovaTest` ganhou 3: o modal e o arrastar do agendamento do aplicativo gravam o horário novo "app"; e
  o marcar com `origem=app` no POST continua gravando "web".
- Os três arquivos da fatia (com o `GradeDaAgendaApiTest` do D324), 98 casos depois da verificação (eram 97), verdes no
  MySQL e no PostgreSQL, em série; o ciclo inteiro está no roadmap (07/11/2026, a fatia 107).
- Mutantes, um de cada vez, nos dois bancos, devolvidos pelo sha1: a remarcação sem passar a origem (O1) e o `agendar`
  com "web" fixo de novo (O2) deixam os 4 casos da origem vermelhos; o `agendar` lendo `$request['origem']` (O3, a forma
  da triagem) deixa vermelho o do marcar com a origem no POST; e a origem vazia trocada por "web" (O4, `?:` no lugar de
  `??`) deixa vermelho o da origem vazia. Depois da verificação, de novo nos dois bancos: a remarcação sem a origem (R1,
  o O1 repetido) deixa 5 vermelhos, o caso da rota entre eles; e a resposta da rota publicando "web" fixo
  (`AgendamentoJson::resumo`, R2) deixa vermelho só o caso da rota.

**O que fica:** a comanda aberta que não acompanha a remarcação (D221); a conferência da lista de serviços do destino, que
a 156 (b) deixou como está.

### D217 — O arrastar da tela antiga grava o terceiro horário em cima do segundo — ANOTADO em 23/09/2026 — CORRIGIDO em 12/10/2026

**Achado lendo `AgendasController::remarcarDragAndDropSalvarAction`** para a
remarcação da tela nova.

Para serviço de mais de um horário, o arrastar calcula os seguintes a partir do
primeiro, somando o tempo de atendimento — e **nunca anda o ponto de partida**:

```php
$horarioAnterior = $request['hora'][0];

for ($i = 1; $i < $agenda->getProduto()->getPeriodos(); $i++) {
    $time = DataHora::dataHoraBr_to_dateTime($request['data'], $horarioAnterior);
    ...
}
```

Com `periodos = 2` o erro não aparece. Com 3, os horários saem X, X+T, **X+T**:
o terceiro cai no segundo. A `RegraDeAgendamento` não pega — a conferência de
"seguidos" pula as horas iguais (D216) —, e o agendamento de uma hora e meia
fica gravado como de uma hora, com duas linhas no mesmo horário.

**Na cópia:** 18 famílias de três linhas com dois filhos no mesmo horário.

**Direção do conserto:** `$horarioAnterior = $request['hora'][$i];` dentro do
laço — uma linha. Não entrou junto porque muda a tela antiga das 32 lojas, e é
decisão do dono; as 18 famílias gravadas continuam como estão.

#### 12/10/2026 — OS TRÊS HORÁRIOS SEGUIDOS — pela resposta 108 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**O conserto.** A resposta 108 pediu "o arrastar gravando os três horários seguidos". O arrastar passou a montar
os horários em `AgendasController::horasDoArrasto`: o da célula onde o bloco caiu, e cada um dos seguintes UM
PASSO DEPOIS DO ANTERIOR -- X, X+T, X+2T --, e não sempre a partir do primeiro. Só o primeiro vem do pedido, e só
ele é conferido ali (HH:MM); hora torta, ou o colaborador sem passo, seguem como vieram, e a remarcação recusa com
a frase dela, e não com o 500 do `DateInterval`. E o arrastar grava pela remarcação da tela nova (o D216), que
recusaria a hora repetida de qualquer jeito.

As 18 famílias de três linhas com dois filhos no mesmo horário, gravadas antes, continuam como estão -- como este
débito já dizia.

**Provado:** `AgendaAntigaComoANovaTest::testArrastarServicoDeTresHorariosGravaOsTresSeguidos` -- o serviço de
três horários das 10:00 arrastado para as 11:00 fica em 11:00, 11:20 e 11:40, nos dois bancos; com o laço de antes
devolvido (o passo somado ao primeiro), o caso fica vermelho (o M12 da tabela do D219).

### D218 — O arquivo de exclusões não distingue remarcação de cancelamento — ANOTADO em 23/09/2026

Remarcar é apagar e gravar de novo — pela tela antiga, pelo aplicativo e agora
pela API —, e o que fica da família velha é a linha em `agendas_exclusoes`,
igual à de um cancelamento. O arquivo **não guarda o id original** (a raiz
arquivada ganha id novo; os filhos arquivados apontam para uma raiz que não
existe mais) nem marca nenhuma de remarcação. E a produção o esvazia a cada 60
dias (`evt_agendas_exclusoes_limpar`).

**Medido na cópia, por heurística:** uma exclusão foi contada como remarcação
quando uma raiz nova, do mesmo cliente e serviço, nasceu até 10 segundos antes
dela. Das 11.275 exclusões pelo painel, 4.046 (umas 67 por dia); das 4.891 pelo
aplicativo, 84. Nenhum relatório consegue separar as duas coisas sem repetir
essa heurística.

**Direção:** uma coluna de motivo (ou o id da família nova) no arquivo — é
mudança de esquema nos dois bancos, e decisão do dono (pergunta Q13 da
remarcação).

### D219 — A trava da remarcação só valia entre duas remarcações: as outras escritas gravavam sem conferir de novo — CORRIGIDO em 24/09/2026 (API); a tela antiga e o aplicativo continuam — PARCIAL, lido em 05/10/2026 — PARCIAL em 12/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: a tela antiga e o aplicativo gravam sem trava; o índice único pede resolver antes as 34 duplicatas e os 986 agendamentos sobre fechamento.

A remarcação da API trava a raiz da família e o colaborador de destino
(`SELECT ... FOR UPDATE`, D216), e **só ela pega essa trava**. Duas remarcações
sobre o mesmo agendamento, ou para o mesmo colaborador, esperam uma pela outra,
e a segunda confere de novo depois de pegá-la. Quem grava sem a trava espera a
linha soltar e grava **sem conferir de novo** — e isso inclui a própria API, e
não só a tela antiga e o aplicativo:

- **o check-in no meio de uma remarcação** lê a família antes, espera, e o
  `UPDATE` cai em zero linhas, que a remarcação acabou de apagar. Responde 200
  com a chegada registrada — e a mensagem "Seu Cliente Chegou!" vai para a
  fila —, e a família nova fica sem a chegada: a chegada perdida que o D216
  fechou na remarcação, voltando por outra porta;
- **o desmarcar no meio de uma remarcação** responde 200, grava no arquivo de
  exclusões linhas que a remarcação já tinha arquivado, e o agendamento
  remarcado continua na agenda;
- **o marcar para o mesmo colaborador e horário** confere antes do commit da
  remarcação e grava depois: o horário fica com dois agendamentos.

A tela nova trava as duas ações do painel entre si, e isso vale só dentro de
uma aba. **A tela antiga e o aplicativo não pegam trava nenhuma**, e `agendas`
não tem índice único por colaborador e horário.

A primeira versão deste débito dizia que "dois pedidos da API sobre o mesmo
agendamento, ou para o mesmo colaborador, esperam um pelo outro". Esperam — e
não conferem de novo. A revisão adversarial de 24/09/2026 achou, lendo o código.

**Na cópia:** 11 horários com dois agendamentos em 2024, 21 em 2025 e 2 em
2026.

#### CORRIGIDO em 24/09/2026 NA API — pedido do dono: *"aplicar o frontend no react para como foi desenhado no claude desing"*

As quatro escritas da agenda pela API pegam **a mesma trava**
(`ISCadastro\Service\TravaDaAgenda`), numa transação, e só leem e conferem
depois de pegá-la (`ISCadastro\Service\EscritasDaAgenda` e a
`RemarcacaoDeAgendamento`):

- o **marcar** trava o colaborador antes de conferir o horário: quem marca no
  meio de uma remarcação para o mesmo colaborador espera, e confere o que ela
  deixou;
- o **check-in** e o **desmarcar** travam a raiz: o agendamento que outro pedido
  apagou enquanto este esperava responde **404**, e não 200 — nem chegada numa
  linha que não existe, nem arquivamento repetido;
- a **remarcação** trava as duas, na mesma ordem — raiz, depois colaborador —, e
  ninguém pega o colaborador antes de uma raiz: dois pedidos não se esperam
  para sempre.

**E o check-in deixou de desfazer chegada.** `AgendaService::checkin()` alterna
cada horário que recebe, e a API conferia só o horário clicado e mandava a
família inteira: a família com um horário já chegado (105 na cópia) tinha a
chegada desfeita, e o duplo envio podia desfazer parte da do primeiro — o
segundo pedido lia o horário clicado antes do commit do primeiro e o resto
da família depois. O cabeçalho do endpoint prometia idempotência. Agora a família é relida depois da trava, e o
serviço recebe só os horários sem chegada.

**O que continua:** a tela antiga e o aplicativo gravam pelo `AgendaService` sem
trava nenhuma, e a tabela não tem índice único por colaborador e horário.
Levar a trava ao serviço compartilhado mudaria a tela das 32 lojas e o contrato
congelado do aplicativo (D5); o índice pede resolver antes as 34 duplicatas e os
986 agendamentos que dividem o horário com uma linha de fechamento.

##### Provado

- `EscritasDaAgendaTest` (novo, 20 casos), nos dois bancos: cada escrita espera a
  trava que outra conexão segura, e para NELA — o erro tem de ser o do
  `FOR UPDATE`, e não o de um `UPDATE` ou `DELETE` adiante, que esperaria pela
  mesma linha e deixaria o caso verde sem trava nenhuma. O agendamento apagado
  por fora responde "sumiu"; a chegada registrada por outro pedido não se
  desfaz; a família misturada se completa.
- Arrancadas uma a uma, a trava e as treze peças das escritas deixam cada uma
  o seu caso vermelho no MySQL (seis delas entraram com a segunda revisão,
  abaixo); as quatro de trava, também no PostgreSQL, que escreve o erro da
  trava de outro jeito. Os 24 mutantes da remarcação seguem mortos depois de
  a trava e a leitura das linhas novas saírem dela. Uma delas mostrou que um caso meu não
  media o que dizia: o de "outro pedido registrou a chegada" ficava verde sem
  reler a família — a entidade velha dizia "sem chegada", e alternar a levava ao
  que o banco já tinha. Quem mede o reler é o caso da chegada desfeita pela tela
  antiga, em que a entidade velha diz "chegou".
- A tela trata o 404 da chegada como o da remarcação: fecha o painel, relê a
  grade e avisa, em vez de mostrar a recusa num painel de horário que já mudou.
- **Uma segunda revisão adversarial**, só deste passo — dois revisores e um
  cético por dimensão, nenhum agente morto —, confirmou 5 achados e refutou 2.
  Quatro eram de instrumento: nenhum caso lia a ORDEM trava → conferência (com as
  duas linhas trocadas no marcar, o D219 voltava e tudo ficava verde); a trava do
  colaborador na remarcação não tinha caso; todos os casos passavam a raiz, e a
  tela manda o filho no segundo bloco de uma família partida; e o 404 da chegada
  com o painel noutro horário não era medido. Hoje a ordem é lida no registro de
  SQL — o primeiro `SELECT` de cada escrita tem de ser o `FOR UPDATE` —, e cada um
  ganhou o caso que fica vermelho sem o conserto. O quinto era defeito, e antigo:
  **o marcar respondia o dia seguinte para quem ia às 23:30**, o mesmo que a
  remarcação tinha. A leitura das linhas novas passou a morar num lugar só
  (`FamiliaRecemGravada`), relida do banco, para as duas.

#### 12/10/2026 — A TELA ANTIGA PEGA A TRAVA — pela resposta 108 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 108** (a opção "a"): a trava nas quatro escritas da tela antiga -- marcar, chegada, desmarcar,
remarcar --, e o aplicativo fica como está, sem a trava.

##### O conserto

As quatro escritas da tela antiga pegam a mesma `TravaDaAgenda` da API, numa transação, e só leem e conferem
depois dela:

- **o marcar** (`agendarAction`) é o `EscritasDaAgenda::marcar` da API, inteiro: trava o colaborador, lê o dia,
  confere o destino (as recusas do D224 -- férias, dia sem agenda, fora da grade, fechado) e a regra antiga, e
  grava. O `proximo` que a tela pré-escolhe no modal vem do mesmo `agendar` (o `marcar` passou a devolvê-lo).
  Antes, a forma do pedido e o dia (o D216);
- **o desmarcar** (`desmarcarAction`) é o `EscritasDaAgenda::desmarcar`, com a trava da raiz, e com UMA diferença
  da API: quem já chegou continua se desmarcando aqui (`$recusaQuemChegou` falso) -- a resposta levou a trava, e
  não aquela recusa. O agendamento que outro pedido apagou enquanto este esperava é "Não foi possível localizar o
  horário: ele pode ter sido remarcado ou desmarcado. Atualize a agenda e tente de novo." (a frase do D422), e nada
  se arquiva de novo; antes, "desmarcado";
- **a chegada** (`checkinAction`) é o `EscritasDaAgenda::alternarChegada`, novo: o botão continua um INTERRUPTOR
  (o D422), e as raízes dos horários que o clique alcança são travadas em ordem crescente de id -- dois cliques do
  mesmo cliente as pegam na mesma ordem. A raiz que outro pedido apagou no meio (a remarcação) é a mesma frase de
  atualizar, e nada se alterna -- alternar só o que sobrou deixaria o cliente meio chegado; e os horários são
  relidos depois da trava, e alternados pelo estado do BANCO. Antes, a chegada ia para as linhas que a remarcação
  acabara de apagar, a resposta dizia "Check-in realizado", e o aviso de "Seu Cliente Chegou!" ia para a fila;
- **o remarcar e o arrastar** são a `RemarcacaoDeAgendamento` (o D216): a raiz, depois o destino.

A ordem das travas continua a da API -- ninguém pega o colaborador antes de uma raiz.

##### O que resta, e por isso PARCIAL

- **O índice único por colaborador e horário**, que pede resolver antes as 34 duplicatas e os 986 agendamentos que
  dividem o horário com uma linha de fechamento: é do corte, e não mudou. Mudança de esquema.
- **O aplicativo** grava pelo `AgendaService` sem trava nenhuma -- e fica assim, por decisão do dono (a mesma
  resposta 108). Contra ele, a corrida continua.

##### Provado

- `AgendaAntigaComoANovaTest` (o do D216): cada escrita da tela antiga despachada em processo espera a trava que
  outra conexão segura, e para no `FOR UPDATE` -- o marcar na do colaborador, o desmarcar, a chegada e o remarcar
  na da raiz --, e não num `DELETE`, `UPDATE` ou `INSERT` adiante, que esperaria pela mesma linha; o marcar recusa
  o horário fechado, a hora repetida e o dia que não é dia; o desmarcar do que outro pedido apagou diz que sumiu, e
  não arquiva; quem já chegou se desmarca; e o botão da chegada marca e desmarca.
- `EscritasDaAgendaTest` ganhou 7 casos: o interruptor marca e desmarca; alterna pelo banco relido depois da
  trava; o que sumiu não alterna nada, nem a outra família do cliente; a chegada espera a trava e a pega antes de
  ler (o primeiro `SELECT` é o `FOR UPDATE`); o desmarcar sem a recusa apaga quem chegou; e o marcar devolve o
  próximo horário.
- Os 702 casos vizinhos (ver o D216), verdes no MySQL e no PostgreSQL, em série.
- **Os mutantes** do item, um de cada vez, no MySQL, cada um restaurado e conferido pelo sha256:

| o mutante | o caso vermelho |
|---|---|
| o marcar pela regra antiga e o `agendar` direto (sem a `EscritasDaAgenda`) | o marcar espera a trava -- parou no `INSERT`, e não no `FOR UPDATE`; e, sem esse caso, o horário fechado se marca |
| o marcar sem conferir o dia | o dia que não é dia dá exceção (o 500), e o horário fechado também cai |
| a forma do pedido sempre aceita | a hora repetida, no marcar e no remarcar |
| o desmarcar pelo serviço direto, sem a trava | o desmarcar espera a trava -- parou no `DELETE`; e, sem esse caso, o que outro pedido apagou responde "desmarcado" |
| o desmarcar recusando quem chegou | quem já chegou continua se desmarcando |
| a chegada pelo serviço direto, sem a trava | a chegada espera a trava -- parou no `UPDATE` |
| o `alternarChegada` sem reler depois da trava | alterna pelo banco |
| o `alternarChegada` ignorando a raiz que sumiu | o que sumiu não alterna nada |
| a tela antiga sem levar o atendimento | quem chegou, quem saiu, a família misturada e o arrastar com chegada recusam com o 409 da API |
| a remarcação sem passar a chegada às linhas novas | os mesmos quatro, e o caso do serviço |
| a chegada toda tirada do primeiro horário | a família misturada, na tela e no serviço |
| o arrastar somando o passo ao primeiro (o D217 de volta) | os três horários seguidos |
| o destino sem conferir se recebe | o destino fora da agenda |
| o tamanho da família sem conferência | a família descompassada, na tela, na regra e no serviço (5 casos) |
| a remarcação sem a trava da raiz | o remarcar da tela antiga espera a trava; e, sem ele, a ordem das travas e a raiz apagada do serviço |
| (o fechar, o reabrir: ver o D111 e o D325) | |

Os casos de trava sem trava fecham o `EntityManager` (o `flush` que esperou a linha), e os seguintes do arquivo
caem em cascata -- o primeiro erro é o único que vale; por isso os três mutantes de trava rodaram de novo sem o
caso de trava, e o que ficou vermelho está depois do "e, sem esse caso".

#### 12/10/2026 — A VERIFICAÇÃO: o "sumiu" da chegada e da remarcação na tela, e o filho órfão

Dois achados da verificação adversarial do item, os dois consertados:

1. **Nenhum caso ficava vermelho se a tela antiga voltasse a dizer "feito"** quando a remarcação ou a chegada acham
   o horário sumido -- o caso central deste débito. O desmarcar tinha caso; o `D190CheckinEmDuasFranquiasTest` cobre
   o outro ramo da mesma frase (o horário clicado que já não está entre os do cliente, antes da trava); a remarcação
   e a chegada, não. **Feito:** `AgendaAntigaComoANovaTest` ganhou o modal e o arrastar do que outro pedido apagou (a
   frase de atualizar, e nada gravado), e a chegada do que outro pedido apagou NO MEIO -- entre a leitura dos
   horários do cliente e a trava: um ouvinte do `postLoad` apaga a família logo depois de a ação a ler, que é o que
   a remarcação de outro caixa faz, sem duas conexões. A resposta é a frase de atualizar, e a outra família do
   cliente não se alterna.
2. **O filho órfão passou a responder "sumiu" para sempre.** A raiz saiu por fora e o filho ficou -- 6 no
   `franquiabv_dev`, de 2020-04-06 a 2024-11-04, cada um de uma família, e a grade das duas telas os desenha. Pela
   trava só da raiz, o desmarcar e a chegada dele eram "sumiu", e atualizar a agenda não mudava nada; na tela antiga,
   a chegada do cliente no dia inteiro ficava recusada. Antes da trava (na API até 24/09, na tela antiga até 12/10)
   ele se desmarcava e chegava. **Feito:** `TravaDaAgenda::daFamiliaDe` trava a raiz -- ou, quando ela não existe
   mais e o horário sim, o próprio horário, pela chave primária (os irmãos pelo `id_pai` seriam um `FOR UPDATE` que,
   sem o índice do D220, trava no MySQL cada linha que varre: a tabela inteira). O que outro pedido apagou continua
   "sumiu": o desmarcar e a remarcação apagam a família inteira na mesma transação. O desmarcar e a chegada das duas
   telas (`EscritasDaAgenda::desmarcar`, `registrarChegada` e `alternarChegada`) passam por ela: o órfão se desmarca,
   e vai para o arquivo, e chega com os outros horários do cliente. O remarcar, não: sem a raiz não há de onde tirar
   o cliente e o serviço do pedido novo, e a resposta é 409 com o remédio (o D216). A ordem das travas é a mesma: o
   órfão fica no lugar da raiz dele, antes do colaborador.

##### Provado (a verificação)

- `AgendaAntigaComoANovaTest`: 36 casos (eram 24), com os 12 novos dos dois achados acima e dos do D216.
- `EscritasDaAgendaTest` ganhou 2 (o órfão se desmarca e vai para o arquivo; a chegada dele se registra);
  `RemarcacaoDeAgendamentoTest`, 1 (o órfão é 409 com a frase, e fica); `ReaberturaDaAgendaTest`, 3, e
  `GradeDaAgendaApiTest`, 1 (o D325 e o D324).
- Os vizinhos da agenda, 750 (749 verdes e o que pede o histórico da produção, pulado como sempre) no MySQL das fixtures e 750 (o mesmo pulado) no PostgreSQL, em série.
- **Os mutantes da verificação**, um de cada vez, restaurados e conferidos pelo sha256:

| o mutante | o caso vermelho |
|---|---|
| o Desmarcar da tela antiga sem recusar a linha de fechamento | a tela antiga não desmarca a linha de bloqueio (o D325) |
| o `DELETE` da API sem recusar a linha de fechamento | a API não desmarca a linha de bloqueio (o D325) |
| a chegada da API sem recusar a linha de fechamento | a API não registra chegada na linha de bloqueio |
| a remarcação da tela antiga sem o `case SUMIU` | o modal e o arrastar do que outro pedido apagou |
| a chegada da tela antiga sem o "sumiu" | a chegada do que outro pedido apagou no meio |
| a família só pela raiz (o órfão de volta a "sumiu") | 6 casos: o desmarcar, a chegada e o remarcar do órfão, na tela e nos serviços |
| a remarcação sem a recusa do órfão | o órfão não se remarca, na tela e no serviço |
| o `alternarChegada` só pela raiz | a chegada com um órfão no dia |
| o modal sem recusar a linha de fechamento | o modal com o id do bloqueio (o D216) |
| o arrastar sem recusar a linha de fechamento | o arrastar com o id do bloqueio (o D216) |
| o modal sem a guarda do serviço nulo na conferência da unidade | o sem serviço para outro colaborador (o D216) |
| o modal lendo a unidade do serviço nulo | o mesmo caso, com o erro (o D216) |
| o arrastar lendo os períodos do serviço nulo | o arrastar sem serviço (o D216) |
| a forma do pedido sem o ramo da observação | o emoji no marcar e os 256 caracteres no remarcar (o D216) |
| as duas frases da observação trocadas | os mesmos dois (o D216) |
| a consulta do dia sem o desempate pelo id | no PostgreSQL, o horário com bloqueio e agendamento; no MySQL, nenhum, e não há como: lá a ordem do id sai do plano (o D324) |

### D220 — Achar a família de um agendamento varre a tabela inteira — ANOTADO em 23/09/2026 — MEDIDO em 08/10/2026, na cópia: com o pool de 2 GB, ainda 2 s no MySQL — PREPARADO em 09/10/2026: o índice na migração do passo 0 e no `mysql.sql`, herdado pelo gerador; em produção, na janela do corte — PREPARADO, e na cópia em 09/10/2026: nos dois bancos dela

`selecionarHorariosFilhos` — que o `desmarcar` do serviço chama, e por ele
todo cancelamento e toda remarcação — procura com `id = X OR id_pai = X`, e
`id_pai` **não tem índice**. O EXPLAIN na cópia lê **3.981.606 linhas**.

A remarcação da API não chama a consulta uma segunda vez: acha a família pela
janela do dia do colaborador, que usa o índice dele. A do `desmarcar` fica, e
só muda mexendo no serviço.

**O que não foi medido:** quanto isso custa em produção. Na cópia a consulta
leva uns 3 s, com o buffer pool no padrão de fábrica de 128 MB — o mesmo
ambiente em que a grade levava 22 s e, com 2 GB, 0,90 s (D189). Antes de
criar o índice — mudança de esquema nos dois bancos —, medir com o buffer pool
do tamanho da produção.

#### MEDIDO em 08/10/2026: o buffer pool não explica os 3 s

Medido na cópia local, só leitura, com a suíte parada. A consulta é a que o código monta,
capturada do próprio Doctrine (`DebugStack`) na chamada de
`AgendaRepository::selecionarHorariosFilhos`:

```sql
SELECT a0_.id AS id_0, ..., a0_.fechamento AS fechamento_15
  FROM agendas a0_
 WHERE a0_.status = ? AND (a0_.id = ? OR a0_.id_pai = ?)   -- [true, X, X]
```

Dois parâmetros reais, escolhidos por contagem: um horário de agosto de 2026 com dois filhos
(a família tem três linhas) e um sem filho nenhum (uma linha). O cronômetro é o do método de
verdade, pelo PHP do contêiner, com o `EntityManager` limpo a cada rodada, 9 rodadas por
parâmetro depois do aquecimento -- e o SQL cru, pela mesma conexão, deu o mesmo. O pool foi
lido antes (134.217.728), posto em 2 GB por `SET GLOBAL` (efetivo 2.147.483.648, 16 pedaços
de 128 MB) e devolvido no fim (conferido: 134.217.728, redimensionamento concluído).

| MySQL 8.4.11, `agendas` com 4.000.085 linhas (291 MB de dado) | com filhos (3 linhas) | sem filhos (1 linha) |
|---|---|---|
| pool de 128 MB, o desta máquina: mediana / pior | 2,78 s / 3,49 s | 2,82 s / 3,06 s |
| pool de 2 GB, a tabela inteira na memória: mediana / pior | 2,29 s / 2,84 s | 2,05 s / 2,49 s |

**Dezesseis vezes mais pool tira de um quinto a um quarto do tempo -- não os 96% do D189.**
Com 128 MB, cada varredura lê do disco a tabela inteira de novo: 293.961.728 bytes no
`Innodb_data_read`, iguais na primeira e na segunda vez -- a tabela não cabe, e não existe
"quente". Com 2 GB, depois da primeira (3,19 s, 244 MB lidos), nenhum byte veio do disco nas
39 chamadas seguintes, e a consulta continuou em 2 s. O preço não é o disco: é avaliar 4
milhões de linhas numa thread só -- o `EXPLAIN ANALYZE` quente dá 2,1 s na varredura, de 2,5 s.

O plano, nos dois tamanhos: `type ALL`, nenhuma chave (o `OR` com `id_pai` impede usar só a
`PRIMARY`), 3.981.606 linhas estimadas; o `EXPLAIN ANALYZE` varre 4 milhões, e o
`performance_schema`, na cópia descartável abaixo, conta **4.000.085 linhas examinadas para
devolver 3** (ou 1).

No PostgreSQL 16, o mesmo dado (4.000.084 linhas, 377 MB), como `bvip_app`, com o RLS e
`app.inquilino_id = 1`, o comando preparado de novo a cada rodada (o plano sob medida, como o
PDO), e o `shared_buffers` que estava, 128 MB (não mexi: mudar pede reinício):

| PostgreSQL 16 (9 rodadas) | com filhos | sem filhos |
|---|---|---|
| execução: mediana / pior | 279 ms / 433 ms | 271 ms / 313 ms |
| `EXPLAIN (ANALYZE, BUFFERS)`: mediana / pior | 242 ms / 260 ms | 282 ms / 316 ms |

O plano é `Gather` com dois trabalhadores sobre `Parallel Seq Scan`: três processos, 1.333.360
linhas descartadas por cada um, e de 44 a 46 mil das 48 mil páginas lidas de fora do
`shared_buffers` em toda rodada -- tabela maior que um quarto dele é varrida num anel de
páginas e nunca fica nele. Ainda é a tabela inteira.

**O paralelo não explica a distância para o MySQL (corrigido na verificação, 08/10/2026).** O
texto dizia "dez vezes mais rápido porque reparte a varredura em três" -- não fecha. A mesma
consulta, como `bvip_app` e com o RLS, com `SET max_parallel_workers_per_gather = 0` só na
sessão (plano `Seq Scan`, um processo, nenhum trabalhador), 9 rodadas depois do aquecimento:
mediana de **430 ms** (pior 490) com filhos e **413 ms** (pior 583) sem, contra 296 e 250 ms
com os dois trabalhadores na mesma rodada; a verificação, noutra rodada, mediu 557 a 996 ms num
processo (mediana 740). Os três processos rendem de 1,5 a 2,5 vezes; o resto da distância --
um processo do PostgreSQL ainda é de 3 a 7 vezes mais rápido que os 2,0 a 2,8 s do MySQL -- é
o custo por linha, e não o paralelismo. O número que importa para a produção é o de um
processo: lá são 4 vCPU divididos com os pedidos (ver a lista do fim).

**Com o índice, num banco descartável.** Copiei só `agendas`, com `observacao` e `cliente`
nulos, para `med_d220_descartavel` nos dois servidores, medi sem e com o índice proposto, e
apaguei os dois bancos no fim (conferido: nenhum sobrou no `SHOW DATABASES` nem no
`pg_database`). Sem o índice, a cópia reproduz o original: MySQL de 2,64 a 3,15 s, PostgreSQL
de 218 a 298 ms.

**O resto que ficou, e não se apaga com o banco (verificação, 08/10/2026):** o MySQL local grava
binlog (`log_bin` 1, `ROW`, `binlog_row_image FULL`, `binlog_expire_logs_seconds` de 30 dias), e
o `binlog.000046` guarda a cópia inteira -- do `CREATE DATABASE med_d220_descartavel` às
`Write_rows` das 4.000.085 linhas, o `ADD INDEX` e o `DROP DATABASE`, uns 164 MB --, até expirar,
perto de 07/11/2026. Sem dado pessoal: `cliente` e `observacao` foram copiados nulos, e o resto são
ids, horas, marcas e datas. Não dei `PURGE`: o mesmo arquivo guarda os eventos das suítes. No
PostgreSQL, `archive_mode` é `off`: o WAL se recicla, e nada fica.

| com o índice de `id_pai` | MySQL (pool de 128 MB) | PostgreSQL (RLS como no `bvip`) |
|---|---|---|
| plano | `index_merge`, `union(PRIMARY, IDX_AGENDAS_id_pai)`: 3 linhas examinadas | `BitmapOr` da `agendas_pkey` e do índice novo, o inquilino como condição do índice: 7 a 10 páginas |
| mediana / pior, 9 rodadas | 0,20 / 0,24 ms com filhos, 0,22 / 0,30 ms sem (`performance_schema`); a primeira, com o pool deslocado, 11,6 ms | 0,07 / 0,15 ms e 0,05 / 0,08 ms (`EXPLAIN`); pelo `psql`, com a ida e a volta, 0,5 ms |
| criar | 6 s, 47,6 MB | 2,7 s, 29 MB |

Dois cronômetros diferentes nesta tabela e nas de cima: sem o índice, o tempo é o do método
pelo PHP (consulta e hidratação); com o índice, o do `performance_schema` (MySQL) e o do
`EXPLAIN` (PostgreSQL), só o servidor. A ordem de grandeza não muda -- a razão é de uns 10.000
--, mas não é a mesma régua. E o "criar" foi medido na cópia estreita -- só `agendas`, com
`cliente` e `observacao` nulos, pool de 128 MB, a máquina parada --: na tabela inteira, o D189
mediu de **11 a 20 s** para criar um índice nas mesmas 4 milhões de linhas.

**Quantas vezes por dia (corrigido na verificação, 08/10/2026).** O arquivo que o `desmarcar`
grava (`agendas_exclusoes`) cobre de 17/06 a 16/08/2026: **19.575 chamadas**, 357,7 por dia em
julho, **547** no dia de pico (03/07 e 17/07 empatados) e **66** na hora de pico (03/07, das 16h).
A primeira conta dava 20.590, 375, 568 e 67: contava `COUNT(DISTINCT COALESCE(id_pai, id))`, e o
`id` desse arquivo é o auto-incremento DELE -- `AgendaService::arquivarAgendaExclusao` não grava
o id da agenda --, então cada família com filhos contava duas vezes (a raiz pelo id do arquivo,
os filhos pelo `id_pai`). A conta certa é pelas raízes: as 19.575 linhas com `id_pai` nulo, mais
os grupos de filhos sem raiz do mesmo colaborador arquivada até 2 s antes ou depois -- nenhum,
dos 1.015. Só contagem:

```sql
WITH r AS (SELECT colaborador, data_exclusao FROM agendas_exclusoes WHERE id_pai IS NULL),
     g AS (SELECT id_pai, MIN(colaborador) AS colaborador, MIN(data_exclusao) AS data_exclusao
             FROM agendas_exclusoes WHERE id_pai IS NOT NULL GROUP BY id_pai),
     sem_raiz AS (SELECT g.* FROM g WHERE NOT EXISTS (SELECT 1 FROM r WHERE r.colaborador = g.colaborador
                    AND r.data_exclusao BETWEEN g.data_exclusao - INTERVAL 2 SECOND AND g.data_exclusao + INTERVAL 2 SECOND)),
     chamadas AS (SELECT data_exclusao FROM r UNION ALL SELECT data_exclusao FROM sem_raiz)
SELECT DATE_FORMAT(data_exclusao, '%Y-%m') AS mes, COUNT(*) AS chamadas,
       ROUND(COUNT(*) / COUNT(DISTINCT DATE(data_exclusao)), 1) AS por_dia
  FROM chamadas GROUP BY 1 ORDER BY 1;
```

Cada chamada é uma varredura pela tela antiga, pelo aplicativo, pela API e pelo
remarcar do `agendar`; o Desmarcar da tela nova faz **duas** (`EscritasDaAgenda::familia` e o
`desmarcar` do serviço), e o registro de chegada da tela nova, uma. Em julho, no MySQL de
hoje, uns 15 minutos de um núcleo por dia só nesta consulta.

**A forma da família, para a saída sem índice.** Dos 119.442 filhos na cópia, 119.436 têm o
colaborador e o dia da raiz, nenhum tem outro, e 6 perderam a raiz -- o que a
`RemarcacaoDeAgendamento::familia` já supõe.

**O que este número NÃO diz sobre a produção:**

- o `innodb_buffer_pool_size` de produção continua sem ser lido (D189) -- mas aqui ele deixou
  de governar: com a tabela inteira na memória, a consulta segue em 2 s;
- a CPU: é um núcleo do i7-13700H deste laptop, num Docker Desktop (WSL2, 20 vCPU, 15 GB), com
  a máquina parada; os 4 vCPU da VPS nunca foram cronometrados, e lá a consulta disputa a CPU
  com o resto;
- o "disco" daqui é o cache da VM: os 294 MB vêm da memória, e num NVMe frio a linha dos
  128 MB seria pior;
- a tabela: a cópia para em 16/08/2026, e `agendas` ganha de 37 a 58 mil linhas por mês -- a
  varredura cresce com ela, perto de 1% ao mês;
- no PostgreSQL, o `shared_buffers` de produção e os dois trabalhadores paralelos, que numa VPS
  de 4 vCPU disputam os núcleos com os pedidos: lá a varredura tende ao número de um processo
  (aqui, 0,4 a 1,0 s), e não aos 0,3 s com dois trabalhadores livres num processador de 20 vCPU;
- a versão: aqui é o MySQL 8.4.11, e a produção é 8.4.7 (`docs/operacao-atual.md`; a tabela de
  `docs/ambiente-homologacao.md` que diz 8.0.46 foi corrigida logo abaixo dela, em 19/08/2026) --
  a mesma série, mas o `index_merge` que o otimizador escolheu com o índice só foi visto no
  8.4.11, e o D189 já mostrou o otimizador deixando de lado o índice bom;
- o tempo de criar o índice: os 6 s são da cópia estreita (ver acima); na tabela inteira, o
  D189 mediu de 11 a 20 s;
- é a consulta e a hidratação, não o pedido HTTP em volta.

**A decisão do índice foi ao lote (P8), com o DDL escrito e sem rodar.** Na verificação
(08/10/2026), a opção recomendada foi reescrita: pôr o índice no MySQL E uma linha à mão no
gerador do PostgreSQL faria o gerador emitir o mesmo nome duas vezes (ele herda todo `KEY` do
`mysql.sql` -- `agendas_IDX_AGENDAS_id_pai`, que o PostgreSQL guarda em minúsculas, com o
inquilino na frente, o nome exato da linha proposta), e o `psql -v ON_ERROR_STOP=1` pararia a
janela no passo 4 com "already exists", como a `sessoes` do D323. Conferido rodando uma cópia
do gerador sobre uma cópia do `mysql.sql` com o `KEY`: sai a linha
`CREATE INDEX agendas_IDX_AGENDAS_id_pai ON agendas (inquilino_id, id_pai);`, sem nada no
repositório mudar. O caminho que não colide é o do passo 0 do corte: uma migração com o `ALTER`
no laço dele e o mesmo `KEY` no `mysql.sql` de hoje (o portão do passo 0 exige as duas), e o
gerador o herda sem linha própria.

**Decidido pelo dono em 08/10/2026 (pergunta 50 da página do lote):** a opção (a), *"Criar na
janela, pelo passo 0 do corte"* -- "Uma migração com o ALTER no MySQL de produção antes do passo 1,
e o mesmo KEY no mysql.sql; o PostgreSQL o herda pelo gerador, sem linha à mão (que sairia com o
mesmo nome e pararia o passo 4). Nenhum código da aplicação muda."

#### O conserto, em 09/10/2026 — no repositório; aplicar continua sendo seu, no passo 0

O caminho é o do D308 e o do D323 juntos: o índice do D308 entrou no gerador por uma linha em
`$indicesAmpliados` porque AMPLIAVA um índice que o `mysql.sql` já tinha; este é índice NOVO, e índice
novo no MySQL de produção chega ao `schema.sql` pelo passo 0 (a migração antes do passo 1, que refaz o
`mysql.sql` de produção) e pelo `KEY` no `mysql.sql` de hoje, que o gerador herda. Nenhuma linha à mão
no gerador. **E uma linha de código, que a opção (a) dizia não precisar:** com o índice, o PostgreSQL das
fixtures passou a devolver a família com os filhos antes da raiz -- a consulta não tinha `ORDER BY`, e a
ordem era a do plano --, e a caracterização dele caiu; `selecionarHorariosFilhos` ganhou `ORDER BY
age.id`, a ordem que o MySQL de produção já devolve. É o **D455**, registrado e corrigido junto.

- **`scripts/migracao-agendas-indice-id-pai.sql`**, no molde das vizinhas: idempotente (só cria se
  não houver índice com o nome em `agendas`, pelo `information_schema.statistics`), o `ALTER TABLE
  agendas ADD INDEX IDX_AGENDAS_id_pai (id_pai), ALGORITHM=INPLACE, LOCK=NONE` da P8 no `PREPARE`, e
  uma conferência no fim. O cabeçalho diz que ela roda em produção, no laço, e NÃO na cópia local nem
  na base do teste sem a autorização do dono.
- **`docs/procedimento-de-corte.md`**: a linha `| 0 | **Aplicar ...**` da migração nova depois da do
  hash anterior, a mesma entrada no fim do laço, o ensaio dela e a frase de que ela não
  copia a tabela (`INPLACE`, `LOCK=NONE`: as escritas na agenda seguem). O `ensaio-geral-do-corte.md`
  passou a dizer sete, e que na cópia do desenvolvimento esta não é inofensiva: ela cria o índice -- e,
  desde a verificação (abaixo), tem o seu laço para a cópia do desenvolvimento, sem ela.
- **`mysql.sql`**: `` KEY `IDX_AGENDAS_id_pai` (`id_pai`) `` depois do `FK_AGENDAS_fechamento`, onde o
  `SHOW CREATE TABLE` põe o índice criado por último -- é o lugar em que o passo 1 o trará.
- **`postgres/schema.sql`**, regerado: a única diferença é `CREATE INDEX agendas_IDX_AGENDAS_id_pai ON
  agendas (inquilino_id, id_pai);`, uma vez. O mapeamento da `Agenda` não declara índice nenhum (nem o
  da `Venda`, no D308), e por isso não mudou: o `verificar-schema.php` só olha colunas, e o
  `conferir-schema-tool.sh` só conta queda de coluna.
- **`DesmarcarAgendamentoTest::testAFamiliaDoHorarioSeAchaPeloIndiceDeIdPai`**: no MySQL, o
  `possible_keys` do `EXPLAIN` da consulta que o repositório monta, capturada na chamada; no
  PostgreSQL, o índice no esquema, com o inquilino na frente. O plano do PostgreSQL não serve ali: a
  agenda das fixtures tem três linhas, e o planejador as lê por qualquer índice que comece pelo
  inquilino, com o `id_pai` no filtro, mesmo com a leitura sequencial e a busca por índice desligadas
  (medido). A primeira versão do caso lia o nome do índice nesse plano e passou com a consulta
  reescrita em `COALESCE` -- foi o mutante que a derrubou.

**Provado, em 09/10/2026:**

- **O portão do passo 0** sai 0 com as sete do laço (15 diferenças entre o dump e o `mysql.sql`, eram
  14, todas cobertas) e 1 em cada falsificação, com o motivo: pelas cópias (`--mysql-sql`,
  `--procedimento`), o `mysql.sql` sem o `KEY` ("acrescenta índice `agendas.IDX_AGENDAS_id_pai`, que o mysql.sql de hoje não tem"), o laço sem
  a entrada ("a linha manda aplicar ... e o laço não o roda" e o índice sem migração), e sem a linha e
  sem a entrada ("existe e o passo 0 não o cita"); e, no arquivo de verdade, devolvido pelo sha256, a
  cláusula do índice posta em comentário DENTRO do literal do `PREPARE` ("não acrescenta") e o índice
  com o nome certo e a coluna errada ("acrescenta o índice como `(colaborador)`").
- **Num MySQL 8.4.11 descartável** (`med_idp_descartavel`, a estrutura do dump `3b1f05c` e 400 mil
  linhas INVENTADAS na agenda -- ids, horas e datas --, com o `sql_log_bin` desligado, então nada foi ao
  binlog), a consulta que o Doctrine monta para `selecionarHorariosFilhos`: antes do laço, `type ALL`,
  `possible_keys` só `PRIMARY`, e o `EXPLAIN ANALYZE` lendo as 400 mil linhas em 0,53 s; o laço do
  procedimento inteiro, lido do próprio documento, duas vezes -- a nova em 1,7 s na primeira (com a
  partida do cliente `mysql`) e "indice ja existe" na segunda; depois, `index_merge` com
  `union(PRIMARY,IDX_AGENDAS_id_pai)`, 3 linhas lidas, 0,26 ms (e 0,14 ms com o `ORDER BY` do D455, que
  acrescenta um `Using filesort` de 3 linhas); e a `agendas` igual à de um banco
  montado do `mysql.sql` de hoje em toda coluna, índice e chave estrangeira (26 linhas do
  `information_schema`). Os dois bancos apagados no fim (conferido: nenhum `med_idp*`).
- **Num PostgreSQL 16 descartável** (`med_idp_pg`, do `schema.sql` regerado): a carga sai 0, o índice
  existe uma vez, `agendas_idx_agendas_id_pai (inquilino_id, id_pai)`, e o
  `verificar-schema-postgres.php` apontado para ele sai 0. Com 400 mil linhas inventadas, como
  `bvip_app` e com o RLS: o `BitmapOr` da `agendas_pkey` com o índice novo, o inquilino e o `id_pai` na
  condição dele, 0,14 ms (0,23 ms com o `Sort` do D455); sem o índice, `Parallel Seq Scan`, 34 ms (44 ms
  com a ordenação). **E o achado da P8, reproduzido:**
  uma cópia do gerador, fora do repositório, com a linha à mão em `$indicesExtras`, emite o índice duas
  vezes, e a carga do `schema.sql` dela para em `relation "agendas_idx_agendas_id_pai" already exists`
  (saída 3). Apagados no fim.
- **O caso novo**, verde nos dois bancos, e vermelho com cada mutante: o `KEY` fora do `mysql.sql` (no
  MySQL, `possible_keys` só `PRIMARY`; no PostgreSQL, com o `schema.sql` regerado dele, o índice
  ausente) e a consulta reescrita em `COALESCE(age.idPai, age.id) = :idPai` (no MySQL, `possible_keys`
  nulo; no PostgreSQL ele não pega este, de propósito -- o docblock diz por quê).

**O que falta, e é seu:** aplicar, no passo 0 da janela. E a cópia local e a base do teste seguem sem o
índice -- a `franquiabv_dev` não o tem nos dois servidores (conferido no catálogo, só leitura), e a base
do teste veio dela. A P8 oferecia criá-lo na cópia "só com a sua autorização", e a resposta 50 não falou
disso: a pergunta foi ao lote (INDICE-ID-PAI-NO-CORTE). Para o deploy do teste, o próximo `atualizar`
recusa pela migração nova, e `ACEITAR_ESQUEMA=sim` é o certo, sem ALTER (`docs/ambiente-teste.md`).

**O que não foi medido:** o tempo de criar o índice na tabela inteira de produção (o D189 mediu de 11
a 20 s para um parecido, na cópia), a CPU e o pool da VPS, e o otimizador do MySQL 8.4.7 de produção,
que pode não escolher o `index_merge` que o 8.4.11 daqui escolhe.

#### A verificação, em 09/10/2026 — três achados, consertados no repositório

- **O ensaio geral mandava rodar na cópia o laço inteiro, e o laço inteiro cria o índice na
  `franquiabv_dev`.** A frase "menos a sétima" dizia o efeito e não mandava pular; nenhum roteiro roda o
  passo 0 sozinho (o `ensaiar-corte.sh` não o aplica), e quem prepara o ensaio copia o `for` do
  procedimento. Agora o `ensaio-geral-do-corte.md` diz que numa cópia da produção feita para o ensaio rodam
  as sete, e tem o seu laço para a cópia do desenvolvimento, contra a `franquiabv_dev`, com as seis. E o
  portão do passo 0 ganhou a afirmação 7: aquele laço é o do passo 0, na mesma ordem, menos as migrações
  cujo cabeçalho (as linhas `--` do topo, lidas numa linha só) diz "NAO rode na copia local". Vermelho (1)
  no ensaio de antes ("não tem o laço da cópia do desenvolvimento ... que roda na cópia
  `migracao-agendas-indice-id-pai.sql`"), com a sétima no laço da cópia ("falta a autorização do dono"),
  sem a `clientes-sessoes` e com duas trocadas de ordem, pelas cópias (`--ensaio`); e, no arquivo de
  verdade, devolvido pelo sha256, com a frase tirada do cabeçalho, e com ela tirada do cabeçalho e posta
  mais abaixo, depois do `DEALLOCATE` ("o laço do passo 0 roda ..., e o da cópia ... não"); 2 com dois laços
  da cópia.
  Quando o dono autorizar (a pergunta INDICE-ID-PAI-NO-CORTE), a frase sai do cabeçalho e a sétima entra no
  laço do ensaio, as duas juntas: o portão pede.
- **Três dos portões listados como prova não veem índice.** `verificar-schema.php` e
  `verificar-schema-postgres.php` leem colunas (o bloco `CREATE TABLE` e o `information_schema.columns`), e
  o `conferir-schema-tool.sh` lê o banco da aplicação -- a `franquiabv_dev`, que não tem o índice -- e só
  conta queda de coluna. Os três saem iguais com o índice e sem ele; o roteiro de 09/10 os listava em
  "Provado", e foi corrigido. E o `orm:schema-tool:update`, em todo banco que tenha o índice (o da
  caracterização, o do contrato, a produção depois do passo 0, o PostgreSQL do corte), propõe apagá-lo: a
  `Agenda` não declara índice, e o comparador do DBAL põe entre os removidos todo índice do banco que o
  mapeamento não tem (`vendor/doctrine/dbal/lib/Doctrine/DBAL/Schema/Comparator.php`, 253-262). **Medido**
  no banco da caracterização montado do `mysql.sql`, só leitura, pelo mesmo comparador e pelo `toSaveSql`
  do `--dump-sql`: 275 comandos, 7 deles `DROP INDEX`, e um é `DROP INDEX IDX_AGENDAS_id_pai ON agendas`;
  os outros seis já estavam lá, de índices fora do mapeamento (`idx_vendas_data_status`,
  `idx_usuarios_unidade`, `idx_acesso_usuario_data`, `FK_NOTIFICACOES_CLIENTES_cliente` e os dois da
  `clientes_sessoes` -- um deles UNIQUE, `uq_clientes_sessoes_token`: o `--force` tiraria também a
  unicidade do token). Não é regressão, e só machuca com `--force`, que nunca roda;
  o `conferir-schema-tool.sh` deixa `DROP INDEX` de fora de propósito (não perde dado), e declarar o índice
  no mapeamento não resolveria: no PostgreSQL o nome e as colunas são outros, e o schema-tool passaria a
  propor criar e apagar lá. Nenhum portão mudou por isso. **Quem vê o índice:** o portão do passo 0, do
  lado do MySQL; o diff do gerador com o `schema.sql` (no CI, parado) e a carga com `ON_ERROR_STOP`, do
  lado do PostgreSQL; e o caso `testAFamiliaDoHorarioSeAchaPeloIndiceDeIdPai`, nos dois.
- **Um segundo índice igual passava calado.** A P8 proibiu a linha à mão no gerador porque, com o MESMO
  nome do herdado, a carga para em "already exists"; com OUTRO nome (`CREATE INDEX agendas_id_pai ON agendas
  (inquilino_id, id_pai);` em `$indicesExtras`), a carga passa, o diff do gerador só roda no CI, e o caso
  procurava o índice pelo nome -- o PostgreSQL de produção nasceria com dois índices iguais, uns 29 MB a
  mais e o custo em cada escrita na agenda. Agora o caso lista TODO índice de `agendas` que tem `id_pai`,
  coluna ou expressão, e exige que seja só o herdado (`agendas_idx_agendas_id_pai`, `(inquilino_id,
  id_pai)`; no MySQL, `IDX_AGENDAS_id_pai`, só `id_pai`). Mutantes, cada um devolvido pelo sha256: a linha
  com outro nome e o `schema.sql` regerado -- a carga passa, e o caso cai no PostgreSQL com os dois nomes
  na mensagem; a versão anterior passava (o índice procurado existe, conferido no banco montado); um segundo
  `` KEY `IDX_AGENDAS_id_pai_2` (`id_pai`) `` no `mysql.sql` -- o caso cai no MySQL; a versão anterior
  passava (`possible_keys` com `PRIMARY,IDX_AGENDAS_id_pai,IDX_AGENDAS_id_pai_2`); e a linha com `CREATE
  INDEX IF NOT EXISTS agendas_IDX_AGENDAS_id_pai` -- a carga avisa "already exists, skipping", fica um
  índice, e o caso passa, certo: não há repetido.

**Rodado depois dos três:** a caracterização inteira, 2.863 casos verdes no MySQL e 2.863 no PostgreSQL,
16 pulados em cada; o portão do passo 0 com 0 ("copia: 6 migracoes no laco do ensaio geral contra a
franquiabv_dev; o cabecalho tira migracao-agendas-indice-id-pai.sql"); o gerador igual ao `schema.sql`; o
`verificar-schema.php` com 0; o `conferir-tudo.sh` com 0, 62 ok e o vermelho conhecido do D50. Nenhum
código da aplicação mudou na verificação, e por isso o contrato e os ensaios HTTP não rodaram de novo.

#### Decidido pelo dono em 09/10/2026 (pergunta 62) — o índice na cópia local, nos dois bancos

A pergunta INDICE-ID-PAI-NO-CORTE foi ao lote como a 62, *"O índice do Desmarcar também na cópia
local, e na base do teste?"*, e o dono escolheu a opção (b): *"Criar já na cópia local, nos dois bancos,
como no D308"* -- no MySQL pela própria migração, no PostgreSQL por `CREATE INDEX CONCURRENTLY`, e a
próxima cópia do teste exportada daqui já o leva ao BVIP. A (c), que acrescentava a base do teste do
BVIP agora, **não** foi escolhida: nada foi feito no BVIP.

#### O conserto, em 09/10/2026 — o índice criado na cópia local (item INDICE-NA-COPIA-LOCAL)

Só este índice, e só na cópia do desenvolvimento, com a suíte, o contrato e os ensaios parados (conferido
antes: nenhuma transação no `INNODB_TRX`, nenhum PHP além do `php-fpm` no contêiner, nenhuma consulta
no `pg_stat_activity` da `franquiabv_dev`):

- **MySQL (`franquiabv_dev`)**: a própria `scripts/migracao-agendas-indice-id-pai.sql`, como o laço a
  roda (`docker exec -i ... mysql -uroot -proot franquiabv_dev < "$f"`): saída 0, **12 s** no servidor
  (o `exec_time` do `ALTER` no `binlog.000048`, posição 35695, começado às 22:22:07), a conferência dela
  com uma linha (`IDX_AGENDAS_id_pai`, `id_pai`, não único); a segunda vez, "indice ja existe, nada a
  fazer". O `SHOW CREATE TABLE agendas` de antes e o de depois diferem numa linha só,
  `` KEY `IDX_AGENDAS_id_pai` (`id_pai`) ``; um índice só com `id_pai` no `information_schema.statistics`;
  3.044 páginas, 47,6 MB (`mysql.innodb_index_stats`).
- **PostgreSQL (`franquiabv_dev`, esquema `bvip`)**, como `franquiabv`, o dono do esquema e da tabela
  (não o `bvip_app`): `CREATE INDEX CONCURRENTLY agendas_IDX_AGENDAS_id_pai ON bvip.agendas
  (inquilino_id, id_pai)` -- o nome que o gerador emite --, saída 0, em **uns 5 s**: o PostgreSQL da cópia
  não registra duração (`log_min_duration_statement = -1`), e o que ficou é o arquivo do índice, nascido
  às 22:23:01,2 e escrito pela última vez às 22:23:05,0, e a saída do comando, gravada às 22:23:06,1. No
  `pg_indexes`, um índice só com `id_pai`, `agendas_idx_agendas_id_pai (inquilino_id, id_pai)`,
  `indisvalid` e `indisready` verdadeiros, 29 MB; os outros cinco iguais aos de antes.
- **Fora do pedido, e é meu:** um comando de conferência levou junto um `ANALYZE TABLE agendas` no MySQL
  da cópia, logo depois da migração (no `binlog.000048`, posição 35981, às 22:22:39; com o `ALTER`, são
  os dois únicos eventos da `franquiabv_dev` nesse binlog). Ele recalcula as estatísticas do otimizador
  (`mysql.innodb_index_stats`), sem mudar esquema nem dado -- o InnoDB faz o mesmo sozinho a cada 10% de
  linhas mudadas --, e vai ao binlog como comando, como o `ALTER`. Fica escrito porque a autorização foi
  para o índice, e só ele. **O que ele mudou** (verificação, 09/10/2026): as estatísticas dos seis
  índices da `agendas`, e não só as do novo -- todos com `last_update` 22:22:39, 32 s depois do início
  do `ALTER`, que só calcula as do índice que cria --; a estimativa de linhas, de 3.981.606 para
  4.013.037; e o `INDEX_LENGTH` do `information_schema`, de 370.229.248 bytes (desatualizado) para
  341.852.160, com um índice a mais. Nenhum esquema, nenhum dado; mas quem comparar um plano na `agendas` da cópia medido
  antes de 09/10 (o do D189, por exemplo) com um de depois mede também estatísticas novas.
- **E a medida de depois rodou com essas estatísticas** (às 22:23, depois do `ANALYZE`): entre o antes e
  o depois do MySQL mudaram duas coisas, o índice e as estatísticas, e o passo 0 da produção roda só a
  migração. Para esta consulta isso não deve pesar -- sem índice em `id_pai` não há caminho de acesso
  nenhum, o `ALTER` já calcula as estatísticas do índice novo, e o `index_merge` é custeado na hora por
  mergulhos no índice (`eq_range_index_dive_limit` = 200) --, mas na cópia não foi medido sem o
  `ANALYZE`. A evidência sem ele são os bancos descartáveis de acima, onde o mesmo índice deu o mesmo
  `index_merge`.

**Medido antes e depois**, como em 08/10: o método de verdade (`selecionarHorariosFilhos`, hoje com o
`ORDER BY age.id` do D455), pelo PHP do contêiner, o `EntityManager` limpo a cada rodada, 9 rodadas por
parâmetro depois do aquecimento; no PostgreSQL, como `bvip_app`, com o RLS e o inquilino 1 declarados
pelo `DeclararInquilino`. Os parâmetros, escolhidos só por contagem: o horário 5442390, de agosto de
2026, com dois filhos (a família tem três linhas), e o 3814958, sem filho nenhum -- os mesmos nos dois
bancos (3 e 1 linhas em cada).

| `agendas`, 4.000.085 linhas no MySQL e 4.000.084 no PostgreSQL | MySQL, com filhos | MySQL, sem filhos | PostgreSQL, com filhos | PostgreSQL, sem filhos |
|---|---|---|---|---|
| antes, 1ª rodada: mediana / pior | 3,92 s / 4,73 s | 4,42 s / 8,98 s | 741 ms / 1.062 ms | 965 ms / 1.399 ms |
| antes, 2ª rodada: mediana / pior | 5,47 s / 6,75 s | 4,14 s / 6,69 s | 656 ms / 1.104 ms | 937 ms / 1.125 ms |
| **depois**: mediana / pior | **0,77 ms** / 0,88 ms | **0,72 ms** / 1,48 ms | **0,93 ms** / 1,04 ms | **0,81 ms** / 1,23 ms |

Os planos, pela consulta que o repositório monta, capturada na chamada (`DebugStack`):

- MySQL, antes: `type index` pela `PRIMARY`, `possible_keys` só `PRIMARY`, 3.981.606 linhas estimadas --
  com o `ORDER BY` do D455 a varredura vai pela chave primária, em vez do `ALL` de 08/10, e lê a tabela
  inteira do mesmo jeito. Depois: `index_merge`, `Using union(PRIMARY,IDX_AGENDAS_id_pai)`, 3 e 2 linhas
  estimadas, e um `Using filesort` das 3 linhas.
- PostgreSQL, antes: `Gather` com dois trabalhadores sobre `Parallel Seq Scan`, 1.333.360 linhas
  descartadas por processo; nas duas rodadas, de 45.599 a 47.903 páginas lidas de fora do
  `shared_buffers` e de 0,40 a 1,16 s no `EXPLAIN (ANALYZE, BUFFERS)` (na 1ª, 47.903 e 47.807 páginas,
  0,61 s com filhos e 1,16 s sem; na 2ª, 45.695 e 45.599, 0,40 s e 0,53 s). Depois: `BitmapOr` da `agendas_pkey` com a `agendas_idx_agendas_id_pai`,
  o inquilino e o `id_pai` na condição do índice, 7 páginas, 0,085 e 0,94 ms (a segunda, com 3 páginas
  lidas do disco).

**Os números de antes são maiores que os de 08/10** (2,8 s e 0,28 s), e as causas não foram separadas.
No MySQL mudaram duas coisas: o plano -- o `ORDER BY` do D455 levou a varredura do `type ALL` de 08/10
para a `PRIMARY` inteira, `type index` -- e a carga da máquina: as duas rodadas de antes discordam entre
si em até 40% (3,92 e 5,47 s), e não eram escritas no MySQL (o `binlog.000048` não tem evento nenhum
entre 21:58:16 e 22:22:07). No PostgreSQL o plano é o mesmo `Parallel Seq Scan` de 08/10, só com o
`Sort` das 3 linhas, e a carga é a explicação plausível. A ordem de grandeza não muda: de uns 4 a 5 s para menos de
1 ms no MySQL, e de 0,7 a 1,0 s para menos de 1 ms no PostgreSQL, pelo método e com a hidratação.

**Provado, em 09/10/2026:**

- **Os portões de esquema**, contra a cópia com o índice: `verificar-schema-postgres.php` 0,
  `verificar-schema.php` 0, `conferir-schema-tool.sh` 0 ("Igual a linha de base": ele deixa `DROP INDEX`
  de fora de propósito, e o `schema-tool` passa a propor apagar este índice também na `franquiabv_dev`,
  como já propunha nos bancos de teste -- ver a verificação acima. Lido só pelo `--dump-sql`: `DROP INDEX
  IDX_AGENDAS_id_pai ON agendas`, ao lado dos que ele já propunha para os ampliados do D189 e do D308).
- **As duas mudanças do repositório juntas, como o portão do passo 0 pede**: a frase "NAO rode na copia
  local" saiu do cabeçalho da migração (no lugar, que a cópia a recebeu em 09/10 pela resposta 62) e a
  sétima entrou no laço da cópia do desenvolvimento em `docs/ensaio-geral-do-corte.md`.
  `conferir-passo-zero-do-corte.php` sai 0 ("copia: 7 migracoes ... o cabecalho tira nenhuma"), e 1 com
  cada metade sozinha e com os mutantes, cada um com o motivo: só a frase tirada, o laço ainda com seis
  ("o laco do passo 0 roda `migracao-agendas-indice-id-pai.sql`, e o da copia ... nao"); a frase de volta
  ao cabeçalho com o laço de sete, no arquivo de verdade, devolvido pelo sha256 ("o cabecalho dela diz
  'NAO rode na copia local' -- falta a autorizacao do dono"); e, pelas cópias (`--ensaio`), a sétima fora
  do laço e a sétima antes da do hash anterior ("em outra ordem").
- **`testAFamiliaDoHorarioSeAchaPeloIndiceDeIdPai`**, sem mudar, verde nos dois bancos de teste (ele mede
  o banco da caracterização, montado do `mysql.sql` e do `schema.sql`, e não a cópia): a classe
  `DesmarcarAgendamentoTest`, 4 casos em cada banco, e com as vizinhas que chamam a família
  (`RemarcarAgendamentoTest`, `AgendamentoCriadoTest`), 11 em cada.
- **O `conferir-tudo.sh`**, sozinho: 61 ok, o vermelho conhecido do D50, e **um vermelho que não é
  deste item, os testes do front**, no `ConveniosLigado.teste.tsx`. Rodados sozinhos depois, três vezes
  inteiros: o caso "o 422 do cadastro marca o campo..." bateu os 5 s nas três, e na primeira caiu também
  "cadastrando, o cadastrar espera: o segundo clique não cria outro convênio" (o botão "cadastrando…"
  não estava na tela quando o caso o procurou) -- 1.602, 1.603 e 1.603 de 1.604, em 279 e 272 s com 4
  trabalhadores e 526 s com 2, contra 116 s na rodada verde da madrugada de 09/10. O arquivo sozinho
  passa, 67 de 67, em 42 s. Nenhum arquivo do front mudou. **Na verificação**, com a máquina mais calma
  (o `conferir-tudo.sh` de 09/10, 23:59, a 10/10, 00:07): 62 ok, o vermelho conhecido do D50 e nenhuma
  falha inesperada -- os testes do front verdes dentro dele, sem nenhum arquivo do front mudado. Os
  portões que leem os documentos mudados rodaram de novo depois da última edição (a da verificação),
  todos com 0.

**O que isto NÃO prova, e o que falta:**

- **A produção continua sem o índice**: ele entra no passo 0 da janela, e aplicar é seu. Por isso o
  débito segue PREPARADO, como o D308.
- **A base do teste do BVIP continua sem ele**, até a próxima cópia exportada daqui
  (`docs/ambiente-teste.md`): a (c) não foi escolhida, e daqui não se mexe no BVIP.
- **Nenhum mutante na cópia.** Apagar o índice para ver a medida voltar seria outro DDL, fora da
  autorização. A prova na cópia é o catálogo e o `EXPLAIN` de antes e de depois.
- O otimizador do MySQL 8.4.7 de produção, a CPU e o pool da VPS (os mesmos limites de acima).

**Corrigido na verificação, 09/10/2026** (só documentos e um comentário; nenhum banco mudou, só
`SELECT`, o `binlog` lido e o arquivo do índice do PostgreSQL olhado com `stat`): os tempos de criar e a
carga da máquina estavam escritos pelo relógio da sessão, sem registro salvo, e agora citam o que ficou
-- o `exec_time` do binlog e os horários do arquivo do índice --, e a carga deixa de ser a causa única
dos números de antes; o plano de antes do PostgreSQL citava só a 1ª rodada; o `ANALYZE` fora do pedido
ganhou o que mudou e o aviso de que a medida de depois rodou com ele; o parágrafo do front dava a mesma
conta nas três rodadas, e a primeira teve dois casos caídos; o `ensaio-geral-do-corte.md` e o
comentário de `conferir-passo-zero-do-corte.php` erravam a cronologia (a resposta 62 é da manhã de
09/10, 07:45, 18 minutos depois do commit `2d3c6a0` que pôs a frase; o índice e a saída da frase são da
noite); e a tabela do `ambiente-desenvolvimento.md` prometia todos os índices que a cópia tem e a
produção não, e deixava de fora os do passo 0 em `vendas` e `pontuacoes_historicos` e as tabelas novas
-- agora é a lista inteira do catálogo contra o dump `3b1f05c`. Os portões dos documentos e o par
`contar-debitos.php`/`conferir-debitos-repetidos.php` rodaram depois desta edição, a última; os de
esquema (`verificar-schema.php`, `verificar-schema-postgres.php` contra a cópia, `conferir-schema-tool.sh`)
saíram 0 de novo, e `testAFamiliaDoHorarioSeAchaPeloIndiceDeIdPai`, sem mudar, verde nos dois bancos de
teste, com as vizinhas (11 em cada).

### D221 — Remarcar não acompanha a comanda aberta — ANOTADO em 23/09/2026

Abrir a comanda pela agenda (`VendaService::comandaAbrirAgenda`) copia o
colaborador e o serviço de cada agendamento para os itens da comanda, e
`vendas` não tem coluna que aponte para `agendas`. Remarcar depois disso — para
outro colaborador — deixa a comanda com o colaborador de antes, **e com a
comissão dele**. Vale para as três portas da remarcação; a da API não mexe na
comanda (D216).

Na cópia há 2 comandas abertas, e não dá para medir quantas vezes aconteceu:
sem a ligação, o par comanda–agendamento só se acha por cliente, unidade e dia.

**Direção:** ignorar (como hoje), avisar, ou recusar a troca de colaborador com
comanda aberta — decisão do dono (pergunta Q4 da remarcação).

### D222 — Sair não encerrava a sessão, e o login não trocava o id dela — CORRIGIDO em 24/09/2026

Achado em 24/09/2026, limpando a sessão que eu tinha injetado no navegador para
conferir o arrastar da agenda: depois de "Sair", a página do ERP voltava a
pedir a senha, e **a API nova seguia respondendo como quem tinha saído**.
Medido por HTTP, contra o banco de contrato, eram três defeitos:

| | antes | depois |
|---|---|---|
| a API nova, com a sessão de quem clicou "Sair" | 200 | 401 |
| o id de sessão que existia ANTES do login | vira a sessão logada | é trocado |
| o `token_web` de quem saiu, sozinho num navegador novo | entra sem senha | não entra |

#### Por que

- **"Sair" só limpava a identidade.** A página do ERP pergunta pela identidade
  (`ISConfiguracao\Module::handleAuthRedirect`), e a API nova pergunta pela
  unidade e pelos privilégios que o login guarda na sessão (`SessaoAcl`,
  contêiner `ISConfiguracao`) — que ficavam. As rotas `adm-api-*` são livres
  no desvio para o login porque cada ação confere a sua ACL (D146), e a ACL
  estava lá.
- **O login não trocava o id.** Nenhum `regenerateId` em `module/` nem em
  `config/`, e o PHP aceita o id que o cliente manda (`use_strict_mode`
  desligado, D198). Quem conseguisse pôr um id conhecido no navegador de outra
  pessoa — o computador do balcão é de todos — entraria como ela depois do
  login dela. Valia pelos dois caminhos: o formulário e o cookie `token_web`.
- **"Sair" tirava o `token_web` só do navegador.** No banco ele seguia valendo
  até o próximo login pelo formulário — e ele entra sem senha. O token também
  está num campo oculto de toda página (`usuario_sessao_token`), legível por
  qualquer script, e o cookie sai sem `HttpOnly` (D198).

#### O conserto — `AutenticacaoController`

- o login, pelo formulário e pelo cookie, troca o id da sessão
  (`regenerateId(true)`: os dados passam, a sessão velha é apagada);
- "Sair" destrói a sessão inteira, e invalida o `token_web` do banco — **só se
  ele ainda for o do login desta sessão**: o "um dispositivo por vez"
  (`validarHashAction`) troca o token a cada login, e invalidar o token de um
  login mais novo derrubaria quem está usando agora.

#### Provado

- `scripts/ensaiar-sessao-do-erp.php` (novo, 6 casos, no CI depois do de
  permissão): antes do conserto, **2 de 6** — o 1 e o 6, que são controles; o 5
  também tem o seu (o token entra quando ninguém saiu), e caiu pela outra
  metade, a troca de id pelo cookie. Depois, 6 de 6.
  Os controles existem porque um ensaio só de recusas ficaria verde com uma API
  que recusa tudo: o login tem de abrir sessão que a API reconhece, o token
  tem de entrar quando ninguém saiu, e sair na sessão velha não pode derrubar o
  login mais novo da mesma pessoa.
- **Mutações, por HTTP**: as seis peças arrancadas uma a uma — a troca de id em
  cada um dos dois logins, a destruição da sessão, a invalidação do token, a
  conferência de que o token é o desta sessão, e a ORDEM (invalidar depois de
  destruir a sessão não acha mais o token dela) — deixam cada uma o seu caso
  vermelho.
- **E o meu instrumento errou primeiro.** A primeira medição da fixação usou um
  id com letras fora do alfabeto do PHP: o `Laminas\Session` recusa esse id
  com erro fatal, e o erro saía com HTTP 200 e sem campo de senha — a mesma cara
  de uma sessão logada. O ensaio passou a usar ids hexadecimais e a só aceitar
  200 com JSON no corpo; a fixação, medida de novo, é real.

#### O que continua

O cookie sem `HttpOnly`/`SameSite` e o `use_strict_mode` desligado (D198); e o
dispositivo expulso pelo login de outro continua logado (D223).

**01/10/2026: os dois fechados** -- o D198 e o D223. Uma consequência para este registro: com a guarda
do D223 em todo pedido, a sessão velha é esvaziada antes de chegar ao "Sair", e o caso 6 do ensaio
deixou de distinguir a conferência "invalida só se o token ainda for o desta sessão" -- ela ficou
como defesa redundante, e a "conferência de que o token é o desta sessão" das mutações acima já não
deixa o seu caso vermelho.

**06/11/2026: decidido pela resposta 318 (a) do dono** (gravada na página do lote em 07/11/2026 01:14 UTC, sem
observação): "Fica como está: na entrada da tela nova". O "sair" da tela nova (a fatia 94) encerra a sessão como o do ERP e
deixa a pessoa no formulário de entrada da própria tela nova, com "Você saiu do sistema.". O estado deste débito não muda.

### D223 — O "um dispositivo por vez" não desloga o dispositivo expulso — CORRIGIDO em 01/10/2026

Medido em 24/09/2026, por HTTP, contra o banco de contrato: A entra, B entra
com o mesmo usuário (o `token_web` troca), e o `validarHash` de A responde
`{"sucesso":false}`. O JavaScript de A manda a página para
`/autenticacao/entrar` — e é só isso. **A sessão de A continua logada**: a
página do ERP abre sem pedir senha, e a API nova responde 200.

A regra "sua conta é estritamente pessoal e só pode ser utilizada em um único
dispositivo por vez" é, hoje, um redirecionamento no navegador.

**Por que não consertei junto:** destruir a sessão no `validarHashAction`
apagaria a mensagem que explica a quem foi expulso por que ele saiu (ela mora
na sessão, no `flashMessenger`); e a ação confere o id e o token que a PÁGINA
manda, e não os da sessão — um pedido forjado de outro site deslogaria qualquer
um. O conserto é a ação comparar o token da SESSÃO com o do banco, e encerrar a
sessão guardando a mensagem para a próxima.

#### CORRIGIDO em 01/10/2026

**A regra.** `ISConfiguracao\Permissions\UmDispositivoPorVez` compara o token da SESSÃO com o
`token_web` do banco. Se diferem, a sessão é esvaziada e troca de id, e o id velho é apagado; a
mensagem entra depois, na sessão nova, para o `entrar` mostrar. A sessão sem token (o `autenticarComo`)
não se confere, e o corpo do pedido não decide nada -- um pedido forjado não derruba ninguém.

**Quem aplica, e a primeira versão errou aqui.** Na primeira rodada, só o `validarHash` aplicava a
regra, e o expulso seguia atendido até ele disparar: o relógio de 48 s recomeça a cada página, quem
mudava de página antes disso nunca saía, e a tela nova nunca chama o `validarHash`. O próprio ensaio
travava a lacuna -- o caso 9 exigia 200 na API de A depois do login de B. Agora a guarda de despacho,
que já relê o usuário a cada pedido (D294), aplica a regra em todo pedido de sessão logada: a página
do ERP vai ao `entrar` com a mensagem, e `/adm/api` responde 401. Não há consulta a mais na sessão do
próprio usuário. O `validarHash` chama a mesma classe.

**Medido.** O ensaio dá 15 de 15 no MySQL e no PostgreSQL: no caso 9, a API de A responde 200 antes
de B; depois, a página pede senha e mostra a mensagem, e a API responde 401 com os dois ids; no caso
15, quem só usa a tela nova recebe 401 no primeiro pedido. O verificador mediu por conta própria: 8
pedidos em paralelo do expulso, 8 respostas 401; 8 do aparelho atual, 8 respostas 200; o POST de
escrita do expulso não chega ao controller; o `token_web` velho sozinho não entra.
`D223UmDispositivoPorVezTest` tem 10 casos. Os ensaios de permissão e de recusas e o contrato (67
testes) seguem verdes.

**Falsificado.** A guarda sem a regra deixa vermelhos os casos 9 e 15 e 2 dos 4 testes da guarda; o
`validarHash` sem a regra, 2 dos 6 testes dele.

**O que a loja pode ver.** Quem entra num segundo aparelho derruba o primeiro no próximo clique dele --
antes, o primeiro ia à tela de entrar em até 48 s, e a sessão dele continuava logada. **E uma fonte
nova de falso vermelho para quem testa:** um login de `gerente.teste` em paralelo derruba, no pedido
seguinte, a sessão de um contrato ou ensaio em andamento -- com a trava e o CI em série, hoje não
acontece.

**O que fica.** A personificação, no D315.

### D224 — Marcar pela API nova não conferia o que a remarcação confere — CORRIGIDO na API em 25/09/2026 (o destino no serviço em 24/09; o pedido no controller em 25/09)

Achado em 24/09/2026, lendo o `POST /adm/api/agendamentos` antes de a agenda
nova ganhar o "Marcar" — o mesmo quadro do endereço de remarcar antes do D216:
nenhuma tela o chamava, e nenhum ensaio o recusava. Ele aceitava o que a grade
não oferece, e virava erro 500 (em HTML, porque `/adm/api` não tem o ouvinte de
erro em JSON) no que não entendia.

| pedido | antes | depois da fatia 1 |
|---|---|---|
| horário com a agenda fechada | gravava por cima do fechamento | 422 `horario-indisponivel` |
| dia de férias do colaborador | gravava | 422 `ferias` |
| dia em que o colaborador não tem agenda | gravava | 422 `agenda-fechada` |
| horário fora da grade (`10:10` numa agenda de 20 em 20) | gravava | 422 `horario-indisponivel` |
| hora que não é hora (`9h`, `25:00`) | 500 | 422 `horario-indisponivel` |

#### Por que

`EscritasDaAgenda::marcar` só chamava a `RegraDeAgendamento` — a da tela antiga,
que confere quantidade de horários, horário ocupado e sequência, e mais nada: a
lista de horários da tela antiga é que escondia o resto. A remarcação da API já
conferia o destino (`RegraDaRemarcacao::conferirDestino`); o marcar passou a
chamar a mesma conferência, depois da trava e antes da regra antiga. Antes,
porque a regra antiga lê cada hora com `new DateTime`, e é lá que o "9h" virava
500: o que chega a ela agora já é horário da grade.

A regra antiga e o `AgendaService` não mudam — a tela antiga e o contrato
congelado do aplicativo (D5) usam os dois.

#### Onde a medição podia enganar, e o que a segura

- **O dia em dois formatos.** A grade, as férias e o dia aberto leem o dia em
  `dd-mm-aaaa`; lido em ISO, o `DataHora::diasemana` erra o dia da semana sem
  erro nenhum (`2026-12-24` sai terça-feira de 2030). As fixtures abrem os sete
  dias iguais, e por isso um caso comum passaria com o formato trocado. Os casos
  novos mudam SÓ a quinta (24/12/2026 é quinta): sem agenda na quinta, e a quinta
  abrindo às 10:00 com os outros dias às 08:00.
- **Dois casos mudaram de sentido.** O que prendia o marcar gravando por cima do
  fechamento virou recusa, e a separação entre a linha de fechamento e a família
  nova (`FamiliaRecemGravada`) ganhou caso próprio, gravando por baixo da API como
  a tela antiga grava. O de 23:40 passou a fechar a quinta às 23:59 dentro da
  transação do caso: as fixtures fecham às 20:00.
- **Uma leitura do dia só.** O destino se confere com as mesmas linhas que depois
  separam a família nova (`linhasDoDia` e `idsDe`): nenhuma consulta a mais, e a
  contagem do `conferir-cobertura-por-par` não muda.

#### A segunda parte, no controller — 25/09/2026

| pedido | antes | depois |
|---|---|---|
| cliente que não existe, ou sem id | 500 | 422, campo `cliente` |
| serviço que não existe, ou de outra unidade | 500 / gravava | 422, campo `servico`, a mesma frase para os dois |
| item que não é serviço (3 marcações de `probar` no último ano da cópia) | gravava | 422, "Só serviço se marca na agenda." |
| serviço inativo | gravava | 422 |
| colaborador inativo ou fora da agenda | gravava | 404, como na remarcação |
| colaborador sem tempo de atendimento | 500 (`DateInterval('PTM')`, depois de gravar) | 422 `agenda-fechada`, no marcar e no remarcar |
| hora repetida (`["10:00", "10:00"]`) | duas linhas no mesmo horário | 422, campo `horas` |
| observação maior que a coluna | 500 | 422, campo `observacao` |
| emoji na observação | 500 (erro 1366: a coluna é `utf8mb3`) | 422, no marcar e no remarcar |

O pedido é conferido ANTES da trava, e todos os campos de uma vez: a forma
das horas e a observação pela `RegraDaRemarcacao::conferirPedido` (a mesma da
remarcação, que ganhou o emoji), e o cliente e o serviço pela
`RegraDaMarcacao`, nova. Como a da remarcação, ela não lê banco: o controller
busca e passa, e cada recusa se testa sozinha.

**O que ela não confere, de propósito:** a unidade do cliente (24% das
marcações de 2026 na cópia são de cliente de outra unidade — o cliente é da
rede); se o cliente está ativo (o id 2, "sem cadastro", está inativo na cópia
e responde por 12,9% das marcações do balcão); e se o colaborador faz o
serviço, que é a decisão 2 do roadmap.

**O tempo de atendimento não entrou no "fora da agenda" (404).** O colaborador
sem ele aparece na grade como coluna de agenda fechada, e a resposta certa é
a da grade: 422 `agenda-fechada`. Posto no 404, o caso 56 do ensaio (remarcar
para 90001003, de tempo nulo) mudava de sentido.

#### Provado

- Os quatro arquivos de teste: 76 casos, nos dois bancos (`EscritasDaAgendaTest`
  27 → 28, `RemarcacaoDeAgendamentoTest` 19 → 20, `RegraDaRemarcacaoTest`
  18 → 19, `RegraDaMarcacaoTest` novo, com 9). Suíte inteira: 828 verdes e 13
  pulados com motivo, no MySQL e no PostgreSQL.
- O ensaio por HTTP ganhou os casos 62 a 77: 77 de 77.
- 11 mutações nas regras e nos serviços, cada uma vermelha pelo caso da sua
  guarda. E três na fiação do controller, medidas pelo ensaio: sem a
  `RegraDaMarcacao`, os casos 67 a 71 viram 500 ou 201; com o colaborador só
  pela unidade, o 72 vira 422; sem o `conferirPedido`, os 62 a 65 viram 422 pelo
  motivo errado ou 500.

### D225 — O anonimizador deixava o nome, o telefone e o e-mail reais na busca e em texto — CORRIGIDO no anonimizador em 25/09/2026; a base local anonimizada antes disso segue com o dado real — PARCIAL, lido em 05/10/2026 — ANOTADO em 12/10/2026: nenhuma limpeza da cópia agora (a resposta 84)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: anonimizar de novo a franquiabv_dev local (escrita pede autorização) e conferir os cinco contadores zerados; o texto livre digitado fica na conta da LGPD, adiada.

Achado em 25/09/2026, no ensaio da exportação para o ambiente de teste público
(`docs/ambiente-teste.md`): a régua da cópia (`scripts/conferir-base-teste.sql`)
comparou a coluna `tags` com os campos anonimizados, e ela não batia.

`clientes.tags` e `unidades_socios.tags` são DERIVADAS: o `setTags()` das duas
entidades as monta com o nome, o telefone e o e-mail, e a busca lê delas
(`tags LIKE`). O `scripts/anonimizar.sql.modelo` trocava os três campos e não
refazia a coluna. Na `franquiabv_dev` local anonimizada, 241.683 dos 242.152
clientes com tags ainda tinham ali o dado REAL, e a busca os achava pelo nome
verdadeiro. E `indicacoes.url` guardava o link da produção com o hash do
cliente.

#### O que mudou

- O anonimizador refaz as duas `tags` com os valores já anonimizados
  (`LOWER(CONCAT_WS(' ', nome, telefone_sem_mascara, email))`) e troca a
  `indicacoes.url` por um endereço `.invalid`. A fórmula é uma aproximação do
  `tagsBuscasPalavras()` — sem tirar pontuação, acento e palavra repetida —, e
  só entra nela dado anonimizado.
- O `scripts/anonimizar.sh` ganhou o quarto contador, `tags_com_dado_real`.
- A segunda revisão adversarial do deploy de teste (25/09/2026) achou o mesmo
  defeito em outro lugar: o próprio sistema escreve o nome da pessoa dentro de
  frases, e o anonimizador não as alcançava. Na cópia local: 19.042 históricos
  de pontos com "realizado pelo colaborador <id> - <nome>" (que desfaz o
  "Colaborador <id>" do cadastro), 79 com "indicação do <nome>" e 715
  cancelamentos de estoque com "<nome> cancelou em". E o convênio, que o
  anonimizador não tocava, com o nome, o e-mail e o telefone da pessoa de
  contato (34). O anonimizador passou a refazer os quatro, e ganhou o quinto
  contador, `nomes_em_texto`.
- A terceira revisão achou mais dois do anonimizador, corrigidos nele:
  - o hash do cliente saía com 32 caracteres (MD5), e o formulário de
    fidelidade só aceita 8 (`FidelidadeController`, `strlen == 8`): a adesão
    ficava desligada para a base inteira. Agora sai com 8;
  - `unidades.iframe_bi` -- a URL do painel de BI com o token -- deixou de ser
    zerada em 09/09/2026, quando saiu do código, mas a coluna segue no banco da
    produção. Agora é zerada se existir.
  E um do ambiente de teste, que não é do anonimizador: o motivo de desconto
  POR ITEM (`vendas_produtos.desconto_motivo`, 159.668 textos livres na cópia,
  com nome de cliente, de parente e de colaborador). O endurecimento trocava o
  da venda (425) e não este; agora troca os dois.
- A exportação para o teste (`scripts/exportar-base-teste.sh`) passa a cópia
  pelo anonimizador corrigido e só exporta com a régua exata: o ambiente de
  teste não depende de a base local estar limpa.

#### O que continua de pé — decisão do dono

- **A `franquiabv_dev` local anonimizada antes de 25/09/2026 segue com as tags
  reais.** Anonimizá-la de novo escreve nela, e escrita na `franquiabv_dev`
  pede autorização. Até lá, todo dump feito dela à mão (fora do
  `exportar-base-teste.sh`) leva o dado real. Para limpar:
  `sh scripts/anonimizar.sh` e conferir que os cinco contadores dão zero.
- **O texto livre que a equipe digita** — motivos de fechamento da agenda
  (134 mil), de cancelamento (62 mil) e de retirada (22 mil), descrições de
  saída, mensagens, nomes de arquivo — segue como veio no anonimizador. É o
  mesmo critério pelo qual ele zera `agendas.observacao`, e é LGPD: débito
  futuro. O ambiente de teste PÚBLICO não espera por ele: o
  `scripts/endurecer-base-teste.sql` troca esse texto por um genérico.
- A homologação do BVIP recebe o dump anonimizado pelo mesmo caminho
  (`docs/ambiente-homologacao.md`, que já usa o anonimizador corrigido). Se
  algum saiu desta máquina antes de 25/09/2026, leva o mesmo defeito, dentro do
  servidor. Entra na conta da LGPD, anotada como débito futuro.

**Decidido pelo dono em 12/10/2026 (a resposta 84, opção c, e não a a recomendada):** nenhuma limpeza da cópia local
agora. A busca da `franquiabv_dev` segue com o nome, o telefone e o e-mail reais em 241.683 dos 242.152 clientes, e todo
dump feito dela à mão leva o dado real -- a cópia do teste sai pelo anonimizador corrigido
(`scripts/exportar-base-teste.sh`), e não depende dela. O texto livre digitado segue na conta da LGPD. Adiado por
decisão, e não esquecido.

### D226 — Quem só marca na agenda não busca cliente, na tela nova nem (desde o D138) na antiga — CORRIGIDO em 25/09/2026 — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 11/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: o beco da tela antiga: quem só marca e escolhe cliente de cadastro desatualizado recebe 404 do pesquisar, e o Marcar não volta.

Achado ao ligar o Marcar da tela nova, lendo por onde a tela antiga busca o
cliente.

**A tela antiga.** O Marcar dela (`#modal_agenda_marcar`,
`public/layout/scripts/mod-cadastros.js`) busca em
`/mod-cadastros/clientes/autoComplite`. O D138 (08/09/2026) guardou essa ação
com `vendas/nova` -- "a guarda é a da tela que chama" --, mas a agenda também a
chama, e a blacklist do aplicativo (`#modal_blacklist_adicionar`) idem. Quem
marca sem o privilégio do PDV passou a receber 404, e a caixa de busca fica
calada. Em produção ainda não, porque o D138 não foi implantado; na migração,
sim.

**A tela nova.** `GET /adm/api/clientes` abre a busca a quem cuida do cadastro
(`clientes/cadastro/ler`) ou abre comanda (`vendas/comandasNova/ler`). A agenda
não é nenhuma das duas portas.

**Medido na cópia** (sem as fixtures e sem o grupo administrador, que passa em
tudo), entre os 180 usuários ativos que marcam (`Agendas gerenciar`/escrever):

| | todos | entraram nos últimos 90 dias |
|---|---:|---:|
| sem `Vendas nova`/escrever -- perdem a busca na tela antiga | 10 | 8 |
| sem as duas portas da API nova -- perdem a busca na tela nova | 9 | 7 |
| buscam na antiga (depois do D138) e perderiam na nova | 0 | -- |

Os 7 recentes sem porta nenhuma: 6 do grupo "Colaborador" e 1 "Gerente". Sem a
busca, a janela nova os manda ao "cliente sem cadastro" -- a marcação sai, e sem
o cliente.

**A correção segue a regra do próprio D138: a guarda é a das telas que
chamam** -- e derivei as telas do código, procurando cada URL em todos os
`public/layout/scripts/*.js`, e não de memória:

| ação | quem chama | guarda antes | guarda agora |
|---|---|---|---|
| `GET /adm/api/clientes` | cadastro, comanda, o Marcar novo | `clientes/cadastro/ler` ou `vendas/comandasNova/ler` | as duas, ou `agendas/gerenciar/escrever` -- na forma mínima (só ativos, com termo, a primeira página, só o nome) |
| `clientes/autoComplite` | o PDV; o Marcar e o modal de cliente da agenda; a inclusão na blacklist | `vendas/nova` | `vendas/nova`, `agendas/gerenciar` ou `clientes/blacklistApp` |
| `clientes/pesquisarUltimosServicos` | só a agenda | `vendas/nova` | `vendas/nova` ou `agendas/gerenciar` |
| `clientes/pesquisar` (o cadastro inteiro) | o PDV e o modal "gerenciar cliente" da agenda | `vendas/nova` | **a mesma** |

**O cadastro inteiro fica fechado de propósito.** Ele devolve CPF, endereço e
anotações, e o modal da agenda que o abre só salva com `clientes/cadastro`
(`salvarAction`) -- abri-lo a quem marca mostraria o que a pessoa não pode
mudar.

**E isso deixa um beco na tela antiga, que fica anotado e não corrigido.** Na
unidade com `clientes_atualizar`, escolher um cliente de cadastro desatualizado
chama `agendaClienteModal()`, que FECHA o Marcar antes de pedir o cadastro
(`mod-cadastros.js`). Para quem só marca, o `pesquisar` responde 404, sai "Erro
ao localizar." e o Marcar não volta -- e escolher o mesmo cliente de novo repete
tudo. Abrir o `pesquisar` não o tiraria do beco: o modal que se abriria só salva
com `clientes/cadastro`. Antes do D138 era o mesmo beco um passo adiante (o
modal abria e não salvava). A tela nova não o herda: o aviso de cadastro
desatualizado dela é a etapa C2, e não fecha o Marcar.

O `vendas/nova` do `pesquisarUltimosServicos` ficou, embora o PDV não o chame:
tirá-lo fecharia a porta a quem já passava, e o ganho seria só de arrumação.

**Provado** por `scripts/ensaiar-busca-de-cliente-da-agenda.php`, no CI e na
lacuna declarada: o colaborador da fixture entra três vezes -- sem privilégio
nenhum (as portas recusam: é o controle), só com `Agendas gerenciar` e só com a
blacklist --, com a concessão feita antes de cada login e desfeita logo depois.
14 de 14.

**O conserto do beco (11/10/2026, no item D322-CONSERTOS).** Das três saídas -- não fechar o Marcar, reabri-lo no erro,
ou não pedir a atualização a quem não tem o cadastro --, o registro aponta o defeito ("o Marcar não volta"), e a tela já
tinha o caminho de volta: o "fechar" do modal do cliente reabre o Marcar (`agendas/index.phtml`, o `onclick` do `close`).
O `error` do pedido ao `pesquisar` em `agendaClienteModal` (`mod-cadastros.js`) passou a reabrir o Marcar, com o que já
estava preenchido; quem tem o cadastro segue para o modal do cliente como antes. O `?cache=` subiu para 167 (o mesmo do
D454). O 404 do `pesquisar` para quem só marca continua, de propósito (o cadastro inteiro fica fechado, como acima).

**Provado** no `remarcarDaTelaAntiga.teste.ts`: escolhido no Marcar um cliente de cadastro desatualizado, o Marcar
fecha, o `pesquisar` sai, e o `error` (o 404) mostra "Erro ao localizar." e reabre o Marcar, com o `cliente_id` no campo;
com o cadastro que abre, o modal do cliente abre e o Marcar não. Mutante: sem o `modal("show")` no `error`, o caso cai.
**Não provado:** o Bootstrap de verdade (o `show` chamado com o `hide` ainda animando) e a tela antiga no navegador.

**A verificação achou que o conserto travava a tela (11/10/2026), e ele foi refeito.** O `agendaClienteModal` chama o
`hide` do Marcar e logo pede o `pesquisar`; o 404 de quem só marca é a resposta mais rápida do app, e chega antes de o
`hide` do Bootstrap 3.3.7 terminar (~300 ms de animação do modal, mais a do fundo). Um `show` nesse meio cria um fundo
escuro novo por cima do velho, e o fim do `hide` esconde o modal e apaga o novo, não o velho
(`public/global/plugins/bootstrap/js/bootstrap.js`, `hideModal` e `removeBackdrop`). **Medido no Chrome**, numa página de
ensaio com os arquivos do próprio ERP (jQuery 1.12.4, migrate, `bootstrap.min.js` e o CSS do layout), servida de um
servidor local só de leitura, com o Marcar `modal fade` aberto e o 404 simulado depois de N ms:

| versão | N = 10, 60, 120 ms | N = 220, 280 ms | N = 400, 800 ms |
|---|---|---|---|
| a do conserto (o `show` direto) | o Marcar volta com 3 fundos; fechado, ficam 2 fundos escuros, e o clique não chega à página | o Marcar não volta, e um fundo transparente cobre a tela | funciona |
| a do HEAD (sem `show`; medida em 60 e 220 ms) | o Marcar não volta; 0 fundos, a página clicável | idem | -- |
| a de agora | o Marcar volta com 1 fundo; fechado, 0 fundos, e o clique chega à página | idem | idem |

A de agora foi medida em 13 tempos (0, 10, 60, 120, 220, 250, 280, 295, 305, 320, 340, 400 e 800 ms; o `hidden.bs.modal`
saiu entre 287 e 374 ms nas rodadas), todos assim. **O conserto:** antes do `hide`, o `agendaClienteModal` anota se o Marcar estava aberto
(o `isShown` do `data("bs.modal")`, que é a condição do próprio Bootstrap para o `hide` andar e o `hidden.bs.modal`
sair) e liga um `one("hidden.bs.modal")` que desfaz a anotação; o `error` reabre o Marcar na hora se o fechar já
terminou, e senão num `one("hidden.bs.modal")`. Era a saída que a verificação já tinha medido no mesmo navegador.

**Provado** no `remarcarDaTelaAntiga.teste.ts`, com o jQuery de mentira imitando o modal do Bootstrap (o `data("bs.modal")`
com o `isShown`, o `hide` que deixa o modal fechando até o caso soltar o `hidden.bs.modal`, e o `show` no meio do fechar
anotado): o 404 no meio do fechar não abre nada até o `hidden.bs.modal`, e então o Marcar volta, sem `show` no meio; o
404 depois do fechar reabre na hora. Mutantes, cada um sozinho e restaurado byte a byte: o `error` mostrando direto, a
anotação nunca feita, e a anotação nunca desfeita -- cada um derruba um caso. **Não provado:** a agenda antiga de verdade,
com a sessão e o 404 do servidor (o ensaio simula o tempo da resposta com `setTimeout` no lugar do `$.ajax`).

**Fica uma pergunta ao dono, que não é o beco:** quem só marca ainda lê "O cadastro do cliente está desatualizado,
favor confirmar os dados e atualizar." antes do "Erro ao localizar." -- um pedido que não pode cumprir. Se o aviso some
para essa pessoa, ou passa a dizer a quem cabe, é regra de quem atualiza o cadastro (no lote, "D322-CONSERTOS (o
D226)").

### D227 — A API do caixa pedia um privilégio que nenhuma tela do ERP usa — CORRIGIDO em 26/09/2026 — PARCIAL, lido em 05/10/2026 — PREPARADO em 12/10/2026: a tela nova não o mostra e a sessão não o lê; o passo 3 do corte apaga o item e as linhas

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decisão do dono: tirar da tela de permissões o bit Vendas caixa, que continua lá sem governar nada.

Achado ao começar a tela do caixa da tela nova, lendo as guardas das três portas
por onde se abre, retira e fecha o caixa.

| porta | privilégio |
|---|---|
| a tela do ERP (`CaixasController::gerenciar`, `retirada`, `retiradaCancelar`) | `vendas/nova/escrever` -- a guarda do D138, "a da tela que chama" |
| o item "Abrir/Fechar Caixa" do menu da tela nova (`menu-do-erp.ts`) | `vendas/nova/escrever` |
| `/adm/api/caixas` (`Adm\CaixasController`) | **`vendas/caixa`** |

O D123 (07/09/2026) escolheu `caixa` pelo nome, e o bit `Vendas caixa` não
governa tela nenhuma do ERP: fora da API, ele só aparece no `menuAcesso` do
grupo Vendas. É a família do D195 -- a fachada tinha escolhido o bit que nunca
governou nada.

**Medido na cópia** (só `SELECT`; sem administradores, que passam em tudo, e sem
as fixtures), entre os **356** usuários ativos:

| situação | usuários |
|---|---:|
| `Vendas nova`/escrever | 170 |
| `Vendas caixa`/escrever | 169 |
| têm o do ERP e não o da API -- abrem caixa no ERP e levariam 403 na tela nova | **2** |
| têm o da API e não o do ERP -- não abrem caixa no ERP | 1 |
| leem o caixa pela API sem ver o caixa no ERP | 2 |

E `ler` e `escrever` de `Vendas nova` coincidem para os 356, então a API pedir
`ler` nas leituras e o ERP pedir `escrever` para tudo não separa ninguém.

**Corrigido** alinhando a API ao ERP: `funcionalidade = 'nova'`. Ninguém que
opera o caixa hoje perde; os 2 que abrem caixa no ERP deixam de levar 403 na tela
nova; 3 perdem pela API o que o ERP já não lhes dava. As fixtures trocaram
`Vendas caixa` por `Vendas nova` no gerente -- continuam oito linhas, como o
`PrivilegioApiTest` conta.

**O portão** `scripts/conferir-guarda-do-caixa.php` lê as três portas sem os
comentários (`token_get_all`) e exige o mesmo par módulo/funcionalidade no ERP, na
API e no menu, e que toda ação da API confira por `getAcesso(`. Falsificado em
seis formas, numa cópia fora do repositório: a API de volta a `caixa`; o
comentário dizendo `nova` e o código `caixa`; uma ação do ERP com outro bit; o
menu com outro bit; uma ação da API sem guarda; uma ação da API por
`getAcesso2(`. As seis ficaram vermelhas.

**Não feito, e é decisão sua:** tirar o bit `Vendas caixa` da tela de permissões.
Ele continua lá, sem governar nada -- como o `visualizar` do D195 antes da
correção.

#### 12/10/2026 — "VENDAS CAIXA" SAI DA LISTA, NO CORTE — pela resposta 122 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 122** (a opção "a"): *"'Vendas caixa' sai da lista. Ninguém perde o que faz hoje. Como a lista não tem
como marcar um item fora de uso, o item e as linhas de quem o tem são apagados no corte (passo 3, com a aplicação
parada); até lá, a tela nova de Grupos não o mostra."* O SQL do passo 3 foi preparado e ensaiado num banco descartável;
não foi aplicado em cópia nenhuma (apagar linha não está na autorização da resposta 83).

##### O conserto

- **A sessão deixou de lê-la** (`SessaoAcl::setAcl` e `menuAcesso`). Não é enfeite: o `Acl` recusa com exceção a
  funcionalidade que não existe, e a sessão de antes perguntava por "Vendas caixa" em todo login -- com a linha apagada
  no corte e o código de antes, ninguém entraria. O menu Vendas, que só ela abria (vazio) para quem não tinha outra
  permissão de vendas, deixou de abrir por ela; os quatro itens dele pedem "nova", "realizadas" e "comandasListar".
- **A tela nova não a mostra nem a grava.** A tela nova de Grupos ainda não existe (`docs/f3-telas-restantes/tela-acl.md`,
  que ganhou a linha); a de Usuários tem a tabela de privilégios da pessoa. Por isso a regra mora na `/adm/api`, que as
  duas leem: `RegraDoPrivilegio::FORA_DE_USO` (só "Vendas caixa"), e o catálogo (`GET /adm/api/funcionalidades`), os
  privilégios da pessoa (o par e o `concediveis`) e o molde do grupo (`GET /adm/api/grupos/:grupo/privilegios`) a deixam
  de fora; o `PUT` e a exclusão dela são o 404 da funcionalidade que não existe. A tela antiga de permissões continua
  mostrando-a até o corte, sem que ela governe nada. *Uma leitura minha, escrita aqui:* "a tela nova de Grupos" vale para
  a tela nova que mostra privilégios -- hoje, a de Usuários.
- **O passo 3 do corte**: `scripts/remover-funcionalidade-vendas-caixa.sql` -- as contagens de antes (a funcionalidade,
  os moldes de grupo, as linhas de pessoas), os três `DELETE` (as linhas de `privilegios_usuarios`, as de `privilegios` e
  a funcionalidade) numa transação, e as contagens de depois. Idempotente. No `docs/procedimento-de-corte.md`, passo 3,
  depois da limpeza e antes do dump, com o "só junto com o código de 12/10/2026 no ar".

##### Provado

- `VendasCaixaForaDaListaTest` (novo, 6 casos, nos dois bancos): a sessão não lê "Vendas caixa" e o menu Vendas não abre
  por ela (com o controle: com "Vendas nova", abre); **os três `DELETE` do roteiro, lidos do arquivo, rodados na
  transação do teste** -- a funcionalidade e a linha dela saem, a da "Vendas nova" fica, o login do colaborador e do
  gerente entra depois, com as mesmas permissões de antes, e o controle prova que a pergunta da sessão de antes, depois
  do corte, lança a exceção do `Acl`; o catálogo sem ela e com todas as outras; os privilégios da pessoa e o molde do
  grupo sem ela; o `PUT` e a exclusão dela 404, e o `PUT` da "Vendas nova" grava.
- `ensaiar-sql-do-passo-3.php` (num banco descartável, `descartavel_ensaio_passo3`, criado e apagado por ele): o
  procedimento manda rodar o roteiro num bloco de código depois da limpeza; com dados inventados (três funcionalidades,
  uma de nome parecido; quatro moldes; seis linhas de pessoas, uma em dobro), saem a funcionalidade, os 2 moldes e as 4
  linhas dela, o resto fica, e a segunda vez conta zero. Saída 0. Falsificado em cópias: o procedimento sem o comando, o
  `DELETE` por `LIKE '%caixa%'` e o roteiro sem apagar a funcionalidade -- os três com saída 1. **Um mutante equivalente:**
  sem o `DELETE` das linhas de pessoas a saída é 0, porque as duas chaves estrangeiras são `ON DELETE CASCADE` -- o
  `DELETE` explícito está ali para a contagem de antes dizer o que sai, e não muda o resultado.
- O `ensaiar-usuarios-adm-api.php` (o catálogo do `concediveis` contado contra o banco) passou a contar sem ela e a
  recusar que ela venha: 19 de 19 nos dois bancos de contrato.
- Mutantes, no MySQL, cada um vermelho: a sessão lendo "Vendas caixa" de novo (dois: o da sessão e o do corte, que lança
  a exceção do `Acl`), o catálogo com ela, o par dela, o `concediveis` com ela, o molde dela, o `PUT` dela e a exclusão
  dela.

**O que fica:** rodar o roteiro no passo 3 do corte, com a aplicação parada -- é da janela.


### D228 — A retirada de caixa conferia o saldo e depois gravava, sem trava entre as duas — CORRIGIDO em 26/09/2026 (API e tela antiga) — PARCIAL, lido em 05/10/2026 — PARCIAL em 15/10/2026 — CORRIGIDO em 07/11/2026 (a fatia 101: o caixa buscado antes do débito; a venda que termina depois do fechamento é o estado aceito de 15/10)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: a finalização da venda grava valor_final sem a trava; levá-la até lá mexe em toda venda das 32 lojas, decisão do dono.

Achado na leitura que precedeu a tela do caixa. A revisão da proposta apontou a
desigualdade: o cancelamento de retirada ficou de fora da primeira tela por
gravar sem transação e por corrida entre sessões (D199), e a retirada -- que
entrava -- tinha o mesmo desenho.

**O que acontecia.** `CaixaService::retirada()` comparava o valor com
`caixas.valor_final` e só depois gravava a retirada e o saldo novo. Dentro de uma
sessão isso bastava: a sessão em arquivo do PHP faz o segundo clique esperar o
primeiro. Entre **sessões diferentes do mesmo operador** -- dois navegadores, o
celular e o balcão --, os dois pedidos liam o mesmo saldo, os dois passavam, e a
gaveta ficava negativa. A API recalculava o saldo antes de chamar o serviço, mas
fora de trava nenhuma: o recálculo dos dois pedidos via o mesmo passado.

**Corrigido no serviço, para as duas portas:**

- toda retirada trava a linha do caixa (`SELECT ... FOR UPDATE`, pela entidade),
  **relê** o caixa depois da trava -- quem esperou tinha na memória o saldo de
  antes da retirada do outro -- e recusa caixa que fechou enquanto esperava;
- a da API (`retiradaComSaldoRecalculado`) recalcula o saldo **depois** da trava,
  na mesma transação;
- o fechamento da API (`fecharComSaldoRecalculado`) recalcula e fecha sob a mesma
  trava: sem ela, uma retirada que terminasse entre o recálculo e o `fechar()`
  ficava fora do número carimbado.

A tela antiga continua comparando com a coluna, como os testes de caracterização
descrevem (`CaixaTest`); ela só ganhou a trava e a releitura. Passar a recalcular
ali muda o que as 32 lojas veem quando a coluna está velha -- é decisão, não
conserto.

**Provado** por `tests/Caracterizacao/CaixaSobTravaTest.php`, nos dois bancos: a
ordem das leituras (o primeiro `SELECT` é o `FOR UPDATE` do caixa, nas três
escritas), a trava de verdade (outra conexão a prende e a escrita desiste com erro
de trava, e o erro vem do `FOR UPDATE`), a releitura, o caixa fechado no meio, e
a API contra a coluna velha. Nove mutantes -- sem a trava, sem a releitura, caixa
fechado aceitando, a API pela coluna, o fechamento sem recálculo, a regra do dia,
a conta a pagar, a soma por forma e a venda ignorando a regra -- ficaram todos
vermelhos.

#### Depois da revisão adversarial (26/09/2026, a mesma tarde)

Quatro revisores e quatro verificadores sobre a tela do caixa e este conserto:
**22 achados, os 22 confirmados**. Os do servidor, e o que foi feito com cada um:

- **A tela antiga recalculava e fechava fora da trava.** O `gerenciar` lia os
  lançamentos, gravava o saldo por cima (a "leitura que escreve") e depois fechava
  -- uma retirada da API que terminasse no meio saía do número carimbado, e a
  coluna ficava acima do saldo real para a retirada seguinte da própria tela
  antiga. **Consertado:** a tela antiga recalcula por `recalcularSobTrava()` e
  fecha por `fecharComSaldoRecalculado()`.
- **O cancelamento de retirada ficava fora da trava**, e a API aceitava cancelar
  retirada de caixa **já fechado** -- mudava o número carimbado e apagava a conta a
  pagar quitada. **Consertado:** `retiradaCancelar()` passa pela mesma trava, relê
  a retirada e o caixa depois dela, e grava as três coisas numa transação; a API
  responde 409 para caixa fechado. É também o fim da corrida entre sessões que o
  D199 deixou registrada.
- **A abertura não tinha trava**: duas sessões do mesmo operador abriam dois
  caixas, e a busca do caixa aberto (sem ordem) escolhia um ao acaso.
  **Consertado:** `abrirSemDuplicar()` trava a linha do operador antes de
  conferir, nas duas telas. Medido na cópia: 0 operadores com dois caixas abertos,
  entre 139.
- **A comparação do saldo era em `double`**: 0,70 + 0,10 dá 0,7999999999999999, a
  tela mostra R$ 0,80, e a sangria que zera a gaveta recebia recusa. **Consertado:**
  a comparação é em centavos inteiros.
- **O valor não tinha teto**: acima de nove dígitos inteiros o `double` não guarda
  o número exato. **Consertado:** a tela nova e a API recusam acima de
  999.999.999,99.
- **A recusa nova do serviço chegava à tela antiga como aviso em branco.**
  **Consertado:** a tela antiga diz "Valor indisponível." ou "Caixa fechado", pelo
  caixa relido.
- **O caso da trava só rodava dentro da transação do teste**: arrancar o
  `beginTransaction`/`commit` do serviço deixava a suíte verde, e em produção toda
  retirada viraria erro. **Consertado:** um caso novo roda fora da transação do
  `setUp`, grava de verdade, confere pela outra conexão que a outra sessão foi
  relida e que a trava soltou, e desfaz o que gravou.

Seis mutantes novos -- o cancelamento sem reler, o saldo em `double`, a abertura
sem trava e sem conferir, o serviço sem transação, o recálculo da tela antiga sem
recalcular -- ficaram vermelhos. O do `double` sobreviveu na primeira rodada, e o
defeito era do caso: passado como número, o PHP escreve o ruído com 14 dígitos --
"0.8" --, e ele sumia antes de chegar ao banco.

**O que ele NÃO alcança, e fica registrado:**

- **a venda grava `valor_final` sem a trava.** `VendaPagamentoService::finalizar()`
  soma o recebido à coluna a partir do caixa que leu no começo do pedido: uma
  retirada que termine no meio tem o desconto na coluna desfeito pela venda, e um
  fechamento que termine no meio tem o número carimbado regravado -- com a venda
  ligada a um caixa já fechado. A retirada e o fechamento da API não dependem disso
  (recalculam dos lançamentos); a trava da tela antiga, que lê a coluna, sim. E se
  o caixa fechar entre a conferência do controller e a busca de dentro do serviço,
  a venda chama método em nulo **depois** de debitar crédito -- o resto do D164.
  Levar a trava à finalização mexe no caminho de toda venda das 32 lojas: é
  decisão sua, e a revisão a deixou escrita.

Um detalhe do instrumento, para quem mexer nele: no MySQL, o `setUp` da suíte
(`porOCaixaDeFixtureNoDiaDeHoje`) faz um `UPDATE` na faixa 90001000+ que, em
REPEATABLE READ, trava toda linha que varre e o intervalo junto. A primeira versão
do caso da trava media isso -- era a outra conexão que esperava. O caso troca
aquela transação por uma limpa antes de prender a linha.

#### A venda soma o caixa por conta no banco — 15/10/2026, a resposta 85 (a) do dono (a fatia 5c)

O que este débito deixou de fora -- *"a venda grava `valor_final` sem a trava"* -- era "decisão sua", e o dono a tomou na
página do lote (a 85, a opção (a): *"soma o caixa e o crédito do cliente também por conta no banco ... nada feito no meio
se desfaz"*). `VendaPagamentoService::gravarAteAVendaFechada` soma o que entra no caixa por
`CaixaRepository::somarAoSaldo` (`valor_final = COALESCE(valor_final, 0) + ?`), na MESMA TRANSAÇÃO do primeiro `flush` da
venda fechada -- o que grava `vendas.caixa` e põe a venda no caixa (o dos débitos, o do troco ou o final) --, com o cliente
e o caixa travados antes dele, nessa ordem (`gravarComAVendaFechada`): a retirada que termina no meio, sob a trava do
caixa, fica na coluna, e a coluna que a tela antiga confere na retirada seguinte não sobe com a venda por cima dela.
Provado em `D321FinalizarUmaVezSoTest` (o caixa lido com 200, a retirada baixa a coluna para 170, a venda de 70 deixa 240;
e com outro processo segurando a linha do caixa).

**A verificação da fatia achou, e foi consertado no mesmo dia:** na primeira versão a soma era um comando à parte, depois
do `flush` já confirmado. O recálculo do caixa que caísse entre os dois -- `CaixaService::recalcular`, sob a trava: a
devolução de crédito, a retirada e o fechamento da API, e a tela de fechamento do ERP a cada vez que abre -- já contava a
venda (fechada e no caixa) e recebia a soma por cima: a venda duas vezes na coluna (a devolução de 30 sobre o caixa de 200
e a venda de 70 davam 310, e não 240); e, se o recálculo fosse o fechamento, por cima do número carimbado, que nada
recalcula depois. Na mesma transação, o recálculo vê a venda e a soma, ou nenhuma das duas. **Provado** com outro processo
recalculando no meio do finalizar, logo depois daquele `flush` (`testORecalculoDoCaixaNoMeioDoFinalizarNaoContaAVendaDuasVezes`:
ele espera o caixa e conta a venda uma vez; com a soma fora da transação, vermelho nos dois bancos), e a falha na soma
desfaz a entrada da venda no caixa e a reabre (`testAFalhaNaSomaDoCaixaDesfazAEntradaDaVendaNoCaixa`, com pontos de
salvamento). O mutante que tira só a trava do caixa passa nos dois bancos, e o porquê é medido: a chave de `vendas.caixa`
segura a linha do caixa para leitura até o fim da transação, e o recálculo de fora espera por ela; a trava explícita fica
para não depender disso e para não trocar a leitura pela escrita no meio, com outro na fila.

**O que fica:** a venda que termina depois do fechamento entra no caixa já fechado -- a soma vai por cima do número
carimbado, e a venda passa a ser dele, então o número fica igual à conta pelos lançamentos (até aqui o finalizar o
regravava com o número de antes); recusar pediria segurar a linha do caixa durante a venda. E o caixa que fecha entre a
conferência do controller e a busca de dentro do serviço ainda faz o finalizar morrer no meio, depois de debitar o
crédito (o resto do D164) -- agora com a venda reaberta.

#### O caixa buscado antes do débito — 07/11/2026, a fatia 101 (FINALIZAR-E-CANCELAR-SEM-DINHEIRO-EM-DOBRO)

O resto que ficou escrito acima -- *"o caixa que fecha entre a conferência do controller e a busca de dentro do serviço ainda
faz o finalizar morrer no meio, depois de debitar o crédito"* -- acabou. `VendaPagamentoService::finalizar` busca o caixa
aberto do operador ANTES de qualquer escrita (antes da conferência do caixa de outro dia e do `UPDATE` condicional da venda),
e sem caixa recusa sem nada gravado; o caixa achado ali é o que vai à venda (`gravarAteAVendaFechada` não o busca mais, e não
há nulo depois do débito). As portas dizem a frase do D164 quando o serviço recusa assim: a API, o 409 com `motivo:
sem_caixa` (`Adm\ComandasController::finalizarAction`, depois da pergunta da venda já fechada); o ERP, *"Não há caixa aberto
para este operador. Abra o caixa antes de finalizar."* (`VendasPagamentosController::SEM_CAIXA_ABERTO`), em vez do *"Não foi
possível finalizar a venda."*.

**Provado** em `D321FinalizarUmaVezSoTest`, nos dois bancos: sem caixa, o finalizar recusa (sem erro), e a venda em
andamento, o crédito, o do programa, o caixa e o pacote ficam como estavam (o caso que até aqui usava o caixa nulo como "erro
no meio" passou a ser este; o `D452SessaoDoPacoteForaDaMesmaVendaTest` trocou o mesmo erro pela trava do cliente que
desiste); o caixa que fecha entre a porta e o serviço dá o 409 do D164 na API e a frase no ERP, com o crédito intacto. Os
mutantes, nos dois bancos: a busca do caixa de volta para depois do débito (três vermelhos); e, no MySQL, cada porta sem a
frase.

**Como o caixa "fecha no meio" nos casos (a verificação da fatia 101):** pelo `servicoDepoisDoCaixaFechado`, que fecha o caixa
com um `UPDATE` na MESMA conexão do caso, antes de chamar o `finalizar` do serviço -- e não com outro processo, como as notas
pediam. O efeito provado é o mesmo, e outro processo não acrescentaria um caminho: a busca do começo do finalizar é leitura
sem trava; o fechamento que outra sessão CONFIRMOU antes dela é exatamente o que o caso monta (a busca não acha caixa e o
finalizar recusa), e o que ela confirma DEPOIS da busca cai no estado aceito abaixo (a venda entra no caixa fechado), e não
na recusa.

**O que fica é o estado aceito em 15/10/2026:** a venda que termina depois do fechamento entra no caixa já fechado (a soma vai
por cima do número carimbado, que fica igual à conta pelos lançamentos). O caixa que fecha depois da busca do começo do
finalizar cai nesse caso, e não mais no erro.

### D229 — "Com desconto: Não" trazia as vendas COM desconto, nas vendas realizadas — CORRIGIDO em 26/09/2026 (a listagem, nas duas telas)

Achado ao começar a tela de vendas realizadas da tela nova, lendo o filtro que ela
ia oferecer.

A tela antiga oferece "Com desconto: Sim (1) / Não (0)", e
`VendaRepository::listagemIndexVendas()` fazia `if ($request['desconto'] != "")`
seguido de `descontoTotal > 0`. O `'0'` também é diferente de vazio: **as duas
respostas davam a mesma lista, a das vendas com desconto**, e o "Total
descontos" do topo vinha junto. A API nova, que passa o filtro para a mesma
consulta, herdava o defeito.

**O tamanho, medido na cópia** (só `SELECT`, vendas concluídas, sem fixtures):

| ano | com desconto | sem desconto (zero ou nulo) |
|---|---:|---:|
| 2024 | 49.939 | 228.581 |
| 2025 | 42.263 | 218.999 |
| 2026, até a cópia | 20.607 | 121.849 |

Quem pedia as 218.999 de 2025 recebia as 42.263.

**E a coluna é anulável.** `vendas.desconto_total` está nula em **536.783 vendas
concluídas de 2015 a 2020** -- a coluna veio depois delas. Um conserto só com
`= 0` as tiraria das DUAS listas, e "com + sem" deixaria de dar "todas".

**Corrigido na consulta, para as duas portas:** `'0'` filtra
`descontoTotal = 0 OR descontoTotal IS NULL`; `'1'` continua `> 0`; qualquer
outro valor não vazio segue como antes na tela antiga. A API nova passou a
recusar com 422 o que não for `0` ou `1` (`RegraDaListagemDeVendas`).

**Provado** no `ListagemDeVendasTest`, nos dois bancos, com três vendas num dia
sem mais nenhuma -- desconto de R$ 10,00, desconto zero e desconto nulo: "com"
traz a primeira, "sem" traz as outras duas, e sem filtro vêm as três. O caso do
"sem" devolvia a de R$ 10,00 antes do conserto. E no ensaio de recusas por HTTP,
os controles `9h` e `9i` gravam duas vendas de hoje -- uma com desconto, outra
sem -- e conferem que cada filtro traz uma e não a outra.

**A primeira versão do `9h` não media nada**, e foi a revisão adversarial que
viu: o gerente de fixture é do caixa, e quem é do caixa só vê as concluídas de
hoje -- a lista chegava vazia, e o controle passava com qualquer filtro. A
frase acima, na versão de antes, prometia uma cobertura que não havia.

**O que ele não alcança:** a exportação em XLS da mesma tela não aplica o filtro
de desconto -- nem o de colaborador. É o D230.

### D230 — A exportação em XLS das vendas realizadas ignora os filtros de desconto e de colaborador — PARCIAL em 03/10/2026: o desconto nas três abas; o colaborador continua com a escolha — CORRIGIDO em 06/10/2026: o colaborador, pela regra (a) do dono

Achado junto com o D229. `VendasController::indexVendasExportarXlsAction` exporta
o que a sessão guardou do último filtro (`relatorio_vendas_realizadas`), e as
três abas saem de `listagemIndexVendasExportarXls()` (duas) e
`listagemIndexVendasExportarXlsPagamentos()`. As duas consultas aplicam situação,
unidade, cliente, código, NPS, forma de pagamento e período -- e **não** aplicam
`desconto` nem `usuario` (o colaborador).

Quem filtra "as vendas do Joaquim com desconto" na tela e exporta recebe todas as
vendas do período. Conferido lendo o código, e não executado: a exportação é da
tela antiga, e a tela nova não exporta.

**Não consertado agora**, porque não é da tela nova e o conserto tem uma escolha: a
aba 0 é uma linha por par item x pagamento, e "colaborador" num item não diz nada
sobre o pagamento da mesma linha -- filtrar a aba pelo colaborador do item muda o
que ela soma. Fica para quando a exportação migrar.

#### PARCIAL em 03/10/2026: o desconto, nas três abas

**O conserto.** A regra do D229 saiu da listagem para `VendaRepository::filtroDeDesconto()`, e as
quatro consultas da tela -- a listagem e as duas da exportação, que fazem as três abas -- leem dela:
o `'0'` traz as de desconto zero e as de desconto nulo, o `'1'` as de desconto acima de zero, e o
vazio não filtra. Com a regra num lugar só, a listagem e a exportação não têm como divergir de novo.
Para quem usa: quem filtra "Com desconto: Sim" ou "Não" e exporta recebe no XLS as mesmas vendas que
a tela mostrou; sem o filtro, nada muda.

**Provado:** no `ListagemDeVendasTest`, 3 casos novos nos dois bancos, com as três vendas do D229
(desconto de R$ 10,00, zero e nulo) -- "com" traz a primeira nas três abas, "sem" as outras duas, e
sem filtro as três. Antes do conserto, "com" e "sem" traziam as três em todas as abas. Três mutantes,
cada um vermelho: o filtro arrancado da consulta das abas 0 e 1, o da aba 2, e o `'0'` caindo no
`> 0` (que pega também os dois casos da listagem do D229). O `auditar-group-by` continua com saída 0.

**O que ficou: o colaborador.** Continua de fora pela escolha escrita acima -- a aba 0 é uma linha
por par item x pagamento, e filtrar pelo colaborador do item muda o que ela soma. Fica para quando a
exportação migrar, ou para o dono dizer o que a aba deve somar -- a pergunta, com as opções, está no
lote.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 16): a opção (a), "Abas 0 e 1 pelos
itens dele, aba 2 pelas vendas em que ele tem item" -- o XLS bate com a lista e com o total da tela; a
coluna de pagamento da aba 0 e a aba 2 trazem o valor da venda inteira, como a aba 0 já faz hoje. A
implementação está na fila.

#### CORRIGIDO em 06/10/2026: o colaborador, pela regra (a) do dono

**O conserto.** As duas consultas da exportação passaram a ler o `usuario` do pedido que a lista guarda
na sessão: `listagemIndexVendasExportarXls()` -- as abas 0 e 1 -- filtra pelo ITEM (`col.id =
:usuario`, a mesma condição da listagem), e `listagemIndexVendasExportarXlsPagamentos()` -- a aba 2 --
pelas VENDAS em que ele tem item (`EXISTS` em `vendas_produtos`, para que o pagamento não dependa de
quantos itens dele a venda tem). Para quem usa: quem filtra "as vendas do Joaquim" e exporta recebe no
XLS as vendas que a tela mostrou; a aba 1 soma o "Total" do topo; a aba 0 traz só os itens dele, cada
um cruzado com os pagamentos da venda; a aba 2, os pagamentos daquelas vendas. **A coluna de pagamento
da aba 0 e a aba 2 trazem o valor da venda inteira, e isso é a regra**, e não sobra da consulta: o
pagamento é da venda, e rateá-lo entre os itens inventaria uma divisão que o caixa não fez. A parte dele
é a aba 1; a soma que fecha com o caixa é a da aba 2. Sem o filtro, nada muda.

**Provado:** no `ListagemDeVendasTest`, 5 casos novos nos dois bancos, com três vendas num dia sem
outras -- a de dois colaboradores (um serviço dele de 30 e um do outro de 20, paga com 30 + 20), a só do
outro (40) e a só dele (25 + 15, paga com 40). Com cada um dos dois filtrados, as três abas trazem as
vendas da lista, e a aba 1 soma o total da tela (70 e 60); as abas 0 e 1 não trazem o serviço do outro
na venda dos dois; o serviço de 30 vem na aba 0 com os dois pagamentos inteiros (20 e 30), e a aba 2
soma 90 -- as duas vendas --, e não os 70 dele; e o controle sem filtro: as três vendas nas três abas,
130 nas três somas. Esses quatro montam o pedido pela regra da API nova (`RegraDaListagemDeVendas`). O
quinto passa pelo caminho da TELA ANTIGA, de ponta a ponta: `indexVendas` despachada em processo como
GET, com `?usuario=` e as datas em d/m/Y, grava o pedido em `relatorio_vendas_realizadas`; a
`indexVendasExportarXls`, despachada depois com a mesma sessão, o lê de volta e roda as três consultas
(um espião no repositório guarda o que cada uma devolve e interrompe a ação antes do `gerarXls`, que
termina em `exit`); as três abas trazem as vendas que a lista mostrou, e as somas batem (70/60 na aba 1,
90 na aba 2). Mutantes, um de cada vez, restaurados e conferidos por sha1: o filtro arrancado das abas 0
e 1 (4 casos), o da aba 2 (3 casos) e as abas 0 e 1 filtradas pela venda em vez do item (4 casos -- a
aba 1 somava 90); e os dois que só o quinto pega -- a lista guardando o pedido sem o `usuario` e a
exportação descartando o `usuario` que leu (1 caso cada; o segundo também no PostgreSQL). O
`auditar-group-by` e o `conferir-injecao-dql` continuam com saída 0.

**O que não foi visto:** o arquivo XLS que o `gerarXls` escreve -- as colunas de cada aba e o download.
O que está provado é o caminho até ele: a lista guarda o pedido na sessão, a exportação o lê e entrega às
três consultas, e as consultas devolvem as linhas e as somas acima.

### D231 — O total de descontos das vendas realizadas contava a venda uma vez por item ou por pagamento — CORRIGIDO em 26/09/2026

Achado pela revisão adversarial da tela de vendas realizadas. Não é da tela nova:
é da consulta que ela e a tela antiga usam.

`VendaRepository::listagemIndexVendas($request, true, true)` -- o "Total
descontos" do topo -- projeta `ven.descontoTotal` e herdava o agrupamento do
total de VALOR: com filtro de colaborador, `GROUP BY pro.id`; com forma de
pagamento, `GROUP BY venpag.id`. Então:

- **no MySQL**, o desconto de uma venda entrava uma vez por ITEM do colaborador
  -- dois serviços do mesmo barbeiro numa venda com R$ 10,00 de desconto davam
  R$ 20,00 --, ou uma vez por pagamento da forma filtrada;
- **no PostgreSQL**, a consulta era recusada (`vendas.desconto_total` fora do
  agrupamento por outra tabela), e a tela respondia 500.

A tela antiga filtra por colaborador e por forma de pagamento, então o número
inflado estava na tela de produção -- não medido quanto. A tela nova não oferece
esses dois filtros; a API os aceita.

**Por que nenhum portão viu:** o `auditar-group-by` lê os ramos concatenados, e o
`GROUP BY ven.id` de um ramo cobria o `ven.descontoTotal` do outro; a anotação
`@agrupamento-conferido` do método dizia que os três ramos agrupavam "pela chave
primária da própria raiz", e o do desconto não agrupava.

**Corrigido:** o ramo do desconto agrupa sempre por `ven.id`. **Provado** no
`ListagemDeVendasTest`, nos dois bancos, com uma venda de dois itens do mesmo
colaborador e dois pagamentos da mesma forma: o total de descontos é R$ 10,00
com cada filtro -- era R$ 20,00 no MySQL e erro no PostgreSQL.

**O irmão, conferido:** `listagemIndexRelatorioDescontos()` tem os mesmos três
ramos, mas o `DescontosController` nunca passa `usuario` nem `pagamento` -- os
ramos são código morto ali, e o defeito não aparece. Ficaram como estavam.

### D232 — Quitar fiado não conferia se a dívida já estava quitada, e gravava sem transação nem trava — CORRIGIDO em 26/09/2026 (as duas telas); o caixa de outro dia, só na API — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 15/10/2026 (a resposta 88)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decisão do dono: a tela antiga recusar o caixa de outro dia, como a API, e desfazer as 16 quitações duplicadas do passado.

Achado ao começar a tela de devedores da tela nova, lendo a quitação.

`DevedorService::pagarDebito()` cria uma venda, um pagamento, baixa o débito e
soma o valor ao caixa aberto de quem recebe. Nada nele conferia se o débito ainda
estava aberto: a tela antiga chamava direto, e a API nova conferia **antes** de
chamar, fora de trava. E eram quatro `flush()` sem transação.

**Aconteceu, medido na cópia** (só `SELECT`, sem fixtures): das **971 quitações**
de 2018 a 2026, **16 débitos foram quitados duas vezes** -- 16 vendas a mais,
**R$ 838,98** somados de novo em caixas, 12 delas no mesmo caixa da primeira.
Só uma veio dentro de um minuto (o duplo clique); 9 vieram mais de um dia depois
-- lista velha numa aba, ou o modal reaberto. De 2018 a 2023, e nenhuma depois.

**E mais dois vãos, na mesma leitura:**

- **as formas que não trazem dinheiro** -- crédito do cliente, crédito do
  programa de fidelidade e pacote -- quitavam a dívida sem dinheiro nenhum
  entrar: a quitação não os debita de lugar nenhum. A tela antiga não as oferece
  (`DevedorModal` lista 1, 2, 3, 4, 7 e 8), e a cópia não tem nenhuma quitação
  com elas; a API aceitava qualquer forma cadastrada;
- **o caixa de outro dia recebia** (o D28): 154 das 971 quitações entraram num
  caixa aberto em dia anterior ao da quitação.

**Corrigido:**

- `DevedorService::quitar()`, que as duas telas chamam (o `pagarDebito()` saiu),
  é uma transação com **três travas, na ordem cliente -> débito -> caixa**. O
  débito e o caixa são **lidos pela própria leitura com trava**. O débito quitado
  noutro balcão é recusado (`ja_quitado`); o caixa é buscado já travado -- se
  fechou enquanto se esperava, não é achado. As formas que não trazem dinheiro
  são recusadas no serviço (`forma_sem_dinheiro`). A unidade é conferida **antes**
  do estado: a outra franquia não fica sabendo se a dívida dela foi paga.
- A API responde 422 à forma que não traz dinheiro, 409 com `detalhes.motivo` a
  cada recusa, e manda as formas aceitas no envelope da lista. O código da
  venda na busca só aceita número (ia cru para `ven.id`).
- **O caixa de outro dia é recusado só na API**, como a finalização faz. A tela
  antiga continua aceitando: mudar o balcão das 32 lojas é decisão sua.

**A revisão adversarial da fatia (26/09/2026, 26 revisores, 18 achados
confirmados) derrubou duas coisas que este registro afirmava:**

- **"cada trava seguida de releitura" perdia dinheiro no MySQL.** Em REPEATABLE
  READ, a primeira leitura sem trava tira a foto da transação, e as seguintes
  leem a foto. O `refresh` do débito (sem trava) tirava a foto antes de a
  quitação chegar ao caixa, e o `refresh` do caixa (sem trava) lia o saldo de
  **antes** da espera -- justamente a espera que a trava existe para cobrir: a
  retirada gravada noutro balcão sumia do `valor_final`. O teste de então fazia a
  escrita pela mesma conexão, e a transação sempre vê o que ela mesma escreveu.
  Agora o `refresh` é **com trava**, e o teste grava por outra conexão, depois da
  foto. No PostgreSQL (READ COMMITTED) não acontecia.
- **"a ordem não cruza com nenhuma escrita"** era falso: a venda que a quitação
  insere aponta para o cliente, e no MySQL a chave estrangeira pede trava na
  linha dele; a finalização da comanda que paga débitos grava o cliente e depois
  o débito. As duas se prendiam em ciclo. Com o cliente travado primeiro, quem
  chega depois espera. **Quando a finalização ganhar trava (D228, D164), é nesta
  ordem: cliente -> débito -> caixa.**

**Provado:** `DevedorSobTravaTest`, nos dois bancos -- a ordem das três travas e
o cliente antes de tudo, as três presas por outra conexão, o débito lido só com
trava, quitar de novo recusado com o caixa subindo uma vez, o débito relido, o
saldo gravado **por outra conexão depois da foto** preservado, a tela antiga
respondendo pela `respostaDaQuitacao()` que a ação usa, as três formas recusadas
sem tocar em nada, e um caso fora da transação do teste que grava de verdade e vê
as travas soltarem; `DevedorTest` com o débito quitado de outra unidade dizendo
`outra_unidade`; 13 mutantes do servidor, todos mortos -- entre eles o `refresh`
do caixa sem trava, que só o teste da foto mata; e os casos 9k a 9q do ensaio de
recusas por HTTP, com uma quitação de verdade conferida no banco -- o caixa sobe
R$ 50,00 e nasce uma venda só.

**O portão `conferir-recorte-por-unidade` lia a assinatura:** vigiando
`unidadeId`, o `$unidadeId` recebido como parâmetro bastava para ele sair verde
com a comparação arrancada -- em `quitarSobTrava` e em `excluirParcelas` (D132),
desde sempre. O recorte agora começa depois da `{`; arrancada a comparação de
cada um, o portão fica vermelho.

**O que ele não alcança:** as 16 quitações duplicadas do passado continuam nos
caixas onde entraram. Desfazê-las é mexer em caixas fechados há anos -- é decisão
sua, e o número está acima.

#### CORRIGIDO em 15/10/2026 -- a resposta 88 (a fatia 4 da fila das telas que faltam, RESPOSTAS-DO-BALCAO): a tela antiga recusa o caixa de outro dia

**Decidido pelo dono em 12/10/2026 (a resposta 88, opção "a", a recomendada):** *"A tela antiga recusa também, com a
mesma frase da venda, na quitação e na compra de crédito. Quem deixou o caixa de ontem aberto fecha e abre de novo, como
já faz para vender."* A recomendação dizia que "as 16 em dobro ficam como estão, salvo se você disser outra coisa na
observação", e a observação veio vazia: as 16 quitações em dobro de 2018 a 2023 ficam.

**O conserto:** `DevedoresController::pagarDebitoAction` (o "Quitar" do modal de débitos) pergunta
`VendaPagamentoService::caixaDeDataAnterior` antes de quitar e, com o caixa de outro dia, responde
`{sucesso: false, mensagem: <a frase>}` sem gravar nada; o modal a mostra como as outras recusas. A frase é a do PDV
antigo ao recusar a entrada -- "O caixa foi aberto no dia dd/mm/aaaa, para realizar uma nova venda você precisa fechar
o caixa e abrir novamente." --, que passou a morar em `RegraDoCaixaDoDia::fraseDaTelaAntiga`, chamada pelas três portas
(o `VendasController::novoAction` também). A API segue com a frase e o `motivo` dela.

**Na cópia:** 154 das 971 quitações entraram num caixa de dia anterior; a última, em 14/10/2022.

**Provado** nos dois bancos (`CaixaNaoAtravessaODiaTest`, a ação despachada em processo): com o caixa de ontem, a frase,
nenhuma venda, o caixa parado e o débito em aberto; com o de hoje, quita como sempre; e o PDV antigo diz a mesma frase.
**Mutantes:** sem a recusa (1 vermelho); a frase trocada pela da API (4, as duas portas e o PDV). O portão
`conferir-recorte-por-unidade.php` ganhou a vigia do bloco (a consulta, o `if` e a frase): com a consulta arrancada ou o
`if` falso, sai 1.

### D233 — O `PUT` de clientes apagava o que o corpo omite — CORRIGIDO em 27/09/2026: o `PUT` exige o cadastro inteiro, e a tela nova edita por ele; o país vai com o DDI — CORRIGIDO em 03/10/2026

Achado ao mapear a tela de clientes da tela nova (27/09/2026), lendo o
caminho de gravação antes de oferecer "editar".

`PUT /adm/api/clientes/:id` monta o arranjo do serviço com TODAS as chaves
(`Adm\ClientesController::paraOServico()`), e a que o corpo não traz vira
`null`. `ClienteService::update()` passa o arranjo ao `ClassMethodsHydrator`,
que chama o setter de cada chave -- inclusive com `null`. Um `PUT` só com os
campos obrigatórios grava nulo no e-mail, CPF, RG, profissão, anotações,
origem, CEP, estado, cidade, bairro, número e complemento, tira o convênio e
desliga o `marketing`.

**E três coisas na mesma leitura:**

- **o logradouro ganha o prefixo `' -- '`**: o controller o põe porque o
  `insert()` faz `explode(' -- ', ...)` e usa a segunda parte, e o `update()`
  não faz o `explode` -- o `PUT` grava `' -- Rua X'`, e um `POST` sem
  logradouro grava `' -- '`;
- **a origem nula desliga a trava da primeira visita**:
  `ClientePrimeiraVisitaService` só a exige de quem tem origem `Aplicativo`, e
  a API só exige a origem no cadastro novo;
- **os 409 do cadastro vêm sem `detalhes.motivo`** (e-mail repetido, telefone
  repetido com o dono só dentro da frase, sentinela, já ativo ou inativo), e o
  `GET` devolve o telefone como `{completo, ddi, semMascara}` enquanto o `PUT`
  pede `{ddi, numero}`.

**Por que não fere ninguém hoje:** o único consumidor do `PUT` é o passo 3 do
ensaio do fluxo, que confere o nome e o id. A tela nova de clientes (fatia 1)
só lê, e leva à tela antiga para cadastrar e editar. Na cópia, nenhum
logradouro tem o prefixo: ele só nasceria por esta API.

**O conserto vem antes da fatia 2** (a edição pela tela nova): o `PUT` passa a
exigir o corpo inteiro -- chave ausente é 422, `null` explícito limpa --, a
origem gravada não vira nula, o logradouro vai sem prefixo, e os 409 ganham
`detalhes.motivo`.

**O conserto (27/09/2026, na fatia 2 da tela de clientes).** O corpo do
cadastro mora em `ISCadastro\Regra\RegraDoCadastroDeCliente`, que não consulta
banco:

- o `PUT` exige as 12 chaves, e as de dentro do telefone e do endereço -- a
  ausente é 422 com o campo nomeado, e `null` explícito limpa. O `POST` segue
  aceitando só os obrigatórios;
- a origem é exigida nos dois verbos, sem conferir contra o catálogo: há
  "Indicação - N" gravadas por outro fluxo, e a edição tem de aceitá-las;
- o logradouro só ganha o prefixo no cadastro novo, e só quando há rua;
- o DDI só com dígitos, o convênio conferido no banco (422 no campo, e não o 500
  do `flush`), e o e-mail comparado em minúsculas -- no PostgreSQL o `=`
  distingue caixa, e "Fulano@..." passava pela conferência e gravava repetido;
- os 409 levam `detalhes.motivo` -- `sentinela`, `email_repetido`,
  `telefone_repetido` (com `donoId`), `ja_ativo`, `ja_inativo`. Na alteração o
  telefone só é conferido quando muda, e não para quem já é dependente; e a
  frase do 409 não manda mais "reenviar com `responsavelId`" a quem altera, que
  o `PUT` não lê;
- o cadastro ganhou `telefone.numero` -- o telefone sem o `+DDI ` da frente,
  que é o que o `PUT` recebe --, e `GET /adm/api/clientes/catalogos` entrega as
  origens, as 27 UFs e os convênios da unidade (`ConvenioRepository::comboDaUnidade`).

**A prova que importa é a volta:** o corpo montado com o que o `GET` devolve,
passado pelo `ClienteService::update()` de verdade, deixa o cadastro como estava
(`CadastroDeClienteTest`, nos dois bancos). E ela achou a ressalva: `setNome()`,
`setLogradouro()`, `setCidade()` e `setComplemento()` passam o valor por
`IdealizeUtil::normalizarNome()`. O que o serviço gravou volta igual; o que outro
fluxo gravou sem normalizar ("Rua Das Flores") sai normalizado no primeiro `PUT`
-- como sai quando a tela antiga salva.

**O que continua, e fica dito:**

- **o último a salvar vence**: o `PUT` não confere se o cadastro mudou desde que
  o formulário abriu -- como a tela antiga;
- ~~**o país não está no corpo**: trocar o DDI não troca `clientes.pais`, que
  `Cliente::getTelefone(false)` usa para ler o DDD; a tela antiga troca os dois;~~
  CORRIGIDO em 03/10/2026 -- ver "O país vai com o DDI", abaixo;
- **na cópia anonimizada o telefone não tem o `+DDI `** (o anonimizador grava
  "(11) 90000-NNNN"), e o DDI lido é o DDD entre parênteses: o formulário pede o
  DDI antes de salvar. Na produção o formato é `+55 (47) ...`;
- **10% dos cadastros da cópia não têm origem** (25.056 de 242.597): a primeira
  edição pela tela nova pede uma, porque o `PUT` a exige.
- **o telefone gravado sem o espaço depois do DDI** (`+5547...`, só em linhas
  inseridas por SQL, como as fixtures -- todo fluxo do sistema grava
  `+DDI número`) chega com o número inteiro em `ddi` e o `numero` vazio: o
  formulário marca o DDI, e o operador separa os dois.
- **a máscara do telefone vai pelo país do cliente** (fatia 3, revisão
  adversarial): `getTelefone(false)` parte o telefone nos espaços e, fora do
  Brasil, fica só com o primeiro pedaço. O cliente sem país (2.576 na cópia)
  passa a ter o telefone só com os dígitos quando é salvo pela tela nova -- o
  `semMascara` não muda, e o ERP deixa de cortar o número.

**A revisão adversarial da fatia (27/09/2026) confirmou mais quatro, do mesmo
corpo, todos consertados:** o cadastro novo sem rua passava `null` ao
`explode()` do `insert()` (Deprecated impresso, o D100); o valor que não é texto
-- um arranjo no lugar do e-mail -- passava como vazio e apagava o campo com
200; o texto acima da coluna e o convênio acima do maior inteiro viravam 500 no
`flush` em vez de 422; e o ano de nascimento antes de 1900 passava pelo
`checkdate`. Os limites são medidos depois de `normalizarNome()`, que alonga o
texto com pontos.

#### O país vai com o DDI — CORRIGIDO em 03/10/2026

A tela antiga troca o DDI e o país juntos; a nova deixava o país velho, e
`getTelefone(false)`, que parte o telefone pelo país, lia o número do cliente que
passou do Brasil para Portugal como brasileiro ("912345678 ", com o pedaço do DDD que
não existe).

**O conserto:** o `PUT` lê `endereco.pais` -- a mesma chave e o mesmo lugar em que o
`GET` já o devolvia, e a volta continua fechando -- e o grava junto com o DDI
(`RegraDoCadastroDeCliente::paraOServico`, só no `PUT`). É uma das chaves exigidas,
como as outras: a ausente é 422 com `endereco.pais`. O valor é um da lista do
formulário antigo (`Arrays::paises`: Brasil, Estados Unidos, Portugal), em texto exato
-- `getTelefone(false)` compara com "Brasil" --, e o que não está nela é 422 no campo;
na cópia, todo país gravado está na lista ou é nulo (Brasil 238.334, nulo 2.576,
Portugal 1.684, Estados Unidos 2, vazio 1). `GET /adm/api/clientes/catalogos` entrega
`paises`. Na tela nova, o seletor "País" aparece na edição, logo depois do telefone (a
ordem do formulário antigo); o corpo leva o país escolhido, e a máscara do telefone
segue ele.

**O país vazio é 422 no `PUT` (a revisão de 03/10/2026).** A primeira versão deste
conserto deixava `null` (ou vazio) limpar o país, e o seletor da tela nova oferecia o
"—", que ia como `null`. O formulário antigo não deixa: o "*País" é um `Select` que o
`ClienteFilter` não declara, e o Laminas lhe dá a especificação padrão do `Select`,
`required` -- a edição do ERP nunca grava país vazio. Limpar mudava o que lê
`pais == "Brasil"` para o cliente: o endereço obrigatório do `ClienteFilter`, a classe
do telefone no `editarAction`, as etapas da fidelidade. Agora o vazio é 422 com
"Obrigatório: um país da lista", e a tela nova o marca antes de enviar ("Escolha o país
na lista.", com o foco no seletor, que passou a `aria-required` e à marca de
obrigatório). **O cliente gravado sem país** (2.576 na cópia, e o das fixtures)
escolhe um para salvar, como na tela antiga. O cadastro novo continua sem ler nem
exigir o país.

**O cadastro novo não lê o país, nas duas telas:** `Cliente::__construct` grava o país
da unidade da sessão por cima do que vier, e o formulário antigo também não o muda no
cadastro (só na edição). A tela nova não oferece o seletor ali, e o `POST` não o
exige; se vier, é conferido contra a lista e não vai ao serviço.

**Provado:** `CadastroDeClienteTest` (62 execuções com o `ClienteApiTest` desde a revisão, verdes nos
dois bancos): o `PUT` que troca o DDI para 351 e o país para Portugal grava os dois,
e `getTelefone(false)` devolve o número; os três da lista passam; minúsculo, fora da
lista e em arranjo são 422 no campo; o país ausente é 422; e o cadastro novo não manda
o país ao serviço. Da revisão: nulo, vazio e só espaços são 422 no `PUT` e passam no
`POST`; o cliente das fixtures, sem país, não volta pelo `PUT` com o corpo que o JSON
dele monta até escolher um; e a volta do cadastro inteiro (`testOPutComOQueOJsonDevolve...`)
passou a partir de um cliente com país. No vitest (`formularioDoCliente.teste.ts` e
`ClientesLigada.teste.tsx`): o corpo leva o país e a máscara segue ele; a edição mostra
o país do cliente e manda os dois juntos; o país vazio não sai, e a tela marca e foca o
seletor; o cliente sem país escolhe um e salva; o país que o servidor recusa marca o
campo; os seis obrigatórios da edição; e o cadastro novo não tem o seletor e sai numa
unidade sem país. Mutantes vermelhos: o `PUT` sem gravar o país, sem a lista, com o
país fora das chaves exigidas, e o `POST` lendo o país; da revisão, o vazio passando
(2 casos) e o país exigido também no `POST` (5), nos dois bancos; no front, o corpo sem
o país, a máscara pelo país fixo, o seletor que não muda, o seletor no cadastro novo e
o cadastro novo sem o país da unidade; da revisão, a conferência sem o país (4 casos),
a tela que confere o cadastro novo como edição (1), a que confere a edição como
cadastro novo (2) e o país fora dos obrigatórios (2); e o catálogo sem `paises`, que
só o caso 35p do ensaio de recusas pega. Os ensaios HTTP, contra o banco de contrato
nos dois bancos: o de recusas (o catálogo com `paises`, o corpo dos `PUT` com o país, e
o caso novo 35h2, o `PUT` com o país nulo, 422 em `endereco.pais`) e o do fluxo 22
passos (o passo 3 manda o país que leu). **A tela não foi vista no navegador** -- só o
vitest, os casos do `PUT` e os ensaios.

**O último a salvar vence** continua nas decisões, como acima.

### D234 — O cadastro novo pela API aceitava qualquer responsável, e o dono do telefone podia ser o cadastro do sistema — CORRIGIDO em 27/09/2026 (na API) e em 05/10/2026 (na tela antiga)

Achado pela revisão adversarial da edição de clientes (27/09/2026), onde foi
refutado por não ser daquela fatia, e consertado na seguinte -- o cadastro
novo pela tela nova, que é quem manda o `responsavelId`.

`POST /adm/api/clientes` aceita `responsavelId`: é o dono do telefone que o 409
`telefone_repetido` nomeia, e com ele o cliente novo nasce dependente de quem já
tem o número (D32). **Nada era conferido:**

- qualquer `responsavelId` dispensava a conferência do telefone repetido -- o
  dependente de alguém de outro número passava;
- o id que não existe virava 500 no `flush` (a chave estrangeira do
  responsável);
- o id 1 fazia o cliente novo dependente do "Cliente Avulso" (D18).

**E, do outro lado, o dono do 409 podia ser o próprio cadastro do sistema:** a
busca pelo telefone ordena por id e não tira os ids 1 e 2, e com a sentinela
dona do número o 409 mandava reenviar com ela -- que a guarda nova recusa. O
número ficaria sem saída.

**O conserto (27/09/2026):** `RegraDoCadastroDeCliente::conferirResponsavel`
exige que o responsável exista, esteja ativo, não seja cadastro do sistema e
tenha o MESMO telefone do cadastro novo -- o que não serve é 422 no campo; e o
dono do 409 nunca é uma sentinela: quando só ela tem o número, o cadastro passa.
O 409 passou a dizer também o nome do dono (`donoNome`), que é por onde o balcão
decide se o cliente novo é dependente.

**A tela antiga continua igual:** o modal de cadastro do ERP
(`ClientesController::validarFormulario`) pula a conferência do telefone para
qualquer `responsavel_id` não vazio, e oferece como dono o de menor id, sentinela
inclusive. Ele só manda o `responsavel_id` depois da própria pergunta, com o dono
que ele mesmo achou -- o furo é de quem monta o pedido à mão.

**Provado:** três casos na caracterização (`CadastroDeClienteTest`: as quatro
recusas do responsável, a forma do `responsavelId` só no cadastro novo, e o
dependente gravado pelo serviço de verdade), nos dois bancos; sete no ensaio de
recusas, nos dois bancos (35w a 35z2: o dono nomeado no 409, o responsável que
não existe, o de outro telefone, o avulso como responsável e como dono, e os
controles de 201); e 9 mutantes -- cinco da regra, pela caracterização, e
quatro do controller, pelo ensaio --, todos mortos pelo caso que devia, entre
eles o da sentinela como responsável, que só morre com o avulso dono do número.
E no navegador, pela tela nova: a recusa com o nome do dono e o dependente
cadastrado pela oferta.

#### O conserto na tela antiga — 05/10/2026

A regra é a da API, e o registro não pedia decisão. No ERP
(`ISCadastro\Controller\ClientesController`):

- **o responsável do cadastro novo é conferido** por
  `RegraDoCadastroDeCliente::conferirResponsavel`, com a mesma frase, na linha da
  mensagem do modal (`validarFormulario`, quando o `id` vem vazio) -- e também na
  página "Novo cliente" (`novoAction`), o irmão: ela entrega o `responsavel_id` do
  POST ao `insert()` do mesmo jeito. O id tem de ser um número inteiro: `(int)
  '9abc'` é 9, e o serviço grava o texto como veio;
- **o dono do número nunca é um cadastro do sistema** (`donoDoTelefone`, os ids 1 e
  2 fora), no modal e no `validarTelefone` das páginas "Novo" e "Editar": com a
  sentinela e uma pessoa no mesmo número, o oferecido é a pessoa; quando só a
  sentinela o tem, o cadastro passa.

**Provado** (`ResponsavelNoCadastroDoErpTest`, 6 casos, nos dois bancos, com o
controller despachado em processo): o avulso, o "sem cadastro", o id que não
existe, o número com letra, o inativo e o de outro telefone recusados com a frase
da regra e nada gravado; o dono do número, ativo, faz o cliente novo nascer
dependente dele; o dono oferecido é a pessoa, e não a sentinela; o número só da
sentinela não é repetido; e a página "Novo" recusa o avulso como responsável e
grava sem responsável o número só da sentinela. Cinco mutantes, todos vermelhos: o
modal sem a conferência (nos dois bancos), o dono com a sentinela, a página "Novo"
sem a conferência, a sentinela que não conta e o `(int)` sem conferir o número.

**O que fica:** na ALTERAÇÃO, o `responsavel_id` continua pulando a conferência do
telefone, como antes -- é o D417.

**A revisão (05/10/2026): o teto do inteiro tinha guarda e não tinha caso.** A
conferência aceita até dez dígitos, e só consulta o id que cabe na coluna
(`<= 2147483647`). Nenhum caso passava de oito dígitos: o mutante que tira só o
teto ficava verde. **Acrescentado** ao primeiro caso o id `9999999999`, recusado com
"Não há cliente com este número." e nada gravado. Sem o teto, o PostgreSQL recusa o
`find` (`out of range for type integer`, a coluna é `integer`) e o modal daria 500;
o MySQL só devolve nulo. O mutante fica vermelho no PostgreSQL, e verde no MySQL --
lá a guarda não muda a resposta.

### D235 — O `PUT` de produtos e o de pacotes chamam um método que o D202 tirou — CORRIGIDO em 27/09/2026, com a tela nova do catálogo

**Achado por leitura**, ao mapear a tela do catálogo: `Adm/ProdutosController:219`
e `Adm/PacotesController:169` chamam `$this->servico()->update(...)`, e
`ProdutoService`, `ServicoService` e `PacoteService` não têm mais `update()` -- o
conserto do D202 (commit 68692fd, 26/09/2026) trocou os três por
`atualizar(<entidade>, $dados)` e conferiu a fiação **dos controllers do ERP**, por
leitura. Os dois da API ficaram chamando o nome velho.

**O efeito:** `PUT /adm/api/produtos/:id` e `PUT /adm/api/pacotes/:id` terminam em
erro fatal (`Call to undefined method`) depois de passar pelas guardas e pela
validação. Nada grava: o erro vem antes.

**Por que ninguém viu:** nenhum teste chamava o caminho feliz dos dois. O
`ProdutoApiTest` confere a forma do JSON; o ensaio de recusas só pede o que é
recusado antes da chamada; e nenhuma tela usa esses `PUT` -- a do PDV só lista o
catálogo.

**A classe foi medida, e são estes dois:** cada chamada `servico()->X` dos
controllers de `/adm/api` foi conferida contra os métodos do serviço que a classe
declara, e as chamadas por `$this->service` e `sm->get(...)` também. Só estas duas
não resolvem. O `insert` que o commit c9715a5 renomeou em `UsuarioService` não tem
chamador velho.

##### O conserto, 27/09/2026

O `PUT` de produtos vai ao `atualizar()` da tela do tipo -- `ServicoService` para
o catálogo de serviços, `ProdutoService` para o de produtos --, porque os dois
gravam campos diferentes e calam o resto. O corpo passa antes por uma regra nova,
`ISConfiguracao\Regra\RegraDoCatalogo`, e **o que não cabe é 422 com o campo
nomeado, e não 200 sem gravar**: o campo do outro catálogo, a quantidade (que muda
pelo estoque, como a tela de editar produto avisa em vermelho), o que a API não
altera (área, etiqueta, o estado -- que tem rotas próprias --, o id, a unidade), e
os que a tela do ERP esconde do insumo. A regra também confere o que a primeira
versão aceitava: interruptor que não é verdadeiro ou falso, categoria fora da
lista do catálogo (e obrigatória, como no `NotEmpty` do ERP), períodos fora de 1 a
3, nome maior que a coluna ou com emoji (500 no MySQL). O `PUT` de pacotes vai ao
`atualizar()` do pacote, e a conferência dele foi apertada junto: o valor de quatro
casas que arredondava a zero, o nome em arranjo, a validade sem teto -- e a
validade ausente, que virava zero, fica como estava.

**Provado** com a primeira fatia da tela do catálogo: os casos 84 a 101 do ensaio de
recusas, nos dois bancos e em duas rodadas seguidas no MySQL, com o catálogo igual
antes e depois; 16 mutantes dos dois controllers pelo HTTP (o `PUT` pelo serviço de
um tipo só, a regra arrancada, o `update()` de volta, os limites do pacote), todos
mortos; e 33 da regra e do `ProdutoJson`, pela caracterização.

### D236 — A listagem de produtos e a de serviços do ERP aceitam a unidade da query — CORRIGIDO em 27/09/2026

`ProdutosController::indexAction` (`:51`) e `ServicosController::indexAction`
(`:49`), no ERP, leem `?unidade=` sem perguntar se quem pede é administrador. A
lista suspensa do filtro só oferece a própria unidade a quem não é --
`UnidadeRepository::popularCombobox($sessao)` --, então a intenção do sistema é
essa; mas lista suspensa não guarda nada, e o pedido com a unidade de outra
franquia devolve o catálogo dela: nome, preço de venda, estoque e quantidade
vendida.

**É o desenho do D139 (cupons) e do D142 (pacotes)**, que os consertaram com a
unidade da sessão para quem não é administrador. Os dois do catálogo ficaram de
fora -- e a API nova, que recorta pela sessão, não tem o defeito.

Foi daqui que partiu a auditoria do D237.

##### O conserto, e o portão que não via a guarda

As duas listagens usam a unidade da sessão para quem não é administrador, na
forma do D139 e do D142, e entraram no portão `conferir-recorte-por-unidade.php`.

**Ao falsificar a entrada nova, o portão ficou verde com a guarda arrancada.** O
`indexAction` também passa `$this->usuarioAdmin` à tela, e a marca achava a outra.
Medido em seguida, **seis entradas** tinham a marca mais de uma vez no método --
duas de antes desta data (a listagem dos cupons, D139, e a dos usuários, D148) e
duas de serviço (em `excluirParcelas` e `quitarSobTrava`, o `$unidadeId !== null`
sobrava com a comparação arrancada): **nenhuma das seis ficaria vermelha sem a
guarda**. O portão passou a comparar a marca sem espaços e a exigir que ela
apareça **uma vez** no código do método -- zero é "sem a guarda", mais de uma é
"marca ambígua", saída 2 --, e as seis marcas viraram a expressão da guarda,
ancorada à esquerda (`$request['unidade'] = $this->usuarioAdmin ? …`), para a
guarda invertida ou alargada também ficar vermelha (revisão adversarial).

**Provado por mutação:** as seis guardas arrancadas, e a das listagens invertida
e alargada -- oito mutantes, todos vermelhos no portão; e a checagem de
ambiguidade, antes de as marcas mudarem, acusou exatamente as seis.

**O que não foi feito:** a listagem da tela antiga continua sem teste de
comportamento -- a guarda mora no controller, e o portão é estrutural, como para o
D139 e o D142.

### D237 — Treze ações do ERP antigo aceitam um id ou uma unidade de outra franquia, sem conferir — PARCIAL em 01/10/2026: as do ERP e a irmã da API nova fechadas (os dois restos em 03/10/2026); as três rotas antigas do aplicativo esperam o dono — CORRIGIDO em 12/10/2026 — ANOTADO em 14/10/2026: a 189 (b)

**Achado por uma auditoria de código**, em 27/09/2026, que partiu do D236: uma
varredura por todas as leituras de `unidade` do pedido nos controllers do ERP
(fora de `/adm/api`, que recorta pela sessão), e depois por outras formas de a
unidade chegar -- o id de uma entidade no POST, o colaborador, o pagamento. Cada
achado aberto passou por **dois céticos**, que tinham de citar a guarda com
arquivo e linha para refutá-lo; **nenhum foi refutado** (31 agentes, nenhum
morto). **Não medido por HTTP**: medir é gravar, e a leitura do código, com o
caminho seguido até a consulta, é a evidência.

É a mesma classe do D132, D137, D139, D142, D148 e D202: a ação pega a entidade
pelo id que o pedido manda e não pergunta de que franquia ela é.

| ação | o que alcança em outra franquia | quem alcança |
|---|---|---|
| `VendasProdutosController::removerAction` (PDV antigo) | **apaga a linha de uma venda e TODOS os pagamentos dela** -- também de venda já concluída | "vendas nova", escrever |
| `VendasPagamentosController::removerAction` (PDV antigo) | **apaga um pagamento** e mexe no recebido e no troco; a conferência da unidade vem DEPOIS do `flush` e só troca a resposta | "vendas nova", escrever |
| `CuponsController::editarAction` | **regrava o cupom de outra franquia**: a guarda do D139 confere o id da rota, e o serviço grava o `id` do campo oculto -- a forma do D202, que não estava na tabela dele | cupons, escrever |
| `CuponsController::novoAction` | **cria cupom de desconto no PDV de outra franquia**: a `unidade` vem crua do POST | cupons, escrever |
| `NotificacoesController::salvarAction`, ao editar | **reescreve uma campanha** (texto, data, destinatários) de outra franquia, que sai em nome dela | notificações, escrever |
| `ClientesController::pacoteRemoverAction` | desativa o pacote pré-pago que outra franquia vendeu | cadastro de clientes, escrever |
| `AgendasController::agendarAction` | marca na agenda de um colaborador de outra franquia, e a resposta traz a grade do dia dela, com clientes | agenda, escrever |
| `AplicativoController::agendaDesmarcarAction` (`POST /api/agendas/cancelar`; a primeira versão desta tabela dizia `/desmarcar`) | **apaga agendamento de qualquer unidade**: o D91 exigiu sessão, e qualquer sessão do ERP serve, sem privilégio | qualquer sessão do ERP |
| `AplicativoController::agendaSalvarV2Action` e `V3` (o mesmo código por dentro) | marca horário com colaborador de qualquer unidade, em nome de qualquer cliente | qualquer sessão do ERP |
| `AgendasController::horariosDisponiveisAction` | a ocupação da agenda de colaborador de outra franquia (sem clientes) | agenda, escrever |
| `DevedoresController::selecionarDebitoAction` | o valor de um débito de qualquer unidade (sem cliente) -- o D136 pôs a ACL, e não o recorte | devedores, ler |
| `CuponsController::selecionarProdutosPorUnidadeAction` | o catálogo (id e nome) de outra unidade | cupons, escrever |
| `VipClassRelatorioController::indexAction` | títulos e capas dos cursos publicados para outra unidade | "VIP Class - Relatório", ler |

**28/09/2026: as três linhas dos cupons foram fechadas** na fatia da tela nova -- o
editar grava a campanha da rota (o D268), o cadastro usa a unidade postada só se
quem pede pode escolhê-la (o D265), e o catálogo por unidade só responde para as
dele --, com uma entrada cada no `conferir-recorte-por-unidade.php` e casos no
`ensaiar-cupons-adm-api.php`. **Ficam dez.**

**Quantas pessoas**, contadas na cópia (`privilegios_usuarios`, só não
administradores ativos, em unidades ativas): 112 com vendas, 62 com cupons e 62
com notificações, 112 com o cadastro de clientes, 123 com a agenda, 111 com
devedores, 105 com o relatório do VIP Class; e as rotas do aplicativo, **264** --
qualquer sessão.

*Correção de registro (04/10/2026, o D343):* os 105 do relatório do VIP Class contam
unidades de qualquer situação; em unidades ativas são 63
(`docs/f3-telas-restantes/tela-vipclass.md`).

**Os que ficaram fora, e por quê.** A auditoria olhou 26 ações. Onze são de rede
por desenho -- conteúdo da franqueadora (assistentes, mensagens, VIP Class admin),
os relatórios de franquias, as etiquetas, os convênios, os sócios das unidades
e duas leituras do aplicativo --, e duas estavam fechadas (`Atendimentos::gerar` e `Evasao::pdf` já
recortam). Uma ressalva latente: se "Configurações mensagens" for dada a quem não
é administrador (hoje, a ninguém), ela verá os avisos da rede inteira, e editar e
desativar acham a mensagem pelo id sem recorte.

**A ordem, quando for consertar:** as quatro primeiras mexem em dinheiro ou em
desconto de outra franquia, e a do aplicativo apaga agendamento. O conserto é o
de sempre -- a entidade do pedido conferida contra `$this->unidadeId` antes de
gravar, e a unidade da sessão no lugar da do POST --, com um caso que prove a
recusa e entrada no portão `conferir-recorte-por-unidade.php`.

#### 01/10/2026: as cinco do dinheiro e do cadastro fechadas

O item e o pagamento do PDV antigo (`VendasProdutosController` e
`VendasPagamentosController::removerAction`) passaram a conferir a venda contra a unidade da sessão
**antes** de o serviço gravar -- a conferência do pagamento vinha depois do `flush`, com o pagamento
de outra franquia já apagado, e só trocava a resposta. Nos dois o controller ganhou um `find()` antes
do serviço: o serviço do item já usava `find()` (e o `null` dele virava erro fatal logo abaixo), e o
do pagamento continua com `getReference()`, que agora só roda sobre um id que existe. A regra é a do
próprio PDV (`adicionarAction`, `validarVenda`, `comandaAction`): só a unidade, também para o
administrador, porque a venda é da unidade de quem a abre. No item, a tabela da resposta passou a vir
da venda do item já conferida, e não do `venda_id` cru do POST: `selecionarProdutosVenda` não filtra
unidade, e o item daqui com o `venda_id` de outra franquia devolvia os itens da venda dela (a
verificação da primeira rodada achou; a tela manda os dois iguais, e nada muda para quem a usa). O
débito (`DevedoresController::selecionarDebitoAction`) é lido pela unidade da venda, como a listagem,
a quitação (D137) e a API nova -- e o administrador passa, como na quitação; na cópia, 9.098 débitos,
zero com a coluna `unidade` divergindo da venda. O pacote (`ClientesController::pacoteRemoverAction`)
precisa ser da unidade da sessão, como a lista ao lado e a API nova (D281). Só esses dois trocaram
`getReference()` por `find()`: o id que não existe dava 500. No pagamento, no débito e no pacote, a
recusa é a resposta que a ação já tinha; no item, "Erro ao remover item." é frase nova. A edição de
campanha (`NotificacoesController::salvarAction`) já tinha sido fechada em 29/09/2026, no D297.
**Provado:** `D237DinheiroTest`, 16 casos em processo nos dois bancos; as quatro guardas arrancadas
deixam vermelhos exatamente os 6 casos de outra franquia, o controller do pagamento de antes responde
"Erro ao remover pagamento." com o pagamento já apagado, e a tabela pelo `venda_id` cru deixa vermelho
o caso dele. Dez entradas no `conferir-recorte-por-unidade.php`, cada uma falsificada -- entre elas,
além do ajudante `conferirAudiencia`, a CHAMADA dele no `salvarAction`. **Ficou:** dentro da mesma
unidade, o PDV antigo ainda tira item e pagamento de venda concluída (o **D313**, como o D163 fez só
na API nova); o pacote antigo, sem a guarda do gerente e por GET (o **D314**); e
`VendasProdutosController::adicionarAction` grava o cliente na venda do `venda_id` antes de conferir a
unidade -- hoje inalcançável, com zero vendas sem cliente entre 2.269.254 na cópia.

#### 01/10/2026: a agenda, a VIP Class e a irmã da API nova fechadas; as três rotas antigas do aplicativo viraram pergunta

`Agendas::agendar` e `horariosDisponiveis` passam a conferir o COLABORADOR do pedido pela regra do
D150 -- da unidade da sessão, ou quem tem "Agendas unidades" --, num ajudante
`colaboradorDaMinhaUnidade`, com a mesma recusa para o id que não existe (que derrubava as duas com
500). A leitura achou duas irmãs que a auditoria não listou: `selecionarHoriarios` e `remarcarSalvar`
conferiam o agendamento (D150) e deixavam cru o colaborador de DESTINO -- a remarcação levava o
agendamento da minha franquia para a agenda de um colaborador de outra; fechadas com a mesma guarda.
A verificação achou a terceira: `fecharValidar` e `fechar` mandavam o colaborador do POST cru para
`AgendaService::fechar`, que gravava o bloqueio com a minha unidade e o colaborador de outra franquia
-- sem bloquear um único horário, visível na tela de bloqueios dela com o texto escrito aqui, e sem
que o gerente de lá conseguisse removê-lo; com o id inexistente, 500. Ali a regra é a da unidade da
SESSÃO, sem a exceção de "Agendas unidades", porque o fechamento é sempre dela. E o id que segue
adiante passou a ser o conferido, no fechar e no marcar: a guarda convertia "90001000.5" em 90001000 e
quem vinha depois usava o texto -- no marcar, a conferência de ocupação não achava o horário e o
marcava duas vezes; no PostgreSQL, 500. **A irmã da API nova**, `POST /adm/api/agendas/fechamentos`,
passava o `colaborador` do corpo cru ao mesmo serviço (201 com o bloqueio na outra franquia); passou a
responder o 404 de `horarios-livres` ao colaborador de fora, ao inexistente e ao id quebrado, e a
gravar o colaborador conferido. No relatório da VIP Class só o administrador escolhe a unidade (a
forma do D148; o `?unidade=` vazio tirava o filtro), e a irmã `relatorio` responde 404 ao curso
publicado só para outra unidade. **Medido na cópia:** 122 pessoas não administradoras ativas
gerenciam a agenda (124 linhas de privilégio: 2 pessoas têm a linha em dobro), e nenhuma tem "Agendas
unidades"; 3 dos 132.296 fechamentos com colaborador apontam para colaborador de outra unidade, os
três sem um horário bloqueado; os 22 cursos da VIP Class não têm unidade ligada, então o vazamento do
relatório era latente. **Provado:** `D237AgendaRecortePorUnidadeTest`, 21 casos nos dois bancos; cada
guarda arrancada deixa o seu caso vermelho e o portão acusa o método; quinze entradas no
`conferir-recorte-por-unidade.php`. E fica, fora do recorte, o serviço: o `agendar` antigo aceita um
serviço de outra unidade com o colaborador da minha (a API nova recusa desde o D224) -- grava na
minha agenda e não alcança a outra franquia.

**A pergunta ao dono -- as três rotas antigas do aplicativo** (`POST /api/agendas/cancelar`, que
apaga o bloco inteiro do atendimento, `/api/v2/agendas/agendar` e `/api/v3/agendas/agendar`). Hoje só
uma sessão do ERP as alcança -- as 264 pessoas, sem privilégio, em qualquer unidade --; o aplicativo
instalado recebe 401 desde o D91 (o sucessor é o `/api/v4`), e nenhuma tela do ERP as chama. O
aplicativo do cliente marca em qualquer unidade por desenho: 16.887 dos 71.404 atendimentos marcados
pelo app em 2026 na cópia são em unidade que não é a do cadastro do cliente. As opções: **(A)**
recortar pela unidade da sessão, com a exceção de "Agendas unidades" e do administrador, como a agenda
do ERP já faz -- não muda nada para o aplicativo, que já toma 401, e o ensaio das escritas do
aplicativo continua passando; o risco é que, se o D91 for revertido no corte para reabrir as rotas ao
aplicativo antigo, a guarda tem de sair junto; **(B)** fechá-las também para a sessão do ERP até
aposentá-las; **(C)** deixar como estão até aposentar, com o risco registrado. A recomendação é (A).

**08/10/2026, na revisão do ERP-BARREIRA-NA-ENTRADA (o fim do D315):** o DISPARO dessas três rotas -- e do
`/api/cliente/indicacao`, que autoriza do mesmo jeito -- por uma página de outro subdomínio aberta com a sessão
viva é 403 desde a barreira da entrada: o pedido de outra origem que traz o cookie da sessão do ERP não chega
como o aplicativo, e fica sob ela (casos 20 do `ensaiar-sessao-do-erp.php` e do `BarreiraDeOrigemNaEntradaTest`).
O aplicativo, sem o cookie, segue com o 401. A pergunta acima, do recorte por unidade, não muda.

**01/10/2026 -- dois restos, achados pelo mapa do D316 (lidos, não medidos):** no acrescentar item do
PDV antigo, o `atualizarCliente` grava o cliente na venda do `venda_id` antes da conferência da
unidade -- inalcançável hoje (nenhuma venda sem cliente na cópia); e a venda de outra franquia recebe
uma recusa muda (`mensagem: null`).

#### 03/10/2026: os dois restos fechados -- o cliente na venda de fora, e o serviço do marcar

**O acrescentar item do PDV antigo** (`VendasProdutosController::adicionarAction`) passou a conferir a
unidade da venda do `venda_id` ANTES de qualquer escrita, com a frase das irmãs do PDV (*"Não foi
possível localizar a venda."*): a venda de outra franquia sem cliente ganhava o cliente do pedido no
`atualizarCliente`, e a recusa vinha muda. O caso de outra franquia do `D316EscritasEmVendaFechadaTest`
mudou junto -- dizia `mensagem: null`, e agora diz a frase.

**O marcar da agenda do ERP** (`AgendasController::agendarAction`) passou pela regra de serviço da
API nova (`RegraDaMarcacao::conferirServico`, separada do `conferir` para isso, com a mesma saída
para a API): o serviço tem de existir, ser da unidade DO COLABORADOR, ser do tipo serviço e estar
ativo. A unidade é a do colaborador, já conferido pela guarda do D237: quem tem "Agendas unidades"
marca no colaborador de outra unidade com os serviços dela -- que é a lista que a tela mostra
(`popularComboboxServicos` da unidade dele). **Não é regra nova para quem usa:** a lista da tela é
mais estreita que ela (só serviços ativos da unidade, com área e os do colaborador), então nada que a
tela oferece é recusado; na cópia, **0** das **194.968** linhas de agenda de 2026 com serviço têm
serviço de outra unidade ou item que não é serviço. (O total de linhas criadas em 2026 é 326.415, mas
131.447 delas são horário fechado -- com o fechamento, sem serviço nem cliente --, e não marcação: a
primeira versão deste parágrafo as contava no denominador. À parte, 75 das 194.968 apontam para
serviço hoje inativo, o que não muda nada: a regra vale para marcação nova, e remarcar usa o serviço
do próprio agendamento.) O serviço vazio continua com a frase de sempre, e o id que não existe
deixou de derrubar a ação (o proxy de `RegraDeAgendamento` estourava).

**A remarcação do ERP, a outra porta do mesmo `agendar`** (achada pela verificação do conserto):
`remarcarSalvarAction` monta o pedido com o serviço DO AGENDAMENTO e o colaborador do POST, e não
passava pela regra. Quem tem "Agendas unidades" (o administrador também, pelo `Acl`) mandava, num POST
escrito à mão, um colaborador de outra unidade -- o modal só lista os da unidade do agendamento --, e
o serviço daqui nascia na agenda de lá: o par que o marcar passou a recusar. Agora a mesma frase
(*"Serviço não encontrado nesta unidade."*, `RegraDaMarcacao::SERVICO_FORA_DA_UNIDADE`). **Só a metade
da unidade, e só quando o colaborador muda:** no mesmo colaborador o par é o do próprio agendamento, e
remarcar não cria estado novo -- o serviço que ficou inativo depois de marcado continua se remarcando,
e o par antigo, de antes da regra, também (na cópia, 514 agendamentos com serviço de outra unidade que
não a do colaborador, de 2016 a 2025, nenhum futuro). Para quem usa pela tela, nada muda. Provado com
3 casos no `D237RestosDoPdvEDaAgendaTest`, nos dois bancos: o POST com o colaborador da outra unidade
é recusado e o original fica (vermelho antes do conserto: remarcava), e os dois controles -- para outro
colaborador da unidade do serviço remarca, e no mesmo colaborador o par antigo remarca. Quatro
mutantes, cada um vermelho no caso que lhe toca, nos dois bancos: a guarda arrancada, a guarda sem o "só quando o
colaborador muda", a unidade do colaborador de antes no lugar da do destino, e a recusa de toda troca
de colaborador. Uma entrada nova no `conferir-recorte-por-unidade.php`, falsificada arrancando o
código (saída 1).

**Provado:** `D237RestosDoPdvEDaAgendaTest`, 8 casos (11 com os 3 da remarcação, acima) verdes nos
dois bancos -- a venda de fora sem
cliente intacta, com a frase; o serviço de outra unidade, o item do bar, o serviço inativo e o id que
não existe recusados sem marcar; com "Agendas unidades", o serviço da minha unidade recusado no
colaborador da outra; e os controles (o serviço da unidade do colaborador marca nas duas situações; o
vazio diz a frase de antes). Antes do conserto, 6 vermelhos (5 falhas e o erro do proxy). Três
mutantes, cada um vermelho: a conferência da unidade no PDV arrancada (2 casos, este e o do D316), a
regra do serviço arrancada (5) e a unidade da sessão no lugar da do colaborador (3, um deles do
`D237AgendaRecortePorUnidadeTest`). Sete entradas novas no `conferir-recorte-por-unidade.php` (estas,
a regra e as três do D275), cada uma falsificada arrancando o código.

**O que fica neste débito:** as três rotas antigas do aplicativo, com o dono.

#### 12/10/2026 — AS TRÊS ROTAS ANTIGAS DO APLICATIVO RECORTAM PELA UNIDADE DA SESSÃO — pela resposta 106 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 106** (a opção "a", a recomendada): *"Recortar as três rotas antigas do aplicativo (o desmarcar antigo e
os dois marcar antigos) pela unidade da sessão, como a agenda do ERP, com a exceção de 'Agendas unidades' e do
administrador; o aplicativo não muda, e o ensaio das escritas do aplicativo continua passando."*

##### O conserto

`AplicativoController::colaboradorDaUnidadeDaSessao`: a mesma expressão da agenda do ERP (`unidadePermitida`) -- a
unidade do colaborador é a da sessão, ou a sessão tem "Agendas unidades" (`agendas/geral`), que o administrador recebe
pelo `Acl`. A sessão é lida da `SessaoAcl` ali mesmo: o controller nasce com `parent::__construct(true)`, sem ACL. Nas
três rotas:

- `POST /api/agendas/cancelar` (`agendaDesmarcarAction`): o horário de outra franquia é "ID inválido.", a recusa do id
  que não existe, e fica;
- `/api/v2/agendas/agendar` e `/api/v3/agendas/agendar`: o colaborador de outra franquia é o que não existe --
  "Informe o colaborador.", a frase de sempre --, e nada se marca. O cliente continua de qualquer unidade: o aplicativo
  do cliente marca em qualquer uma de propósito.

**O aplicativo não muda** -- ele toma 401 nessas três desde o D91. **Se o corte reabrir as rotas ao aplicativo antigo**
(revertendo o D91), a guarda sai junto: está escrito no docblock do ajudante.

##### Provado

- `RotasAntigasDoAplicativoTest` (novo, 12 execuções, nos dois bancos), em processo, com a sessão do processo: o
  desmarcar apaga o da minha unidade e recusa o de outra, que fica; com "Agendas unidades", e com um administrador de
  verdade (grupo com `administrador` e o `setAcl`), alcança o da outra; o v2 e o v3 marcam com o colaborador da minha
  unidade, recusam o de outra sem marcar, e com "Agendas unidades" marcam lá.
- Por HTTP, nos dois bancos de contrato: o `ensaiar-escritas-do-aplicativo.php` continua passando (os cinco pedidos do
  desmarcar, do v2 e do v3 com a sessão do gerente na unidade dele), e o caso 20 do `ensaiar-sessao-do-erp.php` (o id
  0, "ID inválido.") também.
- Mutantes, no MySQL, cada um vermelho: o desmarcar sem o recorte, o v2 sem o recorte, o v3 sem o recorte e o ajudante
  sem a exceção da rede (os quatro casos de "Agendas unidades" e do administrador). Quatro entradas novas no
  `conferir-recorte-por-unidade.php` (o ajudante e as três rotas), cada uma falsificada arrancando o código.

##### A verificação do mesmo dia: o serviço do marcar

**O par misto passava.** O recorte conferia o colaborador, e o serviço ia cru: com o colaborador da minha unidade e o
serviço de outra franquia, o v2 e o v3 gravavam, na agenda da minha unidade, um serviço dela -- o par que a agenda do
ERP recusa desde 03/10/2026 (`RegraDaMarcacao::conferirServico`, acima). O parágrafo de 01/10 o tinha posto "fora do
recorte" para o `agendar` do ERP, e o de 03/10 o fechou ali; a resposta 106 pede o recorte "como a agenda do ERP", então
ele vale aqui também. Agora `AplicativoController::servicoDaUnidadeDoColaborador` só devolve o serviço da unidade do
COLABORADOR (já conferido), e não a da sessão -- quem tem "Agendas unidades" marca no colaborador de outra unidade com os
serviços dela, e não com os daqui. O de outra unidade é o que não existe: "- Informe o serviço.", a frase do serviço que
falta. E o id que não existe deixou de derrubar a rota (era um `getReference`, que estourava no `getPeriodos`). **Só a
unidade:** o tipo e o estado do serviço, que a regra da agenda do ERP também confere, não são recorte, e ficam como
estavam nessas duas rotas.

**Provado:** `RotasAntigasDoAplicativoTest::testOMarcarRecusaOServicoForaDaUnidadeDoColaborador`, seis execuções nos
dois bancos (no v2 e no v3: o serviço da outra franquia no colaborador da minha; com "Agendas unidades", o da minha no
colaborador da outra; e o id que não existe), vermelhas antes do conserto, no MySQL (quatro marcavam, e as duas do id que
não existe davam erro). Quatro mutantes, cada um vermelho, nos dois bancos: o ajudante sem a comparação da unidade (os quatro pares
mistos), o ajudante com a unidade da sessão no lugar da do colaborador (os dois de "Agendas unidades" e os dois controles
que marcam lá), o v2 e o v3 com o serviço de antes (os três de cada versão). Três entradas novas no
`conferir-recorte-por-unidade.php` (o ajudante e as duas chamadas), cada uma falsificada arrancando o código. O
`ensaiar-escritas-do-aplicativo.php` continua passando nos dois bancos de contrato (o serviço do cenário é da unidade do
colaborador).

#### ANOTADO em 14/10/2026 — o recorte das três rotas antigas do aplicativo desligado com as dez abertas, pela resposta 189 (b) do dono

**A resposta 189 (b) reabriu as dez rotas do aplicativo instalado** — sem sessão, como a produção de hoje (o **D91**,
"ANOTADO em 14/10/2026") —, e **o recorte da 106 saiu junto**, como o docblock dele previa desde 12/10: ele lê a unidade
**da sessão**, e sem sessão recusaria toda marcação ("Informe o colaborador.") e todo desmarcar ("ID inválido."). Com as
dez abertas, o desmarcar antigo apaga o horário de qualquer unidade, e o v2 e o v3 marcam com o colaborador de qualquer
uma — para quem souber os ids, sem credencial, como hoje na produção. O resto deste débito (as ações do ERP, a irmã da API
nova, os dois restos de 03/10) não muda.

**O que fica, nos dois estados:** as duas guardas que não leem a sessão — o serviço da unidade do colaborador
(`servicoDaUnidadeDoColaborador`) e a linha do bloqueio que o desmarcar recusa (a resposta 158, o fim do D325). Ficar ou
sair junto era a **pergunta 208** — **decidida em 15/10/2026: as duas ficam** (a 208 (a)), e, com a conta abaixo em
zero, nada volta ao dono. **Medido para ela, na cópia** (`franquiabv_dev`, só contagens; o atendimento é
a primeira linha do bloco, `id_pai` nulo, `origem = 'app'`): **0 dos 71.402 atendimentos de 2026 pelo aplicativo têm o
serviço de outra unidade que não a do colaborador** (16.887 deles, fora da unidade do cadastro do cliente, como em 12/10;
o comentário de então dizia 71.404, numa conta que não ficou escrita). Em todos os anos, 341 de 969.426: 192 em 2018, 113
em 2019, 14 em 2020, 2 em 2021 e 20 em 2025 (pela data do horário). Nenhum dos atendimentos de 2026 que o aplicativo
gravou — a cópia vai até 27/09/2026 — teria sido recusado pela guarda; o pedido que a produção de hoje não grava (o
serviço que não existe dá 500 lá, o `getReference`) não aparece em contagem nenhuma. Recontado na verificação de
15/10/2026, com a mesma conta: os mesmos 0 de 71.402, e os mesmos 16.887.

**O que fecha de novo, e quando:** quando o aplicativo novo estiver em campo, a constante
`AplicativoController::ROTAS_DO_APLICATIVO_INSTALADO` vai para `fechadas`, e o recorte volta com a sessão, nas três. Ele
está escrito e vigiado: as entradas do `conferir-recorte-por-unidade.php` (o desmarcar com o interruptor na condição) e os
18 casos do `RotasAntigasDoAplicativoTest`, que rodam com as dez fechadas postas na instância. **Provado:** os 10 casos
novos do mesmo arquivo, com as dez abertas e sem sessão (o desmarcar e o marcar na outra franquia passam; a linha do
bloqueio e o serviço de fora continuam recusados), nos dois bancos; e os mutantes do roadmap de 14/10/2026 (o recorte
fora do interruptor no desmarcar, no v2 e no v3, cada um vermelho no caso aberto; arrancado com as dez fechadas,
vermelho no caso fechado; o portão, saída 1).


### D238 — O catálogo dizia o estoque de serviço, e o PDV o mostrava — CORRIGIDO em 27/09/2026

`ProdutoJson` prometia, num comentário, quantidade nula para serviço ("serviço não
tem estoque, e zero ali diria 'acabou'"), e só a devolvia nula quando a **coluna**
era nula. Na cópia, **3.292 serviços** têm a quantidade gravada -- quase todos
zero; **20 serviços e 4 Lava VIP acima de zero**, como o 3333 do D171, retipado
depois de ter tido estoque. E o PDV (`LancarItem.tsx`) mostra "N em estoque" ao
lado de todo item com saldo acima de zero -- de um corte, inclusive. Os pacotes
também saíam com a quantidade da linha (133 acima de zero), que não é estoque:
as sessões do pacote são os itens dele.

**O conserto:** o recurso pergunta ao tipo, pela regra da entidade
(`Produto::tipoMovimentaEstoque()`, a mesma da baixa da venda), nas duas formas --
a da listagem e a completa. O produto que ninguém contou continua nulo, e não
zero.

**Provado:** um caso na caracterização (`ProdutoApiTest`, os três tipos sem
estoque com a coluna gravada, os três com, e o produto nunca contado), e um no
ensaio de recusas por HTTP (o serviço com a coluna em 5 devolve `null`).

### D239 — Cadastrar produto, serviço ou pacote pela API sempre terminou em erro 500 — CORRIGIDO em 27/09/2026

**Achado pela revisão adversarial** da tela do catálogo, confirmado por leitura e
depois medido por HTTP.

`produtos.status` é NOT NULL sem padrão nos dois bancos, e ninguém o preenchia no
cadastro pela API: o arranjo do `POST /adm/api/produtos` não tinha a chave, o
`ProdutoService::insert()` não a dá, o construtor da entidade não a inicializa, e
o Doctrine põe todo campo no INSERT, os nulos também. O `POST /adm/api/pacotes`
tem a mesma causa (`PacoteService::insert()`). **Todo cadastro terminava em 500**
-- desde que as duas rotas existem. O ERP só funciona porque o formulário manda
`status`.

**Por que ninguém viu:** nenhum teste nem ensaio chamava o caminho feliz dos dois
`POST` -- a mesma família do D235, um caminho que nunca rodou.

**O conserto:** o item nasce ativo, como no formulário do ERP --
`RegraDoCatalogo::paraOCadastro()` dá o `status` e a unidade da sessão (e o valor
de compra zero, quando não vem, como a tela antiga faz com o campo sem asterisco);
o pacote ganha o `status` no arranjo do controller.

**Provado:** um caso na caracterização grava serviço e produto pelo serviço de
verdade, nos dois bancos, e lê de volta; e dois controles de 201 no ensaio de
recusas, com o banco dizendo que a linha nasceu ativa e na unidade da sessão.

### D240 — O link de paginação das listagens do ERP repete os filtros da query sem escape — CORRIGIDO em 28/09/2026

**Achado pela revisão adversarial** da tela do catálogo, fora dela.

`ISBase/view/partials/paginacao.phtml` imprime `$this->filters` cru dentro do
`href` dos links de página, e os controllers montam esse texto concatenando os
valores da query (`'&quantidade=' . $request['quantidade']`, e outros) -- **38
telas** usam o parcial. Um valor com aspas fecha o atributo: é injeção de
script refletida, que exige a vítima abrir um endereço preparado e a listagem ter
mais de uma página. O cookie de sessão ainda não é `HttpOnly` (D198).

É da família do D203 (texto impresso sem escape), com outra origem: a query, e não
o banco. O conserto é num lugar só -- escapar o `href` no parcial --, e fica para um
passo próprio, com um caso que renderize o parcial.

**O conserto (28/09/2026):** o `href` dos quatro links do parcial passa por
`escapeHtml`. Nenhum dos 31 arquivos que montam `filters` o escapava antes -- não
há escape em dobro. **Uma tela repetia a busca num link seu**, fora do parcial: o
"Exportar" (escondido) da lista de pacotes, `url(...) . "?" . $filtros` cru no
`href` -- escapado também. Procurado nas 37 telas que imprimem `$filtros` e em todo
`href` com `?` seguido de uma variável: era o único. O navegador decodifica o `&amp;` de volta a `&`: o endereço que
ele segue é o de antes.

**Provado:** `PaginacaoDoErpTest` renderiza o parcial de verdade, pelo `PhpRenderer`
do Laminas, e confere o HTML: a busca com aspas não fecha o atributo nem vira
elemento, nos quatro links; a busca comum dá, depois de decodificada pelo
`DOMDocument`, os mesmos endereços de antes; e a lista de uma página só não desenha
nada. Com o parcial antigo, os dois primeiros falham. **O que não foi feito:** abrir
uma listagem paginada no navegador -- no banco de contrato nenhuma passa de uma
página; o caso lê o mesmo HTML que o navegador receberia.

### D241 — O serviço cadastrado pela API nasce sem área e sem etiqueta, e some da agenda e do aplicativo — CORRIGIDO em 27/09/2026, na segunda fatia da tela do catálogo

**Achado ao desenhar o formulário da tela nova do catálogo** (a segunda fatia),
lendo o formulário de serviço do ERP campo a campo.

No ERP, **área de atuação e etiqueta são obrigatórias** para serviço
(`ServicoFilter`, `NotEmpty` nas duas, na tela de cadastrar e na de alterar). A
API não as aceita: `RegraDoCatalogo` recusa `area` e `tag` com "Não se altera por
aqui", e o `POST` -- que só passou a gravar com o D239, no mesmo dia -- cadastra o
serviço sem nenhuma das duas. E ainda pelo serviço errado: `ProdutoService::insert`,
que não traduz a área em referência nem grava os períodos como o de serviço.

**A área não é enfeite, e medir disse onde ela pesa:**

- a lista de serviços da agenda (`ProdutoRepository::popularComboboxServicos`)
  faz `JOIN pro.area` -- **serviço sem área não aparece para agendar**;
- o catálogo do aplicativo (`selecionarServicosApp`) monta as categorias pela
  área -- **serviço sem área não aparece no aplicativo**.

Na cópia de desenvolvimento, de 1.587 serviços ativos, **1** está sem área (a tela
antiga a exige) e **516** sem etiqueta (de antes de ela ser exigida). Há 7 áreas e
8 etiquetas ativas, da rede inteira.

**Por que não fez estrago ainda:** o `POST` do catálogo não tem consumidor -- a tela
nova só lê, e o ERP grava pela tela antiga. O conserto é o primeiro passo da
segunda fatia: `area` e `tag` no corpo, obrigatórias no cadastro de serviço como no
ERP, conferidas contra as ativas; o cadastro de serviço pelo `ServicoService`; e
`GET /adm/api/produtos/catalogos` com as duas listas para o formulário.

**O conserto, como desenhado:** `area` e `tag` entram no corpo do `POST` e do
`PUT`. No cadastro de serviço as duas são obrigatórias ("Obrigatória: o id de uma
área de atuação ativa."). Na alteração, `null` é recusado ("Não se apaga") e a
chave ausente deixa como está -- os 516 serviços antigos sem etiqueta continuam
salváveis sem que ninguém escolha uma. O id é conferido contra as **ativas**,
mais a que o item já tem: a área desativada depois não obriga o operador a
trocá-la para corrigir o nome. O serviço é cadastrado pelo `ServicoService`. E
`GET /adm/api/produtos/catalogos` devolve as duas listas ativas, só para quem
escreve no catálogo.

**Provado:** na caracterização, o cadastro sem área, sem etiqueta e com o id de
outra, a alteração com `null` e com a desativada que o item já tinha -- e o serviço
cadastrado pela regra, lido de volta **na lista da agenda**
(`popularComboboxServicos`), nos dois bancos. No ensaio de recusas por HTTP, os
casos 90, 96 e 102 a 109. E sete mutantes do controller, cada um morto pelo seu caso
do ensaio: o serviço de volta ao `ProdutoService` (o caso 90 recebe 500), a regra sem
as aceitas, a alteração sem a que o item já tem, o catálogo sem guarda, sem áreas e
sem etiquetas, e a área de agora recusada; mais sete na regra, pela caracterização
(entre eles o `in_array` frouxo, que aceitaria o id em texto).

### D242 — O `--tinta-3` fica abaixo de 4,5:1 nos dois temas, e é o texto pequeno de 51 regras — CORRIGIDO em 28/09/2026

**Achado no navegador**, ao conferir a tela do catálogo no tema escuro: o
cabeçalho das tabelas (`thead th`, 11,5 px, em caixa alta) é `--tinta-3`, e mede
**3,62:1** sobre o cartão escuro. Não é do catálogo: é o token, e ele é a cor de
51 regras do `App.css` -- cabeçalhos de tabela, legendas, rótulos pequenos.

Medido pela fórmula do WCAG, contra as três superfícies de cada tema:

| tema | `--tinta-3` | sobre `--superficie` | sobre `--fundo` | sobre `--superficie-2` |
|---|---|---:|---:|---:|
| claro | `#7D7564` | 4,56 | **4,30** | **3,94** |
| escuro | `#79715E` | **3,62** | **3,92** | **3,38** |

O CM-1 já tinha achado a mesma coisa nos rótulos do lançamento de itens, e
consertou na regra (passou-os a `--tinta-2`). O conserto que fecha a classe é no
token: `#70695A` no claro e `#918871` no escuro medem de 4,65:1 a 5,44:1 nas três
superfícies, e continuam distintos do `--tinta-2` -- a hierarquia de três tintas
fica.

**O conserto (28/09/2026):** no token, nas três definições do `index.css` -- o
claro, o escuro pelo sistema e o escuro escolhido no botão. Medido de novo pela
fórmula antes de trocar: **claro `#70695A`** 5,44 / 5,13 / 4,70 e **escuro
`#918871`** 4,98 / 5,40 / 4,65 (sobre `--superficie`, `--fundo`, `--superficie-2`);
o `--tinta-2` segue em 8,24 e 7,60 sobre o cartão, e a hierarquia fica. No
navegador, o valor calculado do token é o novo nos três caminhos: `data-tema`
claro, `data-tema` escuro, e sem `data-tema` com o sistema escuro. O vitest não
mede CSS (o `App.css` chega vazio): a prova é essa leitura, e a conta.

### D243 — A movimentação de estoque saía da API sem data, em toda linha — CORRIGIDO em 27/09/2026

**Achado no navegador**, ao conferir a tela nova do estoque (a terceira fatia do
catálogo): a coluna "Data" dizia "—" em todas as movimentações.

`ProdutoEstoque::getDataCriacao()` e `getDataAlteracao()` devolvem o **texto** que
a tela antiga imprime (`DataHora::dateTimeToString`), e não o `DateTime`.
`ProdutoJson::movimentacao()` passava esse texto a `instante()`, que só converte
`DateTimeInterface` -- e devolvia `null` para o resto. **Desde a sétima fatia
(458b6c6), `criadoEm` e `alteradoEm` saíam nulos em toda movimentação**, e a
convenção da API (ISO 8601) nunca foi cumprida ali. Ninguém viu porque nenhum caso
conferia o valor: o contrato da resposta aceita `null` nos dois.

**O conserto:** os dois getters ganham `$formatar = true`, como os de dinheiro da
casa -- a tela antiga continua recebendo o texto, sem mudar uma linha -- e o
`ProdutoJson` pede o instante com `false`.

**Provado:** um caso na caracterização (`ProdutoApiTest`) lança uma movimentação
com data conhecida e lê `criadoEm` em ISO 8601, com o fuso, e `alteradoEm` nulo; o
texto antigo, `10/03/2026 12:00:00`, está no caso como o que **não** pode voltar. E
o navegador mostra a data em cada linha.

### D244 — O estoque de um produto abria para quem só lê o catálogo, e a tela do ERP exige escrever — CORRIGIDO em 27/09/2026

**Achado pela revisão adversarial** da terceira fatia do catálogo. A tela antiga
`produtos/estoque` pede `getAcesso()` -- escrever --, e o `GET
/adm/api/produtos/:id/estoque` da sétima fatia (458b6c6) pedia só ler: quem não
movimenta via as movimentações, quem as lançou e quem as pediu. A API não pode ser
mais frouxa que a tela que ela substitui.

**O conserto:** a guarda do `GET` passa a escrever, a mesma do lançamento e do
cancelamento.

**Provado:** o caso 111 do ensaio de recusas dá ao colaborador o privilégio de ler o
catálogo e pede o estoque -- 403. Com a guarda de volta a ler, o mesmo caso recebe
**200** (medido pelo mutante).

### D245 — O lançamento de estoque podia devolver outra movimentação como a criada — CORRIGIDO em 27/09/2026

**Achado pela revisão adversarial**, confirmado por leitura. O `POST
/adm/api/produtos/:id/estoque` gravava pelo serviço do ERP, que não devolve a
movimentação, e respondia com a **primeira da listagem** -- ordenada pela data, ao
segundo. Duas no mesmo segundo empatavam, e a resposta podia trazer a outra.

**O conserto:** a criada é a de maior id do produto, lida logo depois da gravação.
Não há caso que force o empate no segundo; a leitura é a prova, e o comentário no
controller diz por quê.

### D246 — O valor do pacote não tinha teto: um número de 310 dígitos era aceito — CORRIGIDO em 27/09/2026

**Achado pela revisão adversarial** da segunda fatia do catálogo, no irmão: o
`PUT` de pacotes (vivo desde o D235) conferia o valor com `deDecimal()` e "maior que
zero", e mais nada. "1" seguido de trezentos e nove zeros é um decimal válido para
o texto, e INF para o `(float)`.

**O conserto:** o teto do catálogo, 999.999,99 -- o maior que cabe no campo do ERP --,
no valor do pacote e no valor da sessão do item.

**Provado:** o caso 114 do ensaio de recusas (422 no `valor`). Com o teto arrancado,
o mesmo `PUT` respondia **200** -- aceitava (medido pelo mutante, no MySQL). **O teto
do valor da sessão do item ainda não tem caso:** o caminho de compor o pacote nunca
rodou por HTTP, e ganha os seus casos na fatia dos pacotes, que é a seguinte.

**Na fatia dos pacotes (28/09/2026)** o teto do valor da sessão ganhou os casos: a
caracterização (`RegraDoPacoteTest`) mede o teto e as duas casas, e o caso 120 do
ensaio manda `0.004` por HTTP -- 422.


### D247 — O item do pacote aceitava qualquer produto, de qualquer franquia, e uma quantidade qualquer — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase** dos pacotes, dos convênios e dos cupons (a leitura, com
um cético por achado), antes da tela nova dos pacotes; **completado pela revisão
adversarial** da tela, que achou o valor e o "00" ainda de fora.

Os dois caminhos que compõem um pacote -- o `adicionarServico` da tela antiga e o
`POST /adm/api/pacotes/:id/itens` -- conferiam o **pacote** (da unidade da sessão,
tipo `pac`) e deixavam o **item** passar:

- **No ERP**, o id do `POST` ia direto ao `getReference()` do produto: qualquer
  produto, de qualquer franquia -- um serviço de outra unidade, um produto de
  prateleira, outro pacote, o próprio pacote -- virava item. A quantidade e o valor
  passavam por `empty()`: `-5`, `abc` e `00` (zero sessões) entravam, e `-80,00` e
  `80,005` também. A venda copia a quantidade e o valor de cada item para o pacote
  do cliente.
- **Na API**, o serviço já tinha de ser da unidade do pacote, mas de qualquer tipo e
  em qualquer estado: o produto, o Lava VIP, o serviço desativado, o próprio pacote.
  A quantidade não tinha teto (acima do inteiro da coluna, 500), e o valor da sessão
  era arredondado calado: `0.004` gravava 0,00.

**O PDV não tem como cumprir** o item de outra unidade, e o de outro tipo não é
sessão de nada: o cliente paga por um pacote que a casa não consegue debitar.

**O conserto:** o item é um dos serviços que o `<select>` da tela antiga oferece --
ativos, do tipo `ser`, da unidade **do pacote** --, nos dois caminhos, e quem escolhe
a unidade é o repositório (`ProdutoRepository::servicosDoPacote`), e não cada
controller. A quantidade é um inteiro de 1 ao teto da coluna; o valor da sessão, um
decimal de até duas casas, de zero (a sessão de brinde, que o ERP aceita) a
999.999,99. As regras moram em `RegraDoPacote` -- a da API e a do formulário do ERP,
na língua de cada um. A API ganhou `GET /adm/api/pacotes/:id/servicos`, com a guarda
de compor, para a tela nova montar a mesma lista.

**Provado:** a caracterização (`RegraDoPacoteTest`) mede as duas regras e a consulta
-- o pacote de outra unidade leva os serviços dela --, nos dois bancos; os casos 115
a 131 do ensaio de recusas, pelo HTTP (o produto, o próprio pacote, o serviço
desativado, o de outra unidade, a quantidade acima do teto, o valor de três casas, o
colaborador que só lê). **O que não tem caso:** a linha do controller do ERP que
chama a regra -- a tela antiga não tem ensaio por HTTP; a regra e a consulta que ela
usa, têm.


### D248 — O cadastro de convênio pela API respondia 500, sempre — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase** dos pacotes, dos convênios e dos cupons, antes da tela
nova dos convênios. `POST /adm/api/convenios` montava o convênio sem `status`, e a
coluna é `NOT NULL` sem padrão: todo cadastro terminava em 500 -- o molde do D239.

**O conserto:** o cadastro nasce ativo, como no formulário do ERP
(`RegraDoConvenio::paraOServico` + `status => true`).

**Provado:** os casos 132 e 133 do ensaio de recusas (201; o 132 confere no banco o
convênio ativo e sem ligação), nos dois bancos.


### D249 — O PUT de convênio apagava todas as unidades: o convênio de uma franquia passava a valer na rede inteira — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase.** `ConvenioService::update()` chamava sempre
`salvarUnidades()`, que apagava todas as ligações e só reinseria as que vinham no
pedido. O `PUT` da API nunca as mandava, e **convênio sem ligação quer dizer "vale
em todas as unidades, inclusive nas que abrirem depois"**. Renomear o convênio de
uma franquia o transformava num desconto da rede.

**O conserto:** as ligações só mudam quando a chave `unidades` vem. Ela aceita
"todas" ou a lista NÃO vazia dos ids -- a lista vazia é recusada, porque é
exatamente o que um cliente com defeito manda --, e a lista grava a DIFERENÇA contra
as ligações de agora: a que fica é a mesma linha. `valeEmTodas` sai no recurso com
todas as letras.

**Provado:** `ConvenioUnidadesTest` (sem a chave as ligações ficam; o mesmo conjunto
mantém as linhas; "todas" apaga; a diferença tira e põe; e tirar a unidade de um
convênio não a tira dos outros -- o caso que o mutante sobrevivente pediu), nos dois
bancos; os casos 134, 135, 140 e 142 do ensaio, pelo HTTP.


### D250 — O corpo do convênio na API aceitava porcentagem zero e fracionária, e texto acima da coluna — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase**, com um cético por achado. As regras moravam num
método privado do controller: o nome sem teto e sem aparar; a porcentagem
fracionária e o zero aceitos (a base tem 638 convênios, **todos** com porcentagem
inteira de 1 a 100 -- a faixa do `<select>` do ERP); os textos sem o teto da coluna,
que o MySQL estrito recusava com 500.

**O conserto:** `RegraDoConvenio`, pura, no molde da `RegraDoPacote`: o nome aparado,
de até 91 caracteres (ver o D253), sem emoji, sem `<` e `>`, sem caractere de
controle; a porcentagem um inteiro de 1 a 100; os textos com o teto da COLUNA
(contado em caracteres) e sem emoji; o e-mail um e-mail; a data `AAAA-MM-DD` de 2000
a 2099; a chave que a API não altera recusada com o nome dela.

**Provado:** `RegraDoConvenioTest`, e os casos 136, 137 e 143 do ensaio.


### D251 — O filtro "vigente" dos convênios sumia, e o vencido não era recalculado na gravação — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase.** `listagemIndex` testava o contrato com `!= ""`: no PHP
8, `false != ""` é falso, e o "vigente" da API (o booleano falso) sumia do filtro --
a lista dos vigentes trazia os vencidos. E o sinalizador `contrato_vencido` só era
recalculado pela rotina do D50, que está parada: o cadastro com data passada nascia
"em andamento", e apagar a data não desmarcava.

**O conserto:** o filtro com `isset && !== ''`; uma regra só do vencido
(`marcarVencimento`: a data antes de hoje marca, a de hoje em diante e a ausente
desmarcam), aplicada no cadastro, na alteração e na rotina.

**Provado:** `ConvenioUnidadesTest` (o vigente e o vencido filtram; a data passada
nasce vencida; o contrato que vence hoje não; apagar a data desmarca) e o caso 146
do ensaio.


### D252 — Filtrada por uma unidade, a lista de convênios contava uma unidade — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase.** A contagem e o filtro saíam do mesmo `LEFT JOIN`: com
o filtro, a junção só guardava a ligação que casava, e o convênio de três unidades
aparecia com uma. É a consulta da lista antiga também.

**O conserto:** `totalUnidades` por subconsulta, com `COUNT(DISTINCT)` -- a tabela
não tem chave única, e o par repetido não conta duas vezes --; o filtro de unidade
por `EXISTS` ou `NOT EXISTS`, **entre parênteses** (o D129: sem eles, o ramo dos
globais escapava do filtro de estado).

**Provado:** `ConvenioUnidadesTest` (três unidades, filtrado por uma, mostra três,
com o par repetido; o inativo global fica fora da lista de ativos; o global fora do
nome também) e o caso 148 do ensaio. **O D129 ganhou guarda:** até esta fatia, tirar
os parênteses deixava tudo verde.


### D253 — O nome de convênio de 92 caracteres ou mais derrubava a venda — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase.** A venda grava "Convênio " e o nome em
`vendas_produtos.desconto_motivo`, um `varchar(100)`, e o MySQL roda estrito: com
o nome de 92 caracteres ou mais, lançar um serviço para o cliente conveniado dava
500. **Na cópia há um convênio assim, ativo, de 100 caracteres** (0 clientes hoje).

**O conserto:** o motivo é cortado em 100 (`mb_substr`), e o nome novo vai até 91, na
API e no formulário do ERP. O prefixo que a tela nova lê fica inteiro.

**Provado:** `ConvenioDescontoTest` (o nome de 100 caracteres lança a venda, com o
motivo de 100), nos dois bancos.


### D254 — A lista de convênios do ERP imprimia o nome cru — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase**, na família do D203: o nome ia sem escape à lista
antiga. O conserto é o `escapeHtml`; `TelasDosConveniosDoErpTest` renderiza a lista
pelo `ViewRenderer` e confere o HTML. As outras telas que ainda imprimem o nome do
convênio cru ficaram anotadas no D203.


### D255 — Quem tinha "escrever" em convênios alterava o convênio da rede: o gerente apagava as ligações das outras franquias — CORRIGIDO em 28/09/2026

**Achado pelo desenho da tela nova.** O convênio não tem unidade dona: todo
convênio alcança outras franquias -- o que vale em todas e o que está ligado a
várias. Na tela antiga, o `<select>` de unidades de quem não é administrador só
oferece a própria unidade; salvar um convênio de outras franquias apagava as
ligações delas, e tirar a própria unidade de um convênio só dela o abria para a
rede.

**Medido na cópia antes de fechar:** nenhum não administrador ativo tem
`configuracoes/convenios/escrever` -- nem por linha própria nem pelo molde do grupo
--, e dois têm `ler`. A guarda não tira nada de ninguém.

**O conserto (decisão tomada por mim, com o caminho de volta registrado no roadmap):**
só o administrador cadastra, altera, ativa e desativa -- na API (403 com a frase "Só
o administrador altera convênio: ele vale em outras franquias.") e no ERP (a ação
volta à lista com a mesma frase, e a lista só mostra novo, editar e alternar a ele).
Ler continua com `ler`.

**Provado:** os casos 138, 144, 151 e 152 do ensaio (o gerente COM escrever
cadastra, altera, desativa e ativa: 403, e o banco intacto) e o 153 (o gerente abre
o convênio). **As guardas do ERP e a da troca de estado moram no controller**, onde a
caracterização não chega: `conferir-recorte-por-unidade.php` ganhou dez entradas, e
arrancar seis delas, uma por vez, deixou o portão vermelho em todas.


### D256 — Salvar no ERP um convênio ligado só a unidades inativas o abria para a rede inteira — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase. Na cópia são 260 convênios assim.** O `<select>` de
unidades do editar só oferecia as ativas (`popularCombobox`): a ligação inativa não
era desenhada, não voltava no POST, e salvar qualquer campo -- o telefone -- apagava
as ligações. Sem ligação, o convênio passava a valer em **todas** as unidades.

**O conserto:** as opções são as ativas **mais as ligadas que não estão entre elas**,
com "(inativa)" (`ConvenioRepository::opcoesDoFormulario`); o select vazio continua
querendo dizer "todas", como o rótulo promete; e o `PUT` da API aceita, além das
ativas, as já ligadas.

**Provado:** `ConvenioUnidadesTest` faz a volta inteira -- as opções, o formulário
montado com elas aceitando o POST com a inativa, e a ligação que fica depois de
salvar o telefone --; o caso 154 do ensaio marca outra unidade num convênio ligado a
uma inativa, pelo HTTP. **Completado pela revisão adversarial**, que mostrou a
primeira versão (privada, no controller) desfeita sem nenhum teste falhar.


### D257 — O formulário de convênio do ERP gravava o POST cru, sem as regras — CORRIGIDO em 28/09/2026

**Achado pelo mapa da fase.** O ERP passava o POST inteiro ao hidratador: o `id` do
campo oculto, o `contrato_vencido` e a data da última venda chegavam aos setters, e
o `StringTrim` e o `StripTags` do filtro não valiam no que se gravava. O formulário
não tinha teto nos textos (500), nem conferia o e-mail e a data, e a porcentagem
aceitava qualquer número por um POST forjado (`disable_inarray_validator`).

**O conserto:** o que vai ao serviço é o `getData()` do formulário, campo a campo
(`RegraDoConvenio::doFormularioDoErp`), e o editar grava com o id da ROTA. O filtro
tem os tetos da coluna, o e-mail, a data `d/m/Y` e a porcentagem das opções. **A
revisão adversarial acrescentou o emoji** -- as colunas são `utf8mb3`, e o `flush`
respondia 500 onde a API responde 422 -- **e o estado** (o D259).

**Provado:** `TelasDosConveniosDoErpTest` (o formulário de verdade: o que ele recusa,
o emoji inclusive; o que vai ao serviço) e `RegraDoConvenioTest`.


### D258 — A gravação do convênio não era atômica nem travada: uma falha no meio o deixava valendo em todas — CORRIGIDO em 28/09/2026

**Achado pelo desenho.** O cadastro gravava o convênio e depois as ligações, cada
um com o seu `flush`: uma falha entre os dois deixava o convênio **sem ligação
nenhuma -- valendo na rede inteira** --, e quem repetisse o pedido criava outro. E a
alteração lia as ligações sem trava: duas abas tirando cada uma uma das duas últimas
unidades liam as duas e apagavam cada uma a sua; não sobrava nenhuma.

**O conserto:** o cadastro e a alteração numa transação só, e a alteração trava a
linha do convênio (`PESSIMISTIC_WRITE`) antes de ler as ligações.

**Provado pela revisão adversarial**, que achou os dois sem teste: o cadastro com
uma unidade inexistente, num EntityManager próprio, não deixa convênio gravado; e a
primeira leitura da alteração é `FOR UPDATE` (`ConvenioUnidadesTest`, nos dois
bancos).


### D259 — Salvar o convênio no ERP desfazia a ativação ou a desativação feita em outra tela — CORRIGIDO em 28/09/2026

**Achado pela revisão adversarial** da tela nova. O `<select>` de status das duas
telas do ERP fica dentro de um `display-none`: ninguém o escolhe, e ele devolve o
estado que a página leu ao abrir. Com as duas telas convivendo, o caso comum é este:
o administrador abre o editar do convênio ativo, alguém o desativa pela tela nova, e
salvar o telefone o reativa -- calado.

**O conserto:** o estado não vem do formulário. O cadastro nasce ativo, como na API;
a edição não toca nele -- o estado muda só pelo alternar da lista e por `/ativacao` e
`/desativacao`.

**Provado:** `RegraDoConvenioTest` (na edição, nenhuma chave de estado, com o select
dizendo "0", "1" ou nada; no cadastro, ativo sempre).


### D260 — O ativar e o desativar dos convênios no ERP eram um GET que alternava — CORRIGIDO em 28/09/2026

**Achado pela revisão adversarial**, na família do D199: o conserto 12 do desenho
tinha ficado de fora. Os dois botões da lista apontavam para `GET
/mod-configuracoes/convenios/ativarRemover/ID`, e a ação ALTERNAVA o estado. O
cookie é `SameSite=Lax`, que vai na navegação de primeiro nível: um link aberto por
um administrador logado trocava o estado de um convênio da rede, e duas navegações
ao mesmo link (a pré-carga, o duplo clique, o histórico) se desfaziam.

**O conserto:** a ação só aceita POST, com o estado pedido no corpo (`estado` =
"ativo" ou "inativo"), e grava o pedido (`definirEstado`), e não o contrário do que
leu. O desativar abre a caixa de `remover-por-post.js`; o ativar é um formulário da
própria linha. O interruptor do serviço (`ativarRemover`) saiu.

**Provado:** `conferir-remocao-por-post.php` lê o par tela e ação -- a trava das
remoções por GET desceu de 19 para 18 --, e `TelasDosConveniosDoErpTest` confere os
dois formulários e o estado de cada um.

> **06/10/2026 -- só da mesma origem** (a pergunta 3 do lote, decidida pelo dono): o POST de outro site,
> de outro subdomínio ou com o `Origin` de outro host é 403 antes de ler o convênio, pela `OrigemDoPedido`
> (o D290). Ver "A barreira de origem nos quatro botões", no fim do D315.


### D261 — Voltar do detalhe depois de uma mudança deixava o foco no `body` por toda a releitura: catálogo, pacotes e convênios — CORRIGIDO em 28/09/2026

**Achado pela verificação no navegador** da tela dos convênios. Desativar o convênio
e voltar relê a lista -- a linha pode ter saído dela --, e as três telas esperavam a
releitura acabar para decidir entre a linha e o título. Até lá o foco ficava no
`body`: no navegador, 4 s. Quem aperta Tab nesse meio-tempo recomeça do topo da
página, e o leitor de tela não anuncia nada.

**E o teste afirmava o contrário sem conferir.** O caso do catálogo dizia, em
comentário, "a linha velha recebe o foco", e em seguida fazia `blur()` nela -- que
passa igual quando ela não tem foco nenhum. A asserção que o comentário prometia,
escrita, falhava antes do conserto.

**O conserto:** o molde saiu das três cópias para `useFocoDaLista`
(`frontend/src/telas/focoDaLista.ts`). Durante a releitura, a linha de onde se voltou
recebe o foco já, na lista de antes; se a lista nova não a trouxer, o foco vai ao
título. A linha que ainda não está na lista de antes -- o item recém-cadastrado --:
o título segura o foco e o passa a ela quando chega, a menos que a pessoa o tenha
levado a outro lugar nesse meio-tempo.

**Provado:** `focoDaLista.teste.tsx` (13 casos) e 10 mutantes do hook, todos mortos
-- um deles é o código de antes, "espera a releitura"; o caso do catálogo com a
asserção; e, no navegador, o foco na linha durante os 4 s da releitura e no título
quando ela saiu da lista.


### D262 — O nome de campanha de cupom longo derrubava a venda com o cupom — CORRIGIDO em 28/09/2026

**Achado pelo desenho da fatia dos cupons.** O PDV antigo aplica o cupom com o motivo
que `pesquisarCupom` monta -- "CUPOM VIP: ", o nome da campanha, " | #" e o número
dela --, e a venda grava esse texto em `vendas_produtos.desconto_motivo`, um
`varchar(100)`, com o MySQL estrito. O nome não tinha teto: **a #129 da cópia,
ativa, tem 93 caracteres**, e o motivo dela dava 111. O `maxlength` do campo da
tela não vale para o `.val()` que o preenche. A #129 emitiu 510 cupons e nenhum foi
usado -- compatível com a falha, sem prová-la, porque a taxa geral de uso é 0,11%.

**O conserto:** `RegraDoCupom::motivoNoPdv` corta o NOME, e não o fim do texto: o
prefixo é o que a queima procura, e o número diz, na venda, qual campanha deu o
desconto. É a família do D253, que cortou o motivo do convênio. **Muda dinheiro:** a
#129 passa a dar o desconto que prometia.

**Provado:** `RegraDoCupomTest` (93 com o id 129 dá exatamente 100; o nome que cabe
sai intacto, mesmo com o maior id que a coluna guarda; o corte conta caracteres) e
`CupomQueimadoTest` pelo caminho inteiro -- o motivo vai ao item pelo
`VendaProdutoService`, e o fechamento queima o cupom --, nos dois bancos. **Sem o
corte, o caso cai com "Data too long for column 'desconto_motivo'"**: a falha,
reproduzida. A linha do controller que chama a regra ainda não tem caso próprio; o
ensaio HTTP dos cupons, na fatia da tela, é quem vai conferi-la.


### D263 — O ativar e o desativar das campanhas de cupom no ERP eram um GET que alternava — CORRIGIDO em 28/09/2026

**Achado pelo desenho da fatia dos cupons**, na família do D199 e no molde do D260
dos convênios. Os dois botões da lista apontavam para `GET
/mod-marketing/cupons/ativarRemover/ID`, e a ação ALTERNAVA o estado. O cookie é
`SameSite=Lax`, que vai na navegação de primeiro nível: um link aberto por quem
escreve trocava o estado de uma campanha, e duas navegações ao mesmo link (a
pré-carga, o duplo clique, o histórico) se desfaziam.

**O conserto:** a ação só aceita POST, com o estado pedido no corpo (`estado` =
"ativo" ou "inativo"), e grava o pedido, e não o contrário do que leu. O desativar
abre a caixa de `remover-por-post.js`; o ativar é um formulário da própria linha.

**Provado:** `conferir-remocao-por-post.php` lê o par tela e ação -- a trava desceu
de 18 para 17, e tirar o `isPost()` da ação deixa o portão vermelho --, e
`TelasDosCuponsDoErpTest` confere os dois formulários e o estado de cada um.

> **06/10/2026 -- só da mesma origem** (a pergunta 3 do lote, decidida pelo dono): o POST de outro site,
> de outro subdomínio ou com o `Origin` de outro host é 403 antes de ler a campanha, pela `OrigemDoPedido`
> (o D290). Ver "A barreira de origem nos quatro botões", no fim do D315.


### D264 — A campanha de cupom encerrada ficava "ativa" para sempre, e o comentário culpava a rotina — CORRIGIDO NA LEITURA em 28/09/2026 — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 11/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decisão do dono: a rotina da noite fechar a campanha encerrada; hoje só a leitura a diz encerrada, e o estado gravado segue ativo.

**Achado pelo desenho da fatia dos cupons.** O comentário da API dizia que a rotina
da noite (`cuponsValidarVencimento`) desativava as campanhas vencidas. **Não
desativa:** ela só vence os cupons EMITIDOS. Quem vira o estado da campanha
encerrada é uma escrita dentro do GET da lista antiga (`validarVencimento`), e só na
unidade de quem a abre. Quando o menu abrir a tela nova, ninguém mais vira. Na
cópia, duas campanhas ativas já passaram do fim (a #143 e a #144).

**O conserto, sem mexer no estado:** o recurso diz `encerrada`, calculada no servidor
com o relógio da emissão (`RegraDoCupom::encerrada`, o avesso exato do corte da
emissão -- a campanha ainda emite no próprio dia limite). O comentário e o
`docs/f2-fatia-cupons.md` foram reescritos. **Fechar a encerrada pela rotina ficou com
o dono** (a pergunta 1 da fatia): mexe numa rotina que roda em produção.

**Provado:** `CupomTest` (a encerrada não emite e o emitido dela resgata; emite no
próprio dia; o recurso a chama de encerrada) e o caso 21 do ensaio dos cupons.

#### CORRIGIDO em 11/10/2026

**A resposta do dono** (a 93 do lote, a opção (a)): *"A rotina da noite faz o que a lista antiga faz -- Desativa a
campanha encerrada que não tem mais cupom válido, em todas as unidades, e a lista antiga deixa de gravar ao ser
aberta."*

**O conserto.** A rotina da noite (`CronJobService::cuponsValidarVencimento`, 01:20) vence os cupons dados e, no mesmo
passo, desativa a campanha ativa que passou da data de fim -- `CupomRepository::encerradasAtivas`, em todas as unidades,
o avesso exato do corte da emissão (no próprio dia limite ela ainda emite) -- e não tem mais cupom válido: ativo e não
usado, o `validarCuponsAtivos` que a lista antiga usava. Os cupons vencem antes, e o de ontem não segura a campanha. Ela
grava a data da alteração, que a lista antiga não gravava, e a linha do registro das rotinas diz quantas campanhas
saíram. A lista antiga (`CuponsController::indexAction`) deixou de gravar; `CupomService::validarVencimento` e
`CupomRepository::validarVencimento` saíram. Na cópia, a #143 e a #144 (0 cupons válidos) são as que a primeira noite
tiraria das ativas; as duas campanhas das fixtures, com cupom até 2030, ficam. A tela nova diz, no aviso da encerrada
ativa: "Sem cupom válido, a rotina da noite a tira das ativas."

**Provado:** o `CupomRotinaDaNoiteTest`, com a rotina em processo (sem cron, sem HTTP, sem mensagem), nos dois bancos: a
encerrada sem cupom válido sai, com a data; a que tem cupom válido fica, e o cupom dela segue; a que acaba hoje fica; a
de outra unidade sai; a encerrada cujo único cupom venceu ontem sai na mesma noite; e abrir a lista antiga não muda o
estado. Os mutantes -- a rotina sem o laço das campanhas, desativando mesmo com cupom válido, presa a uma unidade, e a
lista antiga gravando de novo no GET -- deixaram cada um o seu caso vermelho; no front, a frase da rotina arrancada
deixou o caso dela vermelho. O ensaio HTTP dos cupons (`ensaiar-cupons-adm-api.php`), 59 de 59 nos dois bancos.

**Na verificação (11/10/2026), o controle deixou de depender da data fixa das fixtures.** Dois casos conferiam que as
campanhas das fixtures (90001000 e 90001150) seguiam ativas -- encerradas em 31/01/2026, e de pé só porque o cupom delas
vale até '2030-12-31': a partir de 01/01/2031 a própria rotina o venceria, e os dois casos cairiam sem mudança no código.
O controle agora é escrito no caso, com o cupom valendo daqui a dez dias (a encerrada da outra unidade com cupom válido
fica). Falsificado: com os cupons das fixtures vencidos ontem -- o 2031, antecipado --, os dois casos de antes caem, e os
de agora ficam verdes.


### D265 — O produto da campanha de cupom podia ser de outra franquia, e a campanha mudava de franquia — CORRIGIDO em 28/09/2026

**Achado pelo desenho da fatia dos cupons.** A API aceitava qualquer produto que
existisse, de qualquer franquia; o ERP, qualquer id (o `<select>` tinha o
`disable_inarray_validator`); e a edição do ERP gravava a `unidade` postada -- o
gerente mudava a campanha de franquia forjando o POST. O resgate não confere a
unidade (o D275), então a escrita era a única guarda. Na cópia, 0 campanhas com
produto de outra unidade.

**O conserto:** a campanha aceita os produtos ativos da unidade DELA
(`ProdutoRepository::paraCupom`, de qualquer tipo, como o ERP); o ligado serve
mesmo inativo, na edição -- **e só no seu campo**: numa lista única, a revisão
adversarial de 28/09/2026 levou o oferecido desativado para a regra de "nunca
comprou", que ninguém compra, e todo cliente passaria a ganhar; a unidade não muda
(422 na API, e o formulário do ERP não tem o campo na edição); e o cadastro usa a
unidade postada só se quem pede pode escolhê-la. O `popularCombobox` de produto
passou a derivar do `paraCupom`.

**Provado:** `RegraDoCupomTest`, `CupomTest` (os ativos da unidade; o `<select>`
continua o mesmo), `TelasDosCuponsDoErpTest`, e os casos 1, 2, 13, 15, 17, 19, 20,
42, 43, 45 e 54 do ensaio, conferidos no banco.


### D266 — O filtro "usado" da API não filtrava, e os emitidos sem venda sumiam da lista — CORRIGIDO em 28/09/2026

**Achado pelo desenho da fatia dos cupons.** A consulta dos emitidos testava
`$request['utilizado'] != ""`, e no PHP 8 `false != ""` é falso: o `usado=nao` da API
trazia os usados junto. E o `JOIN` com a venda era interno: **1.166 emitidos da cópia
não têm venda** -- 860 do check-in, que emitia com a venda nula até 29/11/2025 -- e
nenhum aparecia.

**O conserto:** os filtros testados pela presença; o `LEFT JOIN`, com a venda nula no
recurso e um "—" na tela antiga; e a situação na data (válido, vencido, usado), com
os predicados do resgate.

**Provado:** `CupomEmitidosTest` -- nasceu afirmando os dois defeitos, e se inverteu
com o conserto --, e os casos 23 a 27 do ensaio.


### D267 — O corpo da campanha de cupom na API aceitava tudo: sem tetos, o fracionário truncado, a unidade em 500 — CORRIGIDO em 28/09/2026

**Achado pelo desenho da fatia dos cupons.** As regras moravam em métodos privados do
controller: o nome sem teto; o desconto `50.9` gravado como 50, calado; a validade
`3000000000` em 500 na coluna `int`; a unidade `abc` ou inexistente do administrador
em 500 na FK; as chaves que a API não altera (`estado`, `id`...) caladas; e o `PUT {}`
gravava a data de alteração e respondia 200.

**O conserto:** `RegraDoCupom::conferir`, a mesma do formulário do ERP, com o campo
nomeado; o `PUT {}` é 400.

**Provado:** `RegraDoCupomTest` (cada frase, literalmente) e os casos 3 a 12 e 16 do
ensaio -- 422, e não 500, no banco nada gravado.


### D268 — O formulário de cupom do ERP gravava o POST cru: o id oculto escolhia QUAL campanha gravar — CORRIGIDO em 28/09/2026

**Achado pelo crítico do desenho**, na família do D139 e na forma do D202. O editar
conferia a unidade do id da ROTA e gravava o `id` do campo oculto: o gerente
regravava a campanha de outra franquia a partir da própria. E o formulário ia cru ao
hidratador -- o `status` do `<select>` escondido desfazia a troca de estado feita em
outra tela (o molde do D259).

**O conserto:** o formulário perdeu o `id` e o `status`; o editar grava a campanha da
rota por `CupomService::alterar`, que só tem setter para o que a campanha pode mudar;
e só o que mudou é conferido -- a campanha antiga de nome acima de 75 (a #129) salva
os outros campos.

**Provado:** `TelasDosCuponsDoErpTest`, `CupomTest` (`alterar` só mexe no que veio) e
o caso 41 do ensaio: o POST com o id oculto da 90001150 grava a campanha da rota, e
a vizinha fica intacta, conferida no banco. O token CSRF é lido da página antes --
sem ele o caso passaria sem medir nada.


### D269 — Nomes de campanha, de produto e de cliente iam crus às telas de cupom do ERP — CORRIGIDO em 28/09/2026

**Achado pelo desenho**, na família do D203: o nome da campanha na lista e no editar,
os produtos na lista, o cliente e o criador nas estatísticas, e o `<option>` do
catálogo por unidade. Todos com `escapeHtml` (ou `htmlspecialchars` no JSON). E o
"Novo" aparecia para quem só lê -- e dava 404.

E o rótulo da unidade na lista, que só o administrador vê: o bairro entra cru pelo
cadastro de unidades (a revisão adversarial de 28/09/2026 o achou; o editar já o
escapava).

**Provado:** `TelasDosCuponsDoErpTest` (a lista escapa o nome e a unidade; só quem
escreve vê o Novo).


### D270 — O momento "check-in" era aceito, e a campanha de check-in não emite nada — CORRIGIDO em 28/09/2026

**Achado pelo desenho da fatia dos cupons.** A emissão no check-in está comentada no
código (`AgendasController`) -- **e estava ligada em produção até 29/11/2025**: 950
cupons emitidos, 860 sem venda. Hoje são 12 campanhas de check-in, 3 ativas, que não
emitem. A API e o ERP aceitavam criar mais.

**O conserto:** a campanha nova e a troca para "check-in" são recusadas, com a frase;
as existentes ficam como estão, e o ERP ainda as mostra, dizendo que não emitem.
**Religar a emissão no check-in ficou com o dono** (a pergunta 3).

**Provado:** `RegraDoCupomTest`, `TelasDosCuponsDoErpTest` e o caso 8 do ensaio.


### D271 — Mudar o desconto ou o produto da campanha muda os cupons já emitidos — REGISTRADO em 28/09/2026 — PARCIAL em 11/10/2026: o esquema espera a resposta 83 — PREPARADO, e na cópia em 12/10/2026: as colunas nos dois bancos dela; em produção, no passo 0 do corte

**Achado pelo desenho da fatia dos cupons.** O resgate lê o desconto e o produto
ATUAIS da campanha (`pesquisarCupom` e `selecionarCupomProdutoCliente`), e o
aplicativo mostra o mesmo; a notificação já enviada tem a porcentagem antiga. Na foto
de produção, 487 cupons válidos em 9 campanhas, até 152 numa só.

**Não travei** (a pergunta 2): uma campanha que emite sem parar (a #121 emitiu
18.658) sempre teria algum cupom válido, e o desconto dela ficaria congelado. A tela
nova avisa, com o número do servidor. E mesmo a trava teria corrida: fechar isso
exigiria travar a campanha na emissão, dentro da venda.

**Caracterizado:** `CupomQueimadoTest` (o desconto do resgate é o da campanha na hora).

#### PARCIAL em 11/10/2026: o esquema espera a resposta 83

**A resposta do dono** (a 94 do lote, a opção (a)): *"O cupom guarda o desconto e o produto do dia em que foi dado --
São duas colunas novas (nas cópias locais, pela pergunta do esquema; na produção, no corte). Os cupons de antes seguem a
campanha."* A pergunta do esquema (a 83, o esquema em bloco nas cópias locais) não foi respondida: as colunas foram só ao
repositório.

**O conserto.** Duas colunas novas, `cupons_clientes.desconto` e `cupons_clientes.produto_oferecido` (inteiras,
anuláveis, a segunda com a chave estrangeira para `produtos`): a migração
`scripts/migracao-cupons-clientes-desconto-produto.sql`, a nona do laço do passo 0 do corte, com "NAO rode na copia
local" no cabeçalho -- e por isso fora do laço do ensaio geral --; o `mysql.sql`; o `postgres/schema.sql` e o
`scripts/migrar-dados.sh`, regerados pelos geradores (só a `cupons_clientes` mudou nos dois); e os bancos da suíte, que
nascem deles. **Nada na `franquiabv_dev`.** A emissão (`gerarCupons`) grava nelas o desconto e o produto da campanha
naquela hora (`CupomClienteRepository::guardarODoDia`), e o resgate do PDV antigo (`pesquisarCupom`), a queima do
fechamento, a comanda nova (o D276) e o aplicativo leem o que o cupom guardou (`cuponsQueValem`,
`cupomQueValeNoProduto`, `selecionarCuponsAtivosCliente`); nulas -- o cupom de antes --, seguem a campanha. O detalhe da
campanha na API ganhou `emitidos.validosQueSeguemACampanha`, e a tela nova avisa, ao mudar o desconto ou o produto,
quantos cupons antigos mudam e quantos ficam com o do dia em que foram dados.

**A COLUNA AUSENTE NA CÓPIA NÃO QUEBRA O ERP, e o molde não existia.** A `clientes_aparelhos` (o D461) era tabela nova
-- a entidade mapeada só a pede quando alguém a lê, e o código apanhava o `TableNotFoundException` --, e o `id_pai` (o
D220), índice, que não muda o que o Doctrine pede. Coluna nova numa tabela que o ERP inteiro lê não pode ser mapeada
enquanto a cópia não a tem: o Doctrine a pediria em toda leitura de cupom, e todas falhariam; e o `try`/`catch` não serve
no PostgreSQL, onde o erro aborta a transação da venda. Então as duas ficam **fora do mapeamento**, o código as lê e as
grava pela conexão, e pergunta antes se elas existem (`ISBase\Doctrine\ColunaNoBanco`, uma consulta ao
`information_schema` por pedido): sem elas, o cupom segue a campanha, como antes. O molde e o custo dele estão no D473.

**Provado:** nos bancos da suíte, o `CupomGuardaODiaTest` -- a emissão guarda; mudar o desconto ou o produto da campanha
não muda o cupom dado, no PDV antigo, no aplicativo e no resgate; o fechamento com o produto do dia o gasta; o de antes
segue a campanha ao lado do que guardou; o resumo conta só os que seguem --, e o caso da comanda nova que usa o desconto
guardado; verdes nos dois bancos. **Na forma da cópia** -- bancos descartáveis montados do `mysql.sql` e do `schema.sql`
do `HEAD`, sem as colunas, mais as fixtures, apagados no fim --, os 250 casos dos cupons e das comandas, nos dois bancos:
244 verdes e os 6 que exigem as colunas pulados; o pulo é decidido pelo `information_schema` direto
(`ColunasDoCupomDoDia`), e não pela função que ele prova, e o caso que confere as duas
(`testAPerguntaDasColunasDizOQueOBancoTem`) não se pula. **O laço do passo 0**, com a nona, duas vezes num banco
descartável montado do dump de produção (o `mysql.sql` do `3b1f05c`): sem erro (na segunda, "colunas ja existem, nada a
fazer"), e a estrutura igual à 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 `unidades.iframe_bi` de sempre. Os mutantes -- a emissão sem gravar; o resgate, o
aplicativo e o PDV lendo a campanha; o resumo contando todos; e a pergunta das colunas dizendo "não tem" ou olhando o
esquema errado -- deixaram cada um o seu caso vermelho; no front, a promessa e o salvo contando todos os válidos.

**Falta:** as colunas na cópia local (a pergunta 83; até lá, nela, o cupom segue a campanha, e o ensaio geral sobre ela
não passa da conferência -- `docs/ensaio-geral-do-corte.md`), e a migração em produção, no passo 0 do corte. No
servidor de teste do BVIP, o próximo `atualizar` para no esquema, como em toda migração (o `ESQUEMA_RE` do
`scripts/deploy-teste.sh`, pela leitura): sem o `ALTER` lá -- que é cópia, e pede a mesma autorização da 83 --, o
`ACEITAR_ESQUEMA=sim` sobe o código, que funciona, e o cupom segue a campanha.

#### A verificação, em 11/10/2026: os guardas sem caso, e a tela que prometia o do dia sem as colunas

**Os guardas da cópia sem as colunas não tinham caso** (consertado). Três guardas, todos pela mesma pergunta
(`guardaODoDia`), impedem a cópia de tocar nas colunas que não tem -- o de `guardarODoDia` (a emissão), o de
`oQueGuardaram` (a leitura) e o de `CupomRepository::resumoDosEmitidos` (a conta) --, e os três bancos que rodam alguma
coisa têm as colunas: arrancar um deles deixava a suíte, o contrato e os ensaios verdes, e na cópia todo fechamento que
dá cupom chegaria ao `UPDATE` e cairia em "Unknown column" (no PostgreSQL, a venda inteira). Os 244 verdes com 6 pulados
da forma da cópia eram de uma rodada avulsa, fora do repositório. O `CupomSemAsColunasTest`, novo, os prova na suíte, nos
dois bancos: a pergunta responde "não tem" de propósito, e as colunas são enchidas com 30% e a Pomada, enquanto a campanha
dá 10% no Corte -- com os guardas, a emissão não grava nelas, o PDV antigo, o aplicativo, a comanda nova e a queima dão
os 10% no Corte, e a conta diz que todos os válidos seguem a campanha. Ele confere também que a pergunta diz "não" à
coluna e à tabela que não existem em banco nenhum (a ausência de verdade, que a suíte não consegue montar: ver o D473).
**Mutantes**, cada um sozinho no MySQL das fixtures, restaurado pelo sha1: arrancar o guarda da emissão, o da leitura ou o
da conta deixa vermelho, cada um, o seu caso; a pergunta que diz sempre "tem" deixa os quatro vermelhos.

**O "como funciona" prometia o do dia onde as colunas não existem** (consertado). A frase dizia sempre "o desconto e o
produto do dia em que o cupom foi dado", e na cópia sem as colunas -- e no servidor de teste subido sem a migração --
todo cupom segue a campanha; o aviso de alterar, ao lado, dizia o contrário, porque lê os números do servidor. O recurso
da campanha (`CupomJson::completo`) ganhou `guardaODoDia`, a mesma pergunta das colunas, e a tela nova escreve o que vale
em cada banco: sem as colunas, o "como funciona" diz "com o desconto que a campanha tiver naquela hora", e o aviso de
alterar e o do salvo voltam às frases de antes do D271 ("Muda também os 3 cupons já emitidos e ainda válidos…",
"Campanha salva: os 3 cupons válidos passam a dar 25%."), sem os "antigos" -- lá não há os que guardam. O servidor de
antes, que não o manda, conta como sem as colunas. O comentário da `Cupons.tsx` que punha o corte numa data diz agora que
o corte é a migração. **Provado:** `CupomSemAsColunasTest` (o recurso diz `false` sem as colunas e `true` com elas), o caso
22 do ensaio dos cupons (o banco do ensaio guarda), e no front 3 casos novos no `campanhaDoCupom.teste.ts` e 2 no
`CuponsLigado.teste.tsx`. **Mutantes:** o recurso dizendo sempre que guarda, ou sempre que não, deixa o seu caso
vermelho; no front, o "como funciona", a promessa, o salvo e a conta que ignoram o banco, e a tela passando "guarda"
sempre, deixam cada um os seus casos vermelhos.

**Falta** o mesmo de antes: as colunas na cópia local (a pergunta 83) e a migração em produção, no passo 0 do corte.

#### PREPARADO, e na cópia em 12/10/2026: as colunas nos dois bancos dela (item ESQUEMA-NAS-COPIAS)

**A resposta do dono** (a 83 do lote, a opção (a), 12/10/2026, 02:30): *"Sim, o que só acrescenta -- Tabela, coluna e
índice que não apagam linha entram nas duas cópias locais quando a resposta pedir, cada um com a sua migração no passo 0;
o que apaga linha (como juntar os privilégios repetidos) continua um a um, e a base do teste do BVIP os recebe na próxima
cópia exportada, pela sua mão."* A 94 pedia as duas colunas "nas cópias locais, pela pergunta do esquema": é o primeiro
uso da regra.

**O conserto, só na cópia do desenvolvimento, e só as duas colunas** (com o índice e a chave estrangeira que vêm com
elas), com a suíte, o contrato e os ensaios parados. Conferido antes, só com leitura: as colunas **não existiam** na
`franquiabv_dev` (no MySQL, a `cupons_clientes` com 9 colunas; no PostgreSQL, com 10, contando o `inquilino_id`) e
existiam nos bancos da suíte (`franquiabv_cilocal` e `franquiabv_contrato`; `franquiabv_pgtestes`); nenhuma transação no
`INNODB_TRX`, nenhuma consulta na lista de processos do MySQL além do agendador, nenhuma sessão de cliente no
`pg_stat_activity`, nenhum PHP além do `php-fpm` no contêiner, e o `local.php` na `franquiabv_dev`.

- **MySQL**, às 05:37:45: a própria migração, como o laço do ensaio a roda
  (`docker exec -i franquiabv-mysql-1 mysql -uroot … franquiabv_dev < scripts/migracao-cupons-clientes-desconto-produto.sql`),
  saída 0, e a conferência dela: 100.375 cupons, 0 que guardaram. No `binlog.000048`, desde a posição anotada antes
  (30869210), **um evento só**: o `ALTER TABLE cupons_clientes` com as duas colunas, o `KEY` e a `CONSTRAINT` (posição
  30869289; `exec_time` de 1 s, lido do cabeçalho do evento, porque o contêiner não tem o `mysqlbinlog`) -- sem `ANALYZE`
  nem outro comando. Na segunda vez, "colunas ja existem, nada a fazer", e o binlog parado em 30869781. O `SHOW CREATE
  TABLE` de antes e o de depois diferem em quatro linhas: as duas colunas, o `KEY` e a `CONSTRAINT`.
- **PostgreSQL** (esquema `bvip`), às 05:39:42, como `franquiabv`, o dono do esquema e da tabela: o que o gerador emite
  para as duas colunas -- o `postgres/schema.sql`, igual byte a byte à saída do `gerar-ddl-postgres.php` nesse dia: as
  linhas 448 e 449, o índice da 1571 e a chave da 1722 --, numa transação só, com a conferência de ausência dentro dela,
  pelo `psql` do contêiner com o roteiro na entrada (`docker exec -i franquiabv-postgres-1 psql -U franquiabv -d
  franquiabv_dev < <o roteiro>`). Saída 0; na segunda vez, "as colunas ja existem: nada feito". O índice novo
  `indisvalid` e `indisready`, 704 kB. O roteiro, inteiro:

  ```sql
  \set ON_ERROR_STOP on
  BEGIN;
  SET LOCAL search_path = bvip, public;
  SET LOCAL lock_timeout = '10s';
  SELECT count(*) = 0 AS ausentes_antes
    FROM information_schema.columns
   WHERE table_schema = 'bvip' AND table_name = 'cupons_clientes'
     AND column_name IN ('desconto', 'produto_oferecido') \gset
  \if :ausentes_antes
  \else
    \echo 'as colunas ja existem: nada feito'
    ROLLBACK;
    \quit
  \endif
  ALTER TABLE cupons_clientes
      ADD COLUMN desconto                         integer DEFAULT NULL,
      ADD COLUMN produto_oferecido                integer DEFAULT NULL;
  CREATE INDEX cupons_clientes_FK_CUPONS_CLIENTES_produto_oferecido ON cupons_clientes (inquilino_id, produto_oferecido);
  ALTER TABLE cupons_clientes ADD CONSTRAINT FK_CUPONS_CLIENTES_produto_oferecido FOREIGN KEY (produto_oferecido) REFERENCES produtos (id);
  COMMIT;
  ```
- **A conferência, só leitura, contra os bancos da suíte:** no MySQL, a forma da `cupons_clientes` -- as 11 colunas
  (posição, tipo, nulabilidade, padrão, conjunto, comparação, extra e chave), os 5 índices, as 4 chaves estrangeiras com
  as regras e as opções da tabela, 21 linhas do `information_schema` -- igual linha a linha à da `franquiabv_cilocal`. No
  PostgreSQL, as 12 colunas (com o padrão e a identidade), os 5 índices, as 6 restrições, o RLS ligado e forçado e a
  política iguais aos da `franquiabv_pgtestes`; a lista de privilégios da tabela diferia só no `bvip_bi=r`, que saiu
  minutos depois com o papel (o **D9**). Nos dois bancos, as duas colunas nulas nos 100.375 cupons: nada foi emitido na
  cópia desde então. E a pergunta do código: o `guardaODoDia` responde `true` contra a `franquiabv_dev` do MySQL (medido
  em processo, pelo PHP do contêiner), e no PostgreSQL a consulta da `ColunaNoBanco`, com o `search_path` da cópia
  (`current_schema()` = `bvip`), conta 2.
- **No repositório, as duas mudanças juntas:** a frase "NAO rode na copia local" saiu do cabeçalho da migração (no lugar,
  o dia e a resposta), e a nona entrou no laço da cópia em `docs/ensaio-geral-do-corte.md`. O
  `conferir-passo-zero-do-corte.php` sai 0 ("copia: 9 migracoes ... o cabecalho tira nenhuma"), e 1 com cada metade
  sozinha: a frase de volta ao cabeçalho, no arquivo de verdade, devolvido pelo sha256 ("o ensaio geral roda
  `migracao-cupons-clientes-desconto-produto.sql` na copia do desenvolvimento, e o cabecalho dela diz 'NAO rode na copia
  local' -- falta a autorizacao do dono"); e o laço com oito, por uma cópia (`--ensaio`: "o laco do passo 0 roda
  `migracao-cupons-clientes-desconto-produto.sql`, e o da copia do desenvolvimento, no ensaio geral, nao").
- **Os portões que conferem o mapeamento contra a cópia não veem estas colunas, e é o esperado:** a entidade não as
  mapeia (o **D473**), e os dois leem do mapeamento para o banco. Contra a cópia com as colunas, o
  `conferir-tipos-do-mapeamento.php` sai 0 (754 campos mapeados, 0 sem coluna, os 70 desacordos da linha de base), o
  `verificar-schema-postgres.php` sai 0 (0 tabelas e 0 colunas ausentes, 0 tipos que bloqueiam). O
  `verificar-schema.php` também sai 0, mas **não abre banco nenhum**: compara as anotações de `module/` com o arquivo
  `mysql.sql`, e esse 0 não diz nada da cópia (a verificação, abaixo). Um 0 desses dois primeiros também não provava as
  colunas -- eles não as viam, e a cópia sem elas daria o mesmo 0; desde a verificação, eles as exigem (o **D475**,
  abaixo). A tolerância do `scripts/tabelas-fora-da-copia.php` era só de tabela, e esta migração não
  cria tabela: nada a tirar. **Quem as vê é o `conferir-schema-tool.sh`**, que lê o banco da aplicação: as duas entram
  entre as colunas que o `--force` apagaria, como o **D473** previa. Ele mostrou 5, e não 6, por um defeito dele (o
  **D474**, corrigido junto); com a leitura de toda cláusula, 6, e a linha de base foi de 4 a 6, com o motivo no roteiro.
- **A carga do corte:** o `SELECT` do `scripts/migrar-dados.sh` para a `cupons_clientes`, que pede as duas colunas,
  responde nos dois bancos da cópia (medido só ele, por leitura, num `COUNT(*)` por fora: 100.375 linhas em cada); antes,
  no MySQL, ele falhava calado (o **D462**). O `conferir-migracao.php` contra as duas cópias não chega às provas: sai com
  2, "a comparação não vale", pelas fixtures nos dois bancos (560 linhas no MySQL e 93 no PostgreSQL, em 38 tabelas) --
  o estado de sempre da cópia do desenvolvimento, que o `ensaio-geral-do-corte.md` descreve.

**Provado nos bancos da suíte** (que já tinham as colunas e não mudaram): o filtro dos cupons e das comandas
(`Cupo|RegraDoItemDaComanda|ComandaApi`), 152 casos e 529 asserções verdes no MySQL e no PostgreSQL, em série.

**Para quem usa a cópia:** o cupom dado daqui em diante guarda o desconto e o produto do dia, e a tela nova da campanha
passa a escrever, no "como funciona" e nos avisos de alterar e de salvo, o que vale com as colunas (o `guardaODoDia` do
recurso é `true`); os 100.375 cupons de antes seguem a campanha. Isso é pela leitura do código e pelos casos do D271, que
rodam nos bancos da suíte: **a tela não foi aberta no navegador contra a cópia**, nem a emissão ou a queima rodaram nela.

**Falta:** a migração em produção, no passo 0 do corte. A base do teste do BVIP recebe as colunas na próxima cópia
exportada daqui, pela mão do dono; até lá, nela, o cupom segue a campanha, e por isso a `ColunaNoBanco` fica (o
**D473**). Nada rodou no BVIP.

#### A verificação, em 12/10/2026 (item ESQUEMA-NAS-COPIAS)

Oito achados, todos de gravidade baixa e todos verdadeiros (dois eram o mesmo), consertados neste dia:

- **Nenhum portão exigia as colunas** no PostgreSQL da cópia, e no MySQL só o `conferir-schema-tool.sh`, que é do
  operador: a cópia refeita sem elas, ou que as perdesse, deixava o `guardaODoDia` falso, calado, e os portões verdes. Os
  dois que conferem o mapeamento contra o banco passaram a exigi-las (o **D475**, abaixo).
- **O `verificar-schema.php` dado como rodado "contra a cópia"** não abre banco: lê o `mysql.sql` e as anotações
  (corrigido no item dos portões, acima, e no roadmap).
- **O portão dos papéis tinha rodado num clone do banco da suíte**, que nunca teve o `bvip_bi` nem os privilégios da
  cópia: refeito num clone da própria cópia, com a lista de privilégios igual à dela, linha a linha (o **D9**).
- **Comentários do front e de dois casos ainda davam a cópia como o banco sem as colunas** -- um deles, o da
  `frontend/src/telas/Cupons.tsx`, dizia o contrário de hoje. Mudaram os de `Cupons.tsx`, `frontend/src/api/cupons.ts`,
  `frontend/src/telas/campanhaDoCupom.ts` (quatro), `tests/Caracterizacao/CupomSemAsColunasTest.php` (quatro) e
  `tests/Caracterizacao/CupomGuardaODiaTest.php` (um): o banco sem as colunas é a base do teste do BVIP, até a próxima
  cópia exportada, ou o servidor subido sem a migração. Só comentário; nenhum caso os pega, e a prova é a leitura.
- **Dois textos com o estado de antes:** a abertura das respostas 93 a 97 no `docs/f2-fatia-cupons.md`, que dava o D271
  como parcial ao lado do item já "preparado", e a linha do passo 0 no `docs/procedimento-de-corte.md`, com 100.373
  linhas (são 100.375, contadas em 12/10/2026).
- **O guia do servidor de teste não falava desta migração:** o `docs/ambiente-teste.md` diz agora que o `atualizar`
  recusa por ela desde o `2408c28`, e de novo pelo cabeçalho mudado só no comentário, e que o certo é `ACEITAR_ESQUEMA=sim`
  sem ALTER -- a base do teste recebe as colunas na próxima cópia exportada (a 83), e até lá o cupom segue a campanha.
- **Os débitos que esperavam "a P8"** -- a pergunta 83 no consolidado -- ganharam a nota: o **D289** e o **D374** seguem
  esperando a pergunta 125 (juntar as gêmeas apaga linha), e o **D368**, a rodada da 134, respondida em 11/10/2026.

**Provado:** o filtro do item com o caso novo (`Cupo|RegraDoItemDaComanda|ComandaApi|ColunasForaDoMapeamento`), 161
casos e 550 asserções verdes no MySQL e 161 e 550 no PostgreSQL, em série; e, depois dos comentários, os cinco arquivos
de teste dos cupons no front (119 casos), o `lint` e o `build`.


### D272 — A rotina dos cupons calcula uma carência de três dias e a joga fora — REGISTRADO em 28/09/2026 — CORRIGIDO em 11/10/2026

`CronJobService::cuponsValidarVencimento` monta um `$data` de "-3 dias" e o passa a
um método que não tem parâmetro (`CupomClienteRepository::selecionarCuponsAtivosVencidos`):
a intenção de dar três dias de carência nunca existiu no efeito. Só registrado -- a
rotina roda em produção e não é desta fatia.

#### CORRIGIDO em 11/10/2026

**A resposta do dono** (a 95 do lote, a opção (a)): *"Tirar a conta que não vale -- O cupom vale até a data que o
cliente vê, como hoje."*

**O conserto.** A rotina deixou de montar a data de três dias atrás e de passá-la a `selecionarCuponsAtivosVencidos`,
que nunca teve parâmetro; o comentário dos dois diz que o cupom vence no dia seguinte à validade, sem carência, como o
caixa e o aplicativo já o recusam. Para quem usa, nada muda.

**Provado:** `CupomRotinaDaNoiteTest::testOCupomVenceNoDiaSeguinteAValidadeSemCarencia`, nos dois bancos (o de ontem e o
de dois dias atrás vencem, o de hoje fica); o mutante que dá a carência de verdade (a consulta com "-3 dias") o deixa
vermelho, e o da encerrada cujo cupom venceu ontem junto.


### D273 — A lista dos emitidos, com o nome do cliente, abre para quem só lê — REGISTRADO em 28/09/2026

A API pede `ler` nos emitidos; o ERP pede `escrever`. **Medido: ninguém ativo tem ler
sem escrever**, então ninguém perde nada se a guarda subir. A tela nova só mostra os
emitidos a quem escreve. Trocar a guarda da API é uma palavra (a pergunta 5), e
exposição de dados é débito futuro por decisão da casa.


### D274 — A campanha com o produto desativado continua emitindo — REGISTRADO em 28/09/2026 — CORRIGIDO em 11/10/2026

A #45 (ativa, 100%, "nunca comprou" um produto desativado) emitiu 9.750 cupons,
nenhum usado, e até 15/08/2026 avisava clientes de um desconto em algo que o caixa
não vende; o aplicativo também mostra cupom de produto desativado. A tela nova
avisa. Desativar a #45, ou fazer a emissão pular a campanha de produto desativado,
ficou com o dono (a pergunta 6).

#### CORRIGIDO em 11/10/2026

**A resposta do dono** (a 96 do lote, a opção (a)): *"A campanha de produto desativado para de dar cupom -- O
aplicativo também deixa de mostrar o cupom dela. Vale para a #45 e para as próximas, e volta a dar se o produto for
reativado."*

**O conserto.** O produto é o **oferecido**, aquele em que o cupom dá desconto e que o caixa não vende: a emissão
(`CupomRepository::selecionarCuponsDiposniveis`) só escolhe campanha com ele à venda, e o aplicativo (as duas rotas, por
`selecionarCuponsAtivosCliente`) não mostra o cupom cujo produto -- o que ele guardou (o D271), ou o da campanha -- está
desativado. Nada é gravado na campanha: reativado o produto, ela volta a emitir, e o cupom volta a aparecer. A #45, cujo
produto da regra é o mesmo oferecido, para. Na tela nova, o selo dela é "parada", e o aviso diz que ela não emite e que
o aplicativo não mostra os cupons do produto, até ele voltar à venda.

**Provado:** o `CupomDeProdutoDesativadoTest`, nos dois bancos: desligado o oferecido, a campanha some da emissão e o
cupom some do aplicativo; religado, os dois voltam, sem o estado da campanha mudar. Os dois mutantes (a emissão e o
aplicativo sem o filtro) deixam cada um o seu caso vermelho; no front, o selo sem a parada.

**À parte, com o dono** (uma pergunta nova no lote, CUPONS): a campanha de "nunca comprou" com só o produto da REGRA
desativado, e o oferecido à venda, continua emitindo -- o cupom tem onde ser usado, e como ninguém mais compra o produto
da regra, todo cliente o ganha. Na cópia, nenhuma campanha ativa assim. O
`testSoOProdutoDaRegraDesativadoContinuaEmitindo` prende o comportamento de hoje.

**Na verificação (11/10/2026), o produto que o cupom GUARDOU ganhou caso.** No aplicativo, o filtro do produto
desativado só vale para o produto do dia porque vem depois da troca pelo produto guardado (o D271), e nenhum caso
cobria essa ordem: o de antes desligava o produto da própria campanha, num cupom que não guardou nada. Dois casos novos
no `CupomDeProdutoDesativadoTest`, nos bancos com as colunas: o cupom que guardou o Corte, numa campanha que passou a
oferecer a Pomada, à venda, some do aplicativo com o Corte desativado e volta com ele; e o avesso -- com o Corte à
venda e a Pomada desativada, o cupom aparece, e o caixa o aplica no Corte. O mutante que põe o filtro antes da troca
deixa os dois vermelhos.


### D275 — O resgate do cupom não confere a unidade — CORRIGIDO em 03/10/2026

`CupomClienteRepository::selecionarCupomProdutoCliente` casa o produto e o cliente, e
não a unidade. A fatia fechou o cupom de outra franquia pela ESCRITA -- o produto é
da unidade da campanha, e a unidade não muda (o D265) --; na cópia, 0 campanhas com
produto de outra unidade e 0 resgates cruzados. O recorte da leitura é do recurso do
PDV (o D138).

#### CORRIGIDO em 03/10/2026

**O conserto.** `selecionarCupomProdutoCliente` passou a exigir a unidade, e a casá-la com a da
campanha (`cup.unidade`); as duas chamadas a passam -- o `pesquisarCupom` do PDV, a unidade da
sessão; a queima no fechamento (`gerarCupons`), a unidade da venda, que é a mesma. Obrigatória, e
não opcional, para que um chamador novo não esqueça o recorte calado: o terceiro chamador, um teste
(`CupomTest`), apareceu justamente porque a chamada sem a unidade passou a dar erro.

**Não há cupom "de rede" a preservar:** a emissão já é por unidade
(`CupomRepository::selecionarCuponsDiposniveis` exige `cup.unidade`), e na cópia há 0 campanhas sem
unidade, 0 com produto de outra unidade e 0 cupons válidos assim. Para quem usa, nada muda.

**Provado:** no `CupomQueimadoTest`, 4 casos novos nos dois bancos, com a campanha de outra unidade e
o produto desta (escrita direto, como a história a deixaria): a consulta não a escolhe, o cupom da
unidade continua saindo ao lado dela, o PDV não dá o desconto, e o fechamento não a queima. Antes, os
quatro vermelhos. O mutante que troca o recorte por uma comparação sempre verdadeira deixa os quatro
vermelhos. Três entradas no `conferir-recorte-por-unidade.php`, cada uma falsificada.

**O controle do PDV, que faltava** (achado da verificação do conserto): o caso do PDV só conferia
`sucesso: false`, e um recorte que recusasse tudo -- zero, ou a unidade errada no lugar da da sessão
-- o deixava verde; o positivo do `pesquisarCupom` só existia no ensaio HTTP de cupons (o caso 44, no
MySQL de contrato). Entrou o quinto caso: com o cupom da outra unidade e o da unidade, o PDV dá o
desconto do da unidade (R$ 10,00, e não os R$ 30,00 da outra), com o motivo dele. Verde nos dois
bancos; o mutante que pesquisa na unidade zero o deixa vermelho nos dois -- e o caso antigo do PDV continua
verde com o mesmo mutante, que era a lacuna.


### D276 — A comanda nova emite cupom e nunca resgata — ABERTO em 28/09/2026, com o dono — PARCIAL em 11/10/2026: o outro desconto no item do cupom, no lote — PARCIAL em 16/10/2026 (as respostas 152, 154 e 155): o valor em várias unidades, no lote — CORRIGIDO em 16/10/2026: a 234 (a), o valor em várias unidades segue recusado

**Achado pelo crítico do desenho da fatia dos cupons.** A comanda nova fecha a venda
por `finalizar()` e por isso EMITE cupom (`ComandasController`, pelo mesmo
`gerarCupons`); mas nada na tela nova chama o `pesquisarCupom`: o cliente ganha o
cupom na tela nova e **só o usa se o caixa abrir o PDV antigo**. E o prefixo
"CUPOM VIP:" digitado à mão no motivo de um item da comanda nova QUEIMA um cupom do
par produto e cliente.

**Não consertei, e é pergunta:** resgatar na comanda nova é uma fatia da comanda, e
reservar o prefixo é uma decisão de caixa. As frases da tela nova dos cupons não
prometem desconto automático no caixa novo.

#### PARCIAL em 11/10/2026: o outro desconto no item do cupom, no lote

**A resposta do dono** (a 97 do lote, a opção (a)): *"A comanda nova aplica o cupom e reserva o 'CUPOM VIP:' -- O
desconto entra quando o caixa lança o produto do cupom para o cliente, como no PDV antigo, e o motivo digitado não pode
ter 'CUPOM VIP:'. É uma etapa do trabalho da comanda nova."*

**O conserto, no servidor** (`/adm/api/comandas`). Lançar (`POST .../itens`) o produto de um cupom válido do cliente,
nesta unidade, põe na linha o que o PDV antigo preenche: a porcentagem que o cupom guardou (o D271) e o motivo com a
marca, o nome e o número da campanha (`RegraDoCupom::motivoNoPdv`); o fechamento o gasta, pelo mesmo `gerarCupons`.
**Um cupom por linha:** a linha que já leva a marca reserva o primeiro cupom do seu produto, na ordem em que o fechamento
os gasta, e a segunda linha do mesmo produto só leva cupom se o cliente tiver outro -- sem isso, ela ganhava de novo o
desconto de um cupom só. O motivo digitado com "CUPOM VIP:" é recusado com 422 no lançamento e no ajuste, com desconto e
sem (`RegraDoItemDaComanda`, pela mesma pergunta da queima, `RegraDoCupom::temAMarca`); e a linha do cupom não se ajusta
(409, como a da sessão de pacote: tira-se e lança-se de novo, e o cupom volta). A comanda devolve `cupons` (os que ela
ainda pode usar, um por produto) e o item, `cupom`.

**Na tela nova** (`LancarItem.tsx`, `DetalheDaComanda.tsx`, `DescontoDaLinha.tsx`, `ajusteDaLinha.ts` e o
`cupomDaComanda.ts`, novo): na busca, a opção e o produto escolhido dizem o cupom ("Cupom do cliente: 20% em Corte
(campanha «…»). Entra sozinho ao lançar, no lugar de outro desconto."), o campo de desconto some, e a linha mostra o
valor com o cupom; no catálogo, o botão diz "cupom 20%"; o aviso do lançamento diz o desconto do cupom; a linha diz
"cupom do cliente" e não tem os botões de ajuste; e o motivo com a marca é recusado antes de pedir, nos dois
formulários.

**Provado:** o `CupomNaComandaNovaTest`, nos dois bancos (12 casos: a comanda oferece o cupom; o produto entra com os 20%
e a marca, e a comanda não o oferece mais; o sem cupom entra pelo preço; a segunda linha sai pelo preço; com dois
cupons, cada linha leva o seu, o que vence antes primeiro; o de outra unidade não entra; o fechamento gasta o da linha;
o desconto guardado vale; e o outro desconto, a marca digitada no lançamento e no ajuste, e o ajuste da linha do cupom
são recusados), e os casos novos da regra (`RegraDoItemDaComandaTest`, `RegraDoCupomTest`); no front, 6 casos novos na
comanda e 3 no `cupomDaComanda.teste.ts`. Os mutantes -- a comanda sem aplicar, sem reservar, a marca digitada passando,
o outro desconto passando, a linha do cupom se ajustando, o recurso sem dizer a linha, a comanda sem o recorte da
unidade --, e no front a busca, a opção e o catálogo sem o cupom, a linha ajustável, as duas recusas da marca e os dois
avisos arrancados, deixaram cada um o seu caso vermelho. Com as fontes da tela do `HEAD`, os 5 casos novos que dependem
do conserto ficam vermelhos, e o de controle, verde. O ensaio HTTP do fluxo da comanda (`ensaiar-fluxo-adm-api.php`)
segue verde nos dois bancos.

**Falta, e é do dono** (a pergunta nova no lote, CUPONS): por ora o item com cupom **não leva outro desconto nem outro
motivo** -- o servidor recusa com 422, e a tela não oferece o campo. No PDV antigo o caixa podia trocar o desconto
preenchido. Somar os dois, ficar com o maior ou deixar o cupom para outra vez é escolha dele. **E o convênio**, que é a
pergunta 90 (o D277), sem resposta: no serviço do cliente de convênio o serviço troca o motivo pelo nome do convênio e
soma os dois descontos; a linha perde a marca, o cupom não se gasta e não fica reservado -- a linha seguinte do mesmo
serviço o leva de novo, como o PDV antigo, que o preenche a cada lançamento. A comanda nova faz o mesmo que o PDV antigo
até a resposta.

#### A verificação, em 11/10/2026: o toque no catálogo, e mais duas escolhas do caixa no lote

**O toque no catálogo somava pelo preço** (consertado). O botão do catálogo diz "cupom 20%", e o toque, achando na
comanda uma linha do mesmo produto e da mesma pessoa sem desconto -- a que a agenda põe (`VendaService::comandaAbrirAgenda`
não aplica cupom, nem no PDV antigo), ou a que sobrou depois de tirar a do cupom --, somava nela pelo `PUT`, que não
aplica cupom: "Corte agora com 2 unidades.", pelo preço cheio, e o cupom ficava sem uso. Agora, com cupom do cliente no
produto, o toque não soma: lança uma linha nova (`POST`), e o servidor põe o cupom nela -- a regra da linha com
desconto, que também não se soma (`LancarItem.tsx`, `LancarPeloCatalogo`). **Provado:** o caso novo de
`ComandasLigada.teste.tsx` (a comanda com a linha do Corte pelo preço e o cupom do Corte: o toque pede `POST .../itens`,
e o aviso diz o cupom); o mutante que devolve a soma o deixa vermelho.

**Duas escolhas novas, com o dono** (o lote, CUPONS, a verificação):

- **A linha de mais de uma unidade.** O cupom é aplicado sobre a linha, como no PDV antigo: lançado com quantidade 3, o
  produto leva os 20% nas três, e o fechamento gasta um cupom -- o de 100% na Pomada dá as três de graça. Na cópia, das
  598 linhas com a marca, 2 têm mais de uma unidade (8 com 32,00 de 160,00; 2 com 51,00 de 170,00), com o desconto
  sobre todas. A resposta 97 diz "como no PDV antigo" e não fala da quantidade; por ora fica assim, e o
  `testALinhaComMaisDeUmaUnidadeLevaOCupomEmTodasEGastaUm` prende o de hoje, para a resposta mudar de propósito.
- **A linha que vem da agenda.** Nunca leva o cupom, como no PDV antigo: para usá-lo, o caixa tira a linha e lança de
  novo -- e o toque no catálogo, agora, lança a linha do cupom ao lado da da agenda, que o caixa precisa tirar. A
  resposta 97 fala do produto que o caixa lança, e o serviço marcado é o caso mais comum.

**Falta, então, com o dono:** o outro desconto no item do cupom, a linha de mais de uma unidade e a linha da agenda (as
três no lote, CUPONS), e o convênio (a pergunta 90, o D277).

#### PARCIAL em 16/10/2026 -- as respostas 152 (b), 154 (c) e 155 (b) (a fatia 5d, RESPOSTAS-DO-CUPOM-E-DO-CONVENIO)

> **Era CORRIGIDO na primeira escrita desta seção, na mesma rodada, e a verificação da fatia o desfez:** o desconto em
> valor com mais de uma unidade no produto do cupom é recusado, e isso é uma escolha que nenhuma resposta deu -- foi ao
> lote (abaixo). E duas frases desta seção não valiam: "nenhuma das três respostas faz o cliente pagar mais" (o pacote e a
> troca, na seção da verificação, mais abaixo) e a conta dos casos.

**As três respostas do dono** (a página do lote, conferidas em `respostas-corte/respostas/*.json`): a **152 (b)** -- *"Fica
o maior: a comanda aplica o maior dos dois, e o cupom só se gasta se o dele for o maior"*; a **154 (c)** -- *"A linha do
cupom tem uma unidade: lançado com 3, o produto entra em duas linhas: uma de 1 com o cupom, e outra de 2 pelo preço"*; a
**155 (b)** -- *"A comanda nova oferece o cupom na linha da agenda: a linha mostra 'usar o cupom do cliente (20%)', e o
toque a troca pela linha com o cupom. Só a tela nova e uma ação no servidor"*. O convênio (a 187 (c)) é o D277. Pelo pedido
do dono: *"espere acabar e continue de forma autonoma"*.

- **O outro desconto no item do cupom (a 152 (b))** -- `Adm\ComandasController::adicionarItemAction` deixou de recusar com
  422: o desconto digitado vai ao serviço junto com o cupom (a chave nova `cupom` do pedido, à parte do desconto do PDV), e
  `VendaProdutoService::calcularLinha` compara os dois pela regra pura `RegraDoItemDaComanda::oCupomVence` -- o desconto do
  cupom, sobre o preço da linha (`descontoDoCupom`), contra tudo o que a linha leva sem ele. Vencendo, a linha leva só o
  cupom, com a marca, e o fechamento o gasta; perdendo ou empatando, a linha fica com o digitado e o motivo dele, e o cupom
  fica com o cliente e continua oferecido. No item do convênio o cupom enfrenta o convênio com o do balcão somados (a 177
  (c)): fica o maior entre o cupom e os dois -- assim nenhuma das três respostas faz o cliente pagar mais do que pagaria
  com uma só. (Na primeira escrita isso não valia com sessão livre do pacote, nem na troca da linha cujo convênio o caixa
  já não dá: as duas correções estão na seção da verificação, abaixo.)
- **A linha do cupom tem uma unidade (a 154 (c))** -- o lançamento do produto do cupom com quantidade 3 grava a linha de 1
  com o cupom e, se ele venceu, outra de 2 pelo preço (com o convênio e o desconto digitado, que não perderam para cupom
  nenhum); se o cupom perdeu, a linha volta a ser uma só, com as 3 (pela conta do ajuste, `alterar`). O desconto digitado
  em porcentagem vai às duas linhas; **em valor, com mais de uma unidade, é recusado** (422, *"Este item leva o cupom do
  cliente, de 20%, numa linha de uma unidade: lance o desconto em porcentagem, ou uma unidade de cada vez."*): os 30,00 das
  três unidades não dizem quanto vai à do cupom, e repeti-los daria o desconto duas vezes. O PDV antigo fica como está (a
  recomendação da 154): lá o cupom vale na linha inteira.
- **A linha da agenda troca pela do cupom (a 155 (b))** -- `POST /adm/api/comandas/:id/itens/:item/cupom`
  (`usarCupomAction`, a rota `adm-api-comandas/uma/itens/um/cupom/usar`), com as guardas do ajuste e na mesma ordem
  (sessão, `comandasNova/escrever`, a comanda da unidade, a comanda aberta, o item dela). A linha é recalculada como se
  entrasse agora com o cupom (`alterar` com a chave `cupom`), com a pessoa, o produto e o preço dela; com mais de uma
  unidade, fica com uma e as outras vão a uma linha à parte. A linha da agenda não tem marca no banco
  (`VendaService::comandaAbrirAgenda` grava o serviço com uma unidade, pelo preço ou com o convênio): a regra é a da linha
  (`RegraDoItemDaComanda::aLinhaTrocaPeloCupom`) -- a que não tem desconto do balcão, com um cupom do cliente que a
  comanda ainda pode usar no produto. A comanda marca cada linha (`podeUsarOCupom`, em `ComandaJson`, pela mesma conta), e
  a ação recusa com 409 a sessão do pacote, a linha que já leva o cupom, o produto sem cupom, e a linha com desconto do
  balcão (*"... tem um desconto do balcão. Para usar o cupom, tire o item e lance de novo: fica o maior dos dois."*). E,
  desde a verificação, a troca que não melhora a linha (abaixo).
- **Na tela nova** (`LancarItem.tsx`, `DetalheDaComanda.tsx`, `cupomDaComanda.ts`, `api/comandas.ts`): o produto do cupom
  mostra o campo do desconto (até aqui escondido), a nota diz *"Entra sozinho ao lançar, numa unidade; com outro desconto,
  fica o maior, e o cupom só se gasta se for ele."* e, com o desconto digitado, qual dos dois fica; a prévia da linha é a
  do servidor (`previaComOCupom`: o cupom numa unidade; desde a verificação, com o convênio do cliente, abaixo); o desconto em
  valor com mais de uma unidade é recusado antes de pedir, com a frase do servidor; o aviso do lançamento lê as linhas
  novas (a linha à parte, e o cupom que perdeu e fica com o cliente); e a linha marcada mostra o botão *"usar o cupom do
  cliente (20%)"*, nos dois modos, com o aviso *"Corte agora com o cupom do cliente: − R$ 9,00."* e o foco nele.

**O que ficou de fora, e por quê:**

- **o desconto em valor com mais de uma unidade** no produto do cupom é recusado, e não repartido: repartir é desenho que a
  resposta não deu, e a recusa diz o que fazer, sem gravar nada. **Foi ao lote** na verificação (*"o desconto em reais no
  produto do cupom lançado com mais de uma unidade"*, TELAS-RESTANTES [vendas], 16/10/2026): **(a)** recusar, como está;
  **(b)** o valor vale por unidade, como a porcentagem, e o cupom enfrenta a parte da unidade dele; **(c)** fica o maior na
  linha inteira, o cupom contra o valor todo. A recomendação é a (a). Por isso o PARCIAL;
- **a linha da agenda com desconto do balcão** não troca (409): trocar tiraria o desconto digitado, e a 152 (b) manda ficar
  com o maior -- o lançamento de novo compara os dois;
- **o `PUT` da linha pelo preço** continua não aplicando cupom (o ajuste da linha é a conta sem ele): quem põe o cupom numa
  linha que já está na comanda é a troca;
- **o PDV antigo** fica como estava nas três: lá o cupom só preenche o campo (o caixa pode trocar o valor), vale na linha
  inteira, e a agenda não o aplica.

**Provado** nos dois bancos, em série: `CupomNaComandaNovaTest` (23 casos; eram 13 -- dois mudaram de sentido, o da linha de
três unidades, agora a linha à parte, e o da recusa do outro desconto, agora o digitado menor, que fica com o cupom; e
entraram dez: a 152 com o digitado maior e no empate; a 154 com o digitado em porcentagem, com o digitado que vence e a
linha uma só, e com a recusa do valor; a 155 com a troca, a troca de duas unidades, as recusas -- o balcão, o produto sem
cupom, o item de fora, a linha que já leva o cupom --, a comanda concluída, e o privilégio e a unidade. A primeira escrita
dizia "entraram doze", contando os dois que mudaram) e `RegraDoItemDaComandaTest` (5 casos novos). No front,
`ComandasLigada.teste.tsx` (8 casos novos ou reescritos no bloco do cupom) e `cupomDaComanda.teste.ts` (10; eram 3), também
com o amplificador de efeitos (`AMPLIFICAR_EFEITOS_MS=50`). O ajudante `abrir` do `ComandasLigada.teste.tsx` passou a
esperar o "abrindo a comanda…" sair: o título já está no carregando (o CM-5), e com o amplificador dois ou três casos do
arquivo caíam a cada rodada, também no `HEAD` (medido nas duas versões, duas rodadas cada); restam, com ele, dois casos
de antes que caem às vezes ("as pílulas do catálogo andam pelas setas", "abrir outra comanda tira a frase da
encerrada"), também no `HEAD`, fora desta fatia.

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-5d.py` e `-front.py`, no rascunho) | vermelho |
|---|---|
| a linha do cupom com todas as unidades | 2, no MySQL: a linha à parte, sem e com o digitado em porcentagem |
| sem voltar a uma linha só quando o digitado vence | 1: a linha que fica uma só |
| o desconto em valor com várias unidades passa | 1: a recusa |
| a troca sem conferir a linha | 1: as recusas (o balcão) |
| a troca sem partir a linha | 1: a de duas unidades |
| a troca com a quantidade toda no cupom | 1: a de duas unidades |
| a troca sem o privilégio | 1: o privilégio e a unidade |
| a troca fora da comanda aberta | 1: a comanda concluída (pela frase: a lista dos cupons também vem vazia nela, e o 409 sozinho sobrevivia) |
| a troca sem o recorte da unidade | 1: o privilégio e a unidade |
| a marca da troca sem o cupom do produto | 3: as recusas, o cupom que não se oferece no convênio e a troca que perde para ele |
| a troca aceita o desconto do balcão | 2: as recusas e a regra |
| a tela não manda o digitado com o cupom | 2, no vitest: os dois do outro desconto |
| a prévia com o cupom em todas as unidades | 3: a linha à parte e duas da regra |
| a prévia, no empate, com o cupom | 1: a regra |
| a tela deixa passar o valor com várias unidades | 2: a recusa na tela e na regra |
| sem o botão da troca | 2: a troca e a recusa na linha |
| o botão sem a marca do servidor | 1: a linha sem a marca |
| o aviso sem a comanda de antes | 1: as linhas novas |
| a troca sem levar o foco ao aviso | 1: a troca |

#### A verificação da fatia (16/10/2026): o pacote, a troca que piorava a linha, a agenda e a conta dos casos

A verificação em três lentes confirmou, sobre esta seção e a do D277, os achados abaixo. Todos foram consertados, menos o
que é do dono (o valor em várias unidades, acima, e o nome longo do convênio, no D277), que foi ao lote.

- **A linha de uma unidade do cupom caía na regra das sessões livres do pacote** (`VendaProdutoService::calcularLinha` só
  dá o convênio ao lançamento que não cabe nelas, e a tela nova nunca usa o pacote). O cliente de convênio com uma sessão
  livre do corte, lançado com 2 e o cupom de 10%: a linha do cupom (1 >= 1) saía sem convênio, o cupom vencia o zero
  (80,91), e a outra unidade saía pelo preço (89,90) -- 170,81, contra os 143,84 da linha de 2 só com o convênio. Com 3
  livres e 4 cortes, 350,61 contra 287,68. A troca de uma linha de 2 fazia o mesmo. **Agora** as duas linhas que saem de um
  lançamento (e da troca) perguntam as sessões livres pela quantidade lançada (`calcularLinha`, o `$quantidadeDoLancamento`;
  `convenioDoLancamento` pela mesma conta): levam o convênio que a linha única levaria. Depois de uma sessão do pacote (o
  `pacoteAtivo` do pedido montado à mão), a linha à parte pergunta pela dela, como antes.
- **A troca pelo cupom comparava com o convênio de agora, e não com o desconto que a linha tem.** A linha que a agenda gravou
  com o convênio de 20% num dia em que a unidade o dava, ainda aberta num dia em que não dá: o cupom de 10% era oferecido (o
  caixa não daria convênio nenhum), a linha vinha marcada, e o toque a deixava a 80,91 -- o cliente pagava 8,99 a mais, e o
  cupom se gastava. **Agora** a troca só passa quando o cupom dá mais que o desconto que a linha já tem e que o convênio da
  troca (`RegraDoItemDaComanda::oCupomMelhoraALinha`), e, com mais de uma unidade, quando as outras não perdem o convênio
  (`asOutrasUnidadesGuardamODesconto`). A ação e a marca leem a mesma conta (`Adm\ComandasController::recusaDaTroca`, que o
  `recurso` passa ao `ComandaJson` como as linhas que trocam). As recusas novas, 409: *"O cupom do cliente, de 10%, não dá
  mais que o desconto de Corte Teste: a linha fica como está, e o cupom fica com o cliente."* e *"As outras unidades de
  Corte Teste perderiam o convênio, que o caixa não dá agora: a linha fica como está."* Na cópia, 7 unidades limitam o
  convênio por dia da semana, e havia 1 comanda aberta de um dia anterior.
- **A 155 (b) não tinha prova numa comanda aberta pela agenda** (os casos montavam a "linha da agenda" pelo lançamento da
  API). `D276LinhaDaAgendaTrocaPeloCupomTest` (novo, 5 casos) abre a comanda por `VendaService::comandaAbrirAgenda`, como o
  "Abrir comanda" do ERP: a linha pelo preço (desconto nulo) troca pelo cupom; a do convênio troca pelo que vence e não
  pelo que perde; a do convênio sem o motivo (o D363, a gravada antes da resposta 90) troca, com os dois no motivo; e a do
  convênio que o caixa já não dá não troca pelo cupom menor.
- **A tela nova dizia "Fica o cupom" no cliente de convênio**, e o servidor ficava com o convênio e o balcão (o cupom de 30%
  contra o convênio de 20% e 10,00 de cortesia: a tela mostrava a linha a 62,93, e o servidor gravava 61,92). A prévia não
  conhecia o convênio. **Agora** a comanda manda, com cada cupom, o convênio que ele enfrenta (`convenio`: a porcentagem, e a
  partir de quantas unidades -- com sessões livres do pacote, o lançamento de até tantas sai sem ele;
  `VendaProdutoService::convenioDoLancamentoAPartirDe`), e a prévia (`cupomDaComanda.ts::previaComOCupom`) faz a conta do
  servidor: o cupom contra o convênio com o digitado sobre o que ele deixou. A nota diz quem fica também sem desconto
  digitado (*"Fica o convênio do cliente, que dá mais (ou o mesmo): o cupom fica com o cliente."*, *"Ficam o convênio do
  cliente e o seu desconto, ..."*), e o aviso depois de lançar também (*"Corte lançado, com o convênio do cliente e o seu
  desconto, que dão mais que o cupom: o cupom fica com o cliente."*). Os casos novos do vitest provam a 187 (c) na tela: o
  cupom que perde para os dois, o que vence com os dois motivos na linha, e o da sessão livre.
- **A conta dos casos** desta seção dizia "entraram doze" (eram dez, e dois mudaram de sentido): corrigida acima.

**Provado** nos dois bancos, em série: `D276LinhaDaAgendaTrocaPeloCupomTest` (5, novo), `D277CupomNaoSomaAoConvenioTest` (de
13 para 23: os dois do POST do PDV antigo e o do nome longo pela API, no D277, abaixo; os quatro do pacote e os três da
troca), `RegraDoItemDaComandaTest` (+1, a troca que
melhora, e o do nome longo reescrito) e `CupomNaComandaNovaTest` (o `convenio` do cupom na comanda). Com os vizinhos (o item,
o convênio, os cupons, a comanda, o pacote, o D212, o D237, o D314, o D321, o D363/D465, o D384, o D426, o D435, o D450, o
D452, o D453 e o D477): 475 casos no MySQL e 475 no PostgreSQL, saída 0. No front, `cupomDaComanda.teste.ts` (+5) e
`ComandasLigada.teste.tsx` (+3), também com o amplificador de efeitos (três rodadas, os casos novos verdes; os dois de antes
seguem caindo às vezes, como no `HEAD`).

| mutante da verificação (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-5d-verif.py` e `-front.py`, no rascunho) | vermelho |
|---|---|
| as sessões livres pela quantidade de cada linha (como antes) | 3, no MySQL: o cupom que perde e o que vence com a sessão livre, e a troca que vence |
| o lançamento sem a quantidade na linha do cupom | 2: os dois do lançamento com a sessão livre |
| o lançamento sem a quantidade na linha à parte | 1: o cupom que vence com a sessão livre |
| a troca sem a quantidade | 1: a troca que vence com a sessão livre |
| a troca sem comparar com a linha | 3: a da agenda e a da API com o convênio que o caixa já não dá, e a de duas unidades com a sessão livre |
| a troca compara só com o convênio de agora | 3: os dois do convênio que o caixa já não dá, e a regra |
| a troca tira o convênio das outras unidades | 1: a de duas unidades com o convênio que o caixa já não dá |
| a marca da troca sem a conta da ação | 4: as linhas que não trocam (a agenda, a sessão livre, e as duas do convênio que o caixa já não dá) |
| o cupom sem o convênio na resposta | 2: o que vence e o da sessão livre |
| a prévia sem o convênio | 5, no vitest: três da regra e dois da tela |
| a prévia sem o "a partir de" | 2: a da sessão livre, na regra e na tela |
| o desconto do balcão sobre o preço, e não sobre o que o convênio deixou | 1: a regra |
| a frase de quem fica sem o convênio | 3: a regra e dois da tela |
| a nota sem o convênio | 2: a regra e a tela do cupom que perde |
| o aviso sem o convênio | 1: a regra |
| o aviso sem o convênio com o balcão | 2: a regra e a tela do cupom que perde |
| a tela só diz quem fica com o digitado (a condição de antes) | 2: a tela do cupom que perde e a da sessão livre |


#### CORRIGIDO em 16/10/2026 -- a resposta 234 (a) (a fatia 5e, RESPOSTAS-231-E-232)

**A resposta do dono** (a página do lote, 16/10/2026), igual à recomendação: a **234 (a)** -- *"Recusar, como está: o caixa
lança o desconto em porcentagem, ou uma unidade de cada vez; nada sai sem o caixa ver, e a regra do cupom é uma só."* O
desconto em valor com mais de uma unidade no produto do cupom continua recusado com 422, com a frase do que fazer, nas duas
pontas (`Adm\ComandasController::adicionarItemAction` e `cupomDaComanda.ts::descontoEmValorComOCupom`); o código não muda,
só o comentário que dizia que a escolha estava no lote. Era a única parte do PARCIAL de 16/10/2026: o resto da seção acima
-- a linha da agenda com desconto do balcão que não troca, o `PUT` pelo preço que não aplica cupom, e o PDV antigo como
estava -- são limites do desenho, ditos ali, e não defeito deste débito. Os casos que a prendem seguem verdes
(`CupomNaComandaNovaTest`, o da recusa em valor).

### D277 — O convênio apaga o motivo do cupom, e o cupom não queima — REGISTRADO em 28/09/2026 — PARCIAL em 16/10/2026 (a resposta 187): o cupom que perde para o convênio foi ao lote — CORRIGIDO em 16/10/2026: a 233 (a) e a 235 (a), como estava

> **Lido em 11/10/2026 (o D276):** a comanda nova passou a aplicar o cupom como o PDV antigo, e herda isto do mesmo
> `VendaProdutoService`: no serviço do cliente de convênio a linha perde a marca, o cupom não se gasta e, como a linha
> sem a marca não o reserva, a linha seguinte do mesmo serviço o leva de novo -- o que o PDV antigo também faz, ao
> preenchê-lo a cada lançamento. Segue com a pergunta 90 do lote.

> **Lido em 14/10/2026 (o D477, a resposta 177):** o item do convênio com desconto do balcão passou a gravar os dois
> motivos, "Convênio X; <o digitado>" -- menos o motivo com a marca do cupom, que segue trocado pelo do convênio, como
> aqui. Levar a marca para o motivo faria o fechamento gastar o cupom, a linha deixar de se ajustar e o lançamento
> seguinte deixar de levá-lo de novo: é escolha do dono, que a 90 deixou como estava e a 177 não citou. Foi ao lote
> (CADASTRO-E-CONVENIO). **E o "+" da tela nova tirava esse cupom calado** (a verificação do item): sem a marca, a linha
> não era "a do cupom" para a tela nem para o `PUT`, o "+" mandava só a quantidade e o servidor recalculava só o convênio
> -- os 7,19 do cupom de 10% no corte de 89,90 sumiam. Desde então a comanda marca essa linha pelo valor (o desconto
> maior que a parte do convênio, `descontoDoBalcaoNoConvenio`), e o "+" e o "−" dela recusam com a frase da 177; o cupom
> continua sem queimar e valendo, e a pergunta do lote diz isso.

**Achado pelo crítico do desenho.** No item de serviço de cliente conveniado, o
`VendaProdutoService` troca o motivo por "Convênio ..." e soma o desconto do PDV -- o
do cupom -- por cima. Sem o "CUPOM VIP:" no motivo, a queima não acontece: o cliente
acumula o convênio e o cupom, e o cupom continua válido. Na foto, 0 cupons válidos
nessa situação; 8.148 emitidos na história para clientes conveniados em serviço. Só
registrado -- a fatia dos convênios cortou o motivo (o D253), e esta não mexe no item
da venda.

#### PARCIAL em 16/10/2026 -- a resposta 187 (c): o cupom não soma ao convênio (a fatia 5d, RESPOSTAS-DO-CUPOM-E-DO-CONVENIO)

**A resposta do dono** (a 187, opção "c", e NÃO a recomendada "b"): *"A (b), e o cupom não soma ao convênio -- o item do
convênio leva só o maior dos dois descontos: muda o que o cliente paga, e pede regra nova nas duas portas."* A (b) era
*"o motivo leva os dois também no cupom -- 'Convênio X; CUPOM VIP: <a campanha> | #N', cortado em 100: o fechamento gasta o
cupom, e a linha seguinte não o leva de novo"*. Pelo pedido do dono: *"espere acabar e continue de forma autonoma"*.

**O conserto, nas duas portas** (o PDV antigo e a API da tela nova chegam ao mesmo `VendaProdutoService::calcularLinha`):

- **o cupom enfrenta o convênio, e fica o maior** (`RegraDoItemDaComanda::oCupomVence`): o desconto do cupom sobre o
  PREÇO da linha (`descontoDoCupom`; até esta data a porcentagem incidia sobre o que o convênio deixava) contra o do
  convênio -- e, na comanda nova, contra o convênio com o desconto do balcão somados (a 177 (c) e a 152 (b), ver o
  D276). O corte de 89,90 com o convênio de 20% e o cupom de 10%: o item fica com o convênio, a 71,92 (até esta data,
  64,73, os dois somados); com o de 30%: o cupom sozinho, a 62,93;
- **o cupom que vence leva os dois no motivo** (`motivoDoConvenioComOCupom`): "Convênio Convenio Teste; CUPOM VIP: <a
  campanha> | #N", cortado em 100 -- o fechamento o gasta pela marca, e a comanda nova o reserva (a linha seguinte do mesmo
  serviço não o leva de novo). A linha é a do cupom, e não a do balcão (`temODescontoDoBalcaoNoConvenio` responde não com a
  marca): não se ajusta (o `PUT` dá 409), e a tela nova a mostra como "cupom do cliente";
- **o cupom que perde, ou empata, fica de fora e com o cliente**: o item fica com o convênio, sem a marca, e o cupom não se
  gasta -- a regra da 152 (b), *"o cupom só se gasta se o dele for o maior"*. A opção (c) não disse o que fazer com ele, e a
  (b) que ela inclui diz que *"o fechamento gasta o cupom"*: **foi ao lote** (abaixo);
- **o nome do convênio que comeria a marca** (80 caracteres ou mais, como a opção avisava): **corta-se o nome, e não a
  marca** -- o motivo do cupom fica inteiro, "Convênio <o nome cortado>; CUPOM VIP: <a campanha> | #N", o cupom maior
  entra e o fechamento o gasta. A primeira escrita desta seção deixava o cupom de fora (o motivo nulo): ele entraria sem se
  gastar, mas, de fora, o item ficava com o convênio MENOR, contra a letra da (c), "só o maior dos dois" -- e a escolha não
  tinha ido ao lote. A verificação da fatia trocou pela saída que não perde o cupom nem o desconto (o nome do convênio já
  é cortado assim no `motivoDoConvenio`, com 92 ou mais), e a pergunta **foi ao lote** (abaixo). Na cópia, pela medida do
  D477 (14/10/2026), só o convênio ativo de 100 caracteres passa de 79, sem cliente ativo nele;
- **no PDV antigo** o campo é um só: com a marca do cupom no motivo, o desconto dele é o cupom (o PDV o preenche ao escolher
  o produto, e o caixa pode trocar o valor e a forma: em reais, o valor enfrenta o convênio do mesmo jeito). Fora do
  serviço, sem convênio, o PDV antigo segue como sempre;
- **na comanda nova** só se oferece o cupom que vence o convênio (`cuponsDisponiveis`, `oCupomEntraNaLinha`, pela mesma
  pergunta do lançamento, `VendaProdutoService::convenioQueOCaixaDa`, que saiu de dentro do `calcularLinha` sem mudar uma
  operação): o que perde não aparece na busca, no catálogo, nem no "usar o cupom" da linha da agenda (a 155 (b)), que com o
  convênio troca a linha a 71,92 pela do cupom a 62,93. É o PRIMEIRO cupom do produto, na ordem da queima, que se compara
  (o D72): o seguinte não entra no lugar dele, porque o fechamento gastaria o primeiro. Desde a verificação, a comanda manda
  com cada cupom o convênio que ele enfrenta, e a prévia da tela nova o conta (ver o D276);
- **o PDV antigo não lê o cupom do POST** (a verificação da fatia): a primeira escrita lia a chave `cupom` do pedido, e o PDV
  antigo entrega ao serviço o POST cru -- `cupom[desconto]=100` com o motivo vazio zerava o item sem motivo (o que o D212
  recusa pelo campo do desconto), e o motivo de 150 caracteres derrubava o `flush`. O serviço recebe o cupom ao lado do
  pedido (`insert`, a chave `cupom` do `$data`; `alterar`, um argumento), e só a API da tela nova o monta.

**Fica, e é do dono -- foi ao lote** (*"o cupom que perde para o convênio: fica com o cliente, ou se gasta mesmo sem ter
dado desconto?"*, TELAS-RESTANTES [vendas], 16/10/2026): **(a)** fica com o cliente, como na 152 (b) -- o que está feito;
**(b)** gasta-se mesmo assim, a letra da (b) da 187. A recomendação é a (a). E, da verificação, *"o nome longo do convênio
no item em que o cupom vence"* (TELAS-RESTANTES [vendas], 16/10/2026): **(a)** corta-se o nome do convênio, e o cupom
entra inteiro -- o que está feito; **(b)** o cupom não entra, e o item fica com o convênio menor (a primeira escrita);
**(c)** corta-se o fim, como a opção da 187 avisava, e o cupom entra sem se gastar. A recomendação é a (a).

**O que mais fica de fora:**

- **as linhas gravadas antes**: as do PDV antigo e da comanda nova com o cupom somado ao convênio e o motivo do convênio
  seguem como estão; numa comanda aberta, a tela nova as marca pelo valor (o D477, `descontoDoBalcaoNoConvenio`), e o "+"
  delas recusa;
- **o cliente de convênio com sessão livre do pacote no serviço**: o lançamento sem usar o pacote (o "Não" do PDV antigo;
  na tela nova, sempre -- ela não tem o "Usar pacote", o D453) de até tantas unidades quantas as sessões livres sai pelo
  preço, sem a sessão e sem o convênio (`calcularLinha` só dá o convênio ao item que não cabe nas sessões livres); o cupom
  enfrenta zero e entra. A comanda nova pergunta o mesmo antes de oferecê-lo (`VendaProdutoService::convenioDoLancamento`).
  Que o "Não" tire também o convênio é de antes, e não mudou aqui. O que a primeira escrita desta fatia piorava -- a linha
  de uma unidade do cupom (a 154 (c)) caía nessa regra e tirava o convênio das duas linhas -- foi consertado na verificação
  (as duas linhas perguntam pela quantidade lançada; ver o D276).

**Para os relatórios de descontos (as fatias 50 e 51, `spec-descontos.md`, a nota antes da D1)**: o motivo
"Convênio X; CUPOM VIP: ..." é a linha em que o CUPOM venceu -- o desconto inteiro é do cupom, e não do convênio; o
"Convênio X; <o digitado>" (a 177 (c)) é o convênio com o desconto do balcão somados. A regra da origem da D1 (o prefixo
"CUPOM VIP: " primeiro, depois o "Convênio ") chamaria a primeira de convênio: ela pede a marca em qualquer lugar do motivo
(`RegraDoCupom::temAMarca`, a pergunta da queima) antes do prefixo do convênio.

**Provado** nos dois bancos, em série: `D277CupomNaoSomaAoConvenioTest` (novo, 13 casos na primeira escrita, 23 depois da
verificação -- pelo PDV antigo, o cupom menor
fica de fora e não se gasta, o maior entra sozinho com os dois motivos e se gasta, o empate fica com o convênio, o cupom em
reais também enfrenta o convênio, o nome de 80 caracteres é cortado e o cupom maior entra (desde a verificação; era "fica
com o convênio"), o cupom no POST não entra nem derruba o pedido (os dois da verificação), e fora do serviço o cupom segue
como sempre; pela API, o que perde não se oferece e não se gasta, o que vence entra sozinho, com a linha do cupom e não a do
balcão, o cupom perde para o convênio com o balcão somados e ganha deles com 40%, a linha do convênio troca pelo cupom que
vence e não pelo que perde, e o cliente com sessão livre do pacote vê o cupom que o lançamento dá). `D477DescontoAteOValorDoItemTest`:
os dois casos do cupom no convênio mudaram de sentido (o cupom de 10% fica de fora, a 71,92, e a linha não vem marcada).

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-5d.py`, no rascunho da fatia) | vermelho, no MySQL |
|---|---|
| o cupom sempre vence | 11: o cupom menor e o empate do PDV antigo, o que não se oferece, o que perde para o balcão, a troca que perde, os dois do D477, os três da 152 e da 154 em que o digitado fica, e a regra |
| no empate, o cupom vence | 3: o empate do PDV antigo, o da comanda nova e a regra |
| o PDV antigo sem o cupom no convênio (a soma de antes) | 6: os cinco do PDV antigo com o convênio e o do D477 |
| o cupom sobre o que o convênio deixa | 6: o maior do PDV antigo, os dois da API que vencem, a troca no convênio, e dois da comanda nova (o digitado menor, e a linha à parte em porcentagem) |
| o nome longo que come a marca e entra | 2: o do PDV antigo e a regra |
| a comanda oferece o cupom que perde | 2: o que não se oferece e a troca que perde |
| a comanda sem perguntar as sessões livres (`convenioQueOCaixaDa` no lugar de `convenioDoLancamento`) | 1: o cliente com sessão livre do pacote |
| a linha do cupom no convênio é a do balcão | 2: o que vence pela API e a regra |

Da verificação (`medir-mutantes-5d-verif.py`, no rascunho; os do pacote e da troca estão no D276). O mutante "o nome longo
que come a marca e entra" da tabela de cima e o da oferta que tirava a conferência do motivo (que a verificação achou
sobrevivendo) deixaram de existir: o motivo nunca vem sem a marca.

| mutante | vermelho, no MySQL |
|---|---|
| o PDV antigo volta a ler o cupom do POST | 2: o cupom no POST que zerava o item (o corte a 0,00, com "Convênio Convenio Teste; " no motivo) e o do motivo de 150 (o `flush` cai; é o último caso do arquivo de propósito: o EntityManager fechado derruba em cascata os 13 do D477 que rodam depois) |
| o nome longo volta a comer a marca (o corte do fim) | 3: o do PDV antigo, o da API e a regra |


#### CORRIGIDO em 16/10/2026 -- as respostas 233 (a) e 235 (a) (a fatia 5e, RESPOSTAS-231-E-232)

**As duas respostas do dono** (a página do lote, 16/10/2026), iguais à recomendação: a **233 (a)** -- *"Fica com o cliente
(como deixei): o cupom que não deu desconto não se gasta, como na sua 152 (b); vale até vencer, e entra no dia em que for o
maior."* -- e a **235 (a)** -- *"Cortar o nome do convênio (como deixei): o cupom maior entra e se gasta no fechamento; o
motivo fica 'Convênio <o começo do nome>; CUPOM VIP: ...'. O convênio da linha continua o do cliente; só o texto do nome fica
cortado."* As duas eram o que estava feito: o código não muda, só os comentários que diziam "foi ao lote"
(`RegraDoItemDaComanda::motivoDoConvenioComOCupom`, o cabeçalho e o caso do nome longo no `D277CupomNaoSomaAoConvenioTest`)
e a nota do `spec-descontos.md`. Eram as duas partes do PARCIAL. O que a seção acima diz que "mais fica de fora" -- as linhas
gravadas antes, com o cupom somado ao convênio, e o cliente de convênio com sessão livre do pacote -- é o passado que nenhuma
resposta manda refazer e uma regra de antes que não mudou, e não defeito deste débito.

### D278 — A página 2 das telas de cupom do ERP perdia os filtros — CORRIGIDO em 28/09/2026

**Achado pela revisão adversarial da fatia dos cupons.** Nas estatísticas, os
filtros eram montados e nunca chegavam à view -- o link da página 2 ia sem filtro
nenhum, e ela misturava válidos, usados e vencidos com o formulário dizendo "Todos"
--, e o `!empty` jogava fora o `0` de "Não" e de "Vencidos" mesmo que chegassem. Na
lista, o "todas as unidades" do administrador (a unidade vazia) ficava fora do link:
a página 2 voltava à unidade da sessão. A campanha #121 tem 18.658 emitidos.

**O conserto:** os filtros presentes vão todos, codificados (o nome do cliente tem
espaço), e o vazio do administrador vai como `&unidade=`.

**Provado:** os casos 56 e 57 do ensaio, que leem o link da página 2.


### D279 — Os filtros das telas de cupom do ERP iam crus a um booleano e a um `int4` — CORRIGIDO em 28/09/2026

**Achado pela revisão adversarial da fatia dos cupons.** `status`, `utilizado` e
`situacao` iam da query direto a colunas booleanas, e a unidade do administrador
aceitava qualquer número de até dez algarismos: `?utilizado=x` e
`?unidade=2147483648` davam 500 no PostgreSQL (22P02 e 22003), e no MySQL o `x`
virava `0` calado -- o filtro "Não" sem ninguém pedir. É a família do "abc" que a
fatia já tinha fechado na unidade.

**O conserto:** os três valem `0` ou `1` (o `status`, `1` por padrão), e a unidade é
uma das que o administrador pode escolher; fora disso, a da sessão.

**Provado:** os casos 58 e 59 do ensaio. No MySQL eles medem o efeito que o banco
deixa ver -- a lista da sessão, e o filtro que não se aplica --; o 500 é do
PostgreSQL, e o `medir-telas-no-postgres.sh` roda o mesmo ensaio lá.

### D280 — A compra de crédito pela API aceitava a forma que não traz dinheiro, sem teto, no caixa de ontem e em quatro gravações soltas — CORRIGIDO em 28/09/2026 — PARCIAL, lido em 05/10/2026 — CORRIGIDO em 15/10/2026 (a resposta 88)

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decisão do dono: a página de compra de crédito do ERP antigo recusar também o caixa de outro dia, como a API.

**Achado pela leitura da fatia da fidelidade**, antes da tela nova. `POST
/adm/api/clientes/:id/creditos/compras` fazia `find()` na forma e aceitava qualquer
uma que existisse -- inclusive "Crédito Cliente" (5): comprar crédito pagando com
crédito criava saldo do nada. O valor não tinha teto (a família do D246), o caixa
de outro dia recebia a venda (o D28, que a comanda e o fiado já recusam), o 409 sem
caixa não dizia o `motivo` que a tela usa para oferecer "ir ao caixa", e o serviço
grava em quatro passos -- a venda, o pagamento, o caixa, o crédito -- sem transação.

**O conserto, na API:** a regra da compra (`RegraDaFidelidade::conferirCompra`) --
uma das `formasAceitas`, que são as ativas menos as três que não trazem dinheiro (o
mesmo conjunto que o ERP oferecia: 1, 2, 3, 4, 7 e 8), dito pelo servidor no `GET
/creditos`, e o teto de 999.999,99 com até duas casas --; o `motivo` nos dois 409
(`sem_caixa` e `caixa_de_outro_dia`); e a compra inteira numa transação.

**O ERP antigo não mudou:** a página de compra continua aceitando o caixa de outro
dia. Recusar lá também é decisão do dono.

**Provado:** a caracterização da regra (`RegraDaFidelidadeTest`) e os casos 4 a 10
do `ensaiar-fidelidade-adm-api.php` -- a compra que serve soma na linha mais antiga
e cria UMA venda.

**A revisão adversarial da tela (28/09/2026) achou a metade que faltava:** a
transação não travava nada. A compra lia o caixa antes, somava sobre o número da
memória, e a retirada que o mesmo operador fazia em outra sessão no meio sumia do
saldo. A compra passou a um método do serviço, `ClienteService::comprarCreditoTravado`,
que trava o cliente, busca o caixa **já travado** e o relê -- o molde do fiado
(`DevedorService::quitarSobTrava`) --, e responde `sem_caixa` quando o caixa fechou
enquanto o pedido esperava. **Provado:** `FidelidadeTudoOuNadaTest` -- o caixa relido,
e a falha forçada na gravação do crédito desfazendo a venda e o caixa.

#### CORRIGIDO em 15/10/2026 -- a resposta 88 (a fatia 4 da fila das telas que faltam, RESPOSTAS-DO-BALCAO): a página do ERP recusa o caixa de outro dia

**Decidido pelo dono em 12/10/2026 (a resposta 88, opção "a"):** *"A tela antiga recusa também, com a mesma frase da
venda, na quitação e na compra de crédito."* (O corpo inteiro está no D232.)

**O conserto:** `ClientesController::creditoComprarAction` pergunta `VendaPagamentoService::caixaDeDataAnterior` e, com
o caixa de outro dia, recusa como o PDV antigo recusa a entrada: a mensagem de erro com a frase da venda
(`RegraDoCaixaDoDia::fraseDaTelaAntiga`) e o redirecionamento à tela do caixa, para fechar e abrir outro. Vale no GET (a
página não abre) e no POST (a página aberta antes da virada do dia).

**Na cópia:** 269 das 5.054 compras de crédito caíram num caixa de dia anterior; a última, em 11/08/2026.

**Provado** nos dois bancos (`CaixaNaoAtravessaODiaTest`, a ação despachada em processo, com o token do formulário): com
o caixa de ontem, o POST e o GET vão ao caixa com a frase, e nenhuma venda de crédito nasce; com o de hoje, a compra
passa e o caixa sobe R$ 50,00. **Mutantes:** sem a recusa (2 vermelhos); a recusa só no POST (1, o GET); a frase trocada
(no D232). A vigia do portão também (sai 1 com a frase trocada).

**O que segue de pé, e não é deste débito:** a compra do ERP continua sem a trava da API (o **D381**).

### D281 — O cancelamento de pacote pela API alcançava o pacote de outra unidade, e qualquer um com o cadastro — CORRIGIDO em 28/09/2026

**Achado pela leitura da fatia da fidelidade.** `POST .../pacotes/:pacote/cancelamento`
conferia só que o pacote era do cliente: o do mesmo cliente em outra franquia era
cancelado pelo id (a família do D237). E exigia `clientes/cadastro/escrever`, quando
a aba do ERP só mostrava o X ao gerente (`pacotes.phtml`).

**O conserto, na API:** o pacote precisa ser da unidade da sessão (404, como a lista
que não o mostra), e quem cancela precisa ser gerente ou administrador (403).

**O ERP antigo não mudou:** `pacoteRemoverAction` continua no D237, e continua GET
(D199).

**Provado:** os casos 11 a 14 do ensaio.

**01/10/2026 -- o ERP antigo mudou (D314):** a regra mora em `RegraDaFidelidade::cancelaPacote`, e a
API, a ação do ERP e o X da tela a chamam; o ERP passou a POST.


### D282 — O código de resgate dizia "enviado" sem enviar, e a mensageria sem o modelo dava 500 depois do código gravado — CORRIGIDO em 28/09/2026

**Achado pela leitura da fatia da fidelidade.** `POST .../pontuacao/codigo`
respondia `enviado: true` sempre -- também na unidade sem mensageria, onde nada
sai --, e a tela pediria um código que nunca chegaria. Com a mensageria e sem
nenhum dos dois modelos do código, o `PushApp` nascia sem `tipo` nem `conteudo`,
colunas NOT NULL: o flush dava 500 com o código já gravado. O mesmo push nulo estava
no aviso de "resgate realizado", que além disso saía com **0 pontos** -- lia a
pontuação depois de zerá-la.

**O conserto, no serviço (vale para o ERP também):** `gerarCodigoDeResgate` devolve
o canal -- `whatsapp`, `app`, ou `null` quando nada foi enviado --, e a API o
responde; o push sem tipo não é gravado, nos dois avisos; e o aviso do resgate leva
os pontos de antes.

**Provado:** `FidelidadeDaTelaNovaTest` (os quatro caminhos do código e os pontos
da mensagem) e os casos 15 e 16 do ensaio.

**E o canal do aplicativo ia a quem nunca o abriu** (a revisão adversarial): o push
vai pelo id do cliente, e 1.382 aderidos nunca entraram no app. O código só vai pelo
aplicativo a quem já o abriu (`ultimo_acesso_app`), a condição das rotinas de
marketing; sem isso, `null`, e a tela vai direto ao gerente. **Provado:**
`FidelidadeDaTelaNovaTest` e os casos 16 e 23 do ensaio.

### D283 — Devolver o saldo inteiro dava 409 nos saldos com resíduo de `double` — CORRIGIDO na API em 28/09/2026, e no ERP em 03/10/2026 (com o D285)

**Achado pela leitura da fatia da fidelidade.** A coluna `clientes_creditos.valor` é
`double`, e 10 saldos da base (de 3.985 com valor) estão gravados um fio abaixo das
duas casas: a tela mostra R$ 30,30, o banco guarda 30,2999..., e a comparação em
`float` recusava a devolução do saldo inteiro.

**O conserto, na API:** a comparação é em centavos (`RegraDaFidelidade::cabeNoSaldo`).
**O ERP antigo continua** comparando em `float` (`ClientesController.php`, a
devolução) -- a tela antiga sai com a migração.

**Provado:** `RegraDaFidelidadeTest::testADevolucaoCabeNoSaldoEmCentavos`.

**O ERP, conferido em 05/10/2026: já estava resolvido, e nada mudou no código.** O
commit `4ac11f6` (o D285, 03/10/2026) tirou a comparação do
`ClientesController::creditoDevolucaoAction`: ele chama
`ClienteService::creditoDevolucao`, que confere o saldo com
`RegraDaFidelidade::cabeNoSaldo`, em centavos e sob a trava do cliente -- a mesma
conta da API. **A prova é a de lá, rodada de novo nos dois bancos:**
`DevolucaoDeCreditoSobTravaTest::testOErpDevolveOSaldoExatoPeloServicoSobTrava` (o
formulário do ERP despachado em processo devolve 0,80 de um saldo gravado
0,7999999999999999) e `testODoSaldoExatoPassaMesmoComORuidoDoDouble`, e
`RegraDaFidelidadeTest::testADevolucaoCabeNoSaldoEmCentavos` (30,30 contra
30,299999999999997) -- verdes; e o mutante que troca, no serviço, `cabeNoSaldo` pela
comparação em `float` deixa os dois primeiros vermelhos, nos dois bancos.


### D284 — Cancelar uma venda estorna REAIS da pontuação, e não os pontos que ela deu — CORRIGIDO em 01/10/2026 — PARCIAL, lido em 05/10/2026 — PREPARADO em 07/11/2026: o recálculo de todos no corte (123 a), com a 230 (a) antes

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: decisão do dono: corrigir em massa as colunas de pontuação que já divergem, e o destino dos pontos já resgatados de venda cancelada.

**Achado pela leitura da fatia da fidelidade.** `VendaService::cancelar` faz
`setPontuacao(getPontuacao() - getValorTotal())`: subtrai o valor da venda em reais
dos pontos. Com o cashback 10 da unidade, a venda de R$ 90,00 dá 900 pontos, e o
estorno tira 90. Logo depois o histórico da venda é apagado, e nenhum dos dois
caminhos de cancelamento chama `atualizarPontuacao` -- o saldo fica errado até a
próxima venda do cliente, a próxima comanda aberta ou a rotina da noite.

**Não foi consertado aqui:** é o cancelamento de venda, que tem a sua fatia, e muda
saldo de cliente em produção. Nenhum teste de `tests/Caracterizacao/` trata a
pontuação no cancelamento.

**A consequência que a revisão adversarial da tela achou:** o resgate pagava a
COLUNA, e a coluna é que fica errada depois do cancelamento. O PDV do ERP sempre a
recalculava antes de oferecer o resgate; a ficha nova não recalculava. Na cópia, 22
pares cliente/unidade têm a coluna acima da soma das linhas `ativa`, e 7 deles
passariam o piso na ficha e não no PDV. **Na ficha, isso fechou:** o código e o resgate
chamam `atualizarPontuacao` antes de conferir o piso, e o `GET /creditos` mostra a
soma do histórico. **O cancelamento continua errado** -- a conta em reais --, e é o que
este registro pede consertar.

#### CORRIGIDO em 01/10/2026

A conta em reais saiu. `VendaService::cancelar` guarda os clientes das linhas de
`pontuacoes_historicos` que apaga e, depois de apagá-las, chama `ClienteService::atualizarPontuacao`
para cada um -- a soma das linhas `ativa` que a ficha, o resgate e as duas finalizações já usam.
Mora no serviço, e não no controller: as duas telas de cancelamento (`VendasController::cancelarAction`
e `Adm\VendasController::cancelarAction`) passam por ele; `comandasCancelar` só cancela comanda
aberta, que não tem linha de pontos (0 na cópia). Quem indicou acompanha: a finalização grava 10
pontos para ele numa linha presa à venda, que o cancelamento já apagava.

**Provado:** `D284PontosNoCancelamentoTest` (3 casos, nos dois bancos), finalizando e recalculando
como as telas fazem: cashback 10, 400 pontos antes, venda de R$ 90,00 → 1.300 → cancelada → 400, e
a coluna igual à soma do histórico; a venda só de empório não mexe nos 400; quem indicou volta de
10 a 0. Sete mutantes, todos mortos -- o código de antes (1.210, 385 e 10), sem recálculo (1.300 e
10), recalculando só o comprador, a conta em reais por cima do recálculo, e o recálculo ANTES de
apagar as linhas (1.300 e 10).

**Medido na cópia, só a contagem, nada corrigido:** na cópia PostgreSQL (o retrato de 15/08/2026,
que nenhuma rotina tocou), 25 linhas de saldo com a coluna diferente da soma das linhas `ativa` --
19 acima, 6 abaixo, +3.752 pontos líquidos. Na cópia MySQL de desenvolvimento são 8.026, mas 8.003
são pares com linhas expiradas depois do retrato, no próprio ambiente: o número mede o ambiente, e
não a produção. O peso do cancelamento não se mede pelo dado -- ele apaga as linhas que o
explicariam.

**O que mudou para quem usa direito:** cancelar passa a reescrever a pontuação do cliente, em todas
as unidades dele, pela soma do histórico -- como a finalização e a ficha já faziam.

**O que ficou:** as colunas que já divergem seguem como estão até o próximo `atualizarPontuacao` de
cada cliente -- corrigir dado em massa é decisão do dono. Os pontos já RESGATADOS de uma venda
cancelada: as linhas `resgatada` são apagadas e o valor em que viraram fica com o cliente, como
antes -- não é regressão, e fica para o dono. E a indicação em outra unidade virou o **D312**: o
recálculo de quem indicou, na unidade dele, perde os 10 pontos que a linha da venda, gravada em
outra unidade, não explica.

#### PREPARADO em 07/11/2026 -- a resposta 123 (a), com a 230 (a) antes: o passo do corte

**A decisão** (a pergunta 123, 12/10/2026): *"Recalcular todos no corte"* -- "quem tem pontos a mais na coluna perde o excesso,
que o resgate já não pagava, e todo saldo passa a bater com o histórico"; e os pontos de venda cancelada que já viraram crédito
ficam com o cliente, pela lógica da 68. O que faltava a este débito era esse passo, e ele não existia (nem em
`docs/procedimento-de-corte.md` nem em `scripts/`: o `grep` de `recalcul` e `pontua` só achava as duas migrações de coluna).
Ele lê a 230 (a) antes, como o **D312** pedia.

**O roteiro** (a fatia 106, RECALCULO-DA-PONTUACAO-NO-CORTE): `scripts/recalcular-pontuacao-no-corte.sql` e o irmão
`postgres/recalcular-pontuacao-no-corte.sql`. Na ordem das duas respostas: **primeiro a 230 (a)** -- a linha de saldo zerada
(cliente, unidade; no PostgreSQL também o inquilino) de cada par com pontos `ativa` e sem linha de saldo, como o código novo a cria
(`valor`, `valor_pf` e `pontuacao` zero, `pontuacao_validade` no dia), para todo tipo de ponto (a letra: "cada registro de pontos
ainda válido que não tem saldo lá") --; **depois a 123 (a)** -- a soma das linhas `ativa` do mesmo (cliente, unidade), ou zero, em
toda linha de saldo que difere dela (a pontuação nula também), a conta de `ClienteService::atualizarPontuacao`, também nas duas
linhas do par gêmeo (o D72). **Conta antes** (as linhas de saldo; os pares sem saldo e os pontos deles; das linhas, as que diferem,
acima, abaixo, nulas, e o líquido), **lista** os pares sem saldo, as linhas criadas (o id) e cada linha que muda com o número de
antes (o caminho de volta, guardado pelo `tee`), e **confere** no fim com a mesma contagem de antes (as linhas de saldo, as de
antes mais as criadas; todo o resto zero). Só a coluna `pontuacao` e as linhas novas: `valor` e
`valor_pf` não são tocados -- o crédito dos pontos de venda cancelada que já viraram dinheiro fica com o cliente (a 123 (a), pela
lógica da 68) --, e o histórico só é lido. Idempotente. O do PostgreSQL começa por `ANALYZE` das duas tabelas: num clone recém-
carregado, sem as estatísticas, a contagem levou 61 s (o planejador estimou 2.460 linhas `ativa`, e são 225.209); com elas, o
roteiro inteiro leva cerca de 1,3 s.

**Onde roda:** no passo 7 da etapa 1 (`docs/procedimento-de-corte-etapa-1.md`), depois da troca do código e logo depois do consumo
dos clientes, pelo caminho da árvore no lugar e com o `PIPESTATUS` do `mysql` (o mesmo molde do `refazer-consumo-dos-clientes.sql`,
da 295 (a), e do `encerrar-pacotes-de-venda-cancelada.sql`, da 287 (a)); e o irmão no 7d de `docs/procedimento-de-corte.md`, que
com a etapa 1 feita só acerta o que o intervalo deixou. Nunca no laço do passo 0 (muda dado). O item 18 de "O que falta fazer antes
da janela" e as linhas 123 e 230 da tabela das decisões, na etapa 1.

**Medido na cópia, só SELECT** (07/11/2026): 1 par sem saldo, de 10 pontos -- o lançamento de indicação deste débito --; das 256.987
linhas de saldo, 28 diferem no PostgreSQL da cópia (22 acima, 6 abaixo, +5.092) e 7.793 no MySQL de desenvolvimento (7.789 acima, 4
abaixo, +790.297: quase todas linhas expiradas no próprio ambiente, como este débito já dizia); 3 pares gêmeos; nenhuma pontuação
nula. **Num clone descartável das duas tabelas da cópia em cada banco** (`descartavel_f106`, apagado no fim), o roteiro de verdade
pelo cliente de cada banco, duas vezes: no MySQL, cerca de 3 s, 1 linha criada (o indicador, na unidade da compra) e 7.794
recalculadas, a conferência 0 e 0; no PostgreSQL, 1 criada e 29 recalculadas; a segunda rodada, nos dois, contou zero e não mudou
nada. O indicador real: a coluna da unidade do cadastro foi de 10 a 0, e a linha nova da unidade da compra, de 0 a 10.

**Provado** em `RecalculoDaPontuacaoNoCorteTest` (5 casos, nos dois bancos; 6 depois da verificação), o roteiro de cada banco comando por comando dentro da
transação do caso: o indicador de outra unidade (ganha o saldo na unidade da compra, com os 10; o total dele fica 10), o cancelamento
antigo (1.210 a 400), o abaixo, os pontos resgatados (300 a 200, e o crédito de R$ 90,00 e o `valor_pf` ficam), o par gêmeo (as duas
linhas a 60, e nenhuma terceira), a pontuação nula, o que já bate e o que só tem linha expirada (nada nasce); a contagem de antes, as
listas, a conferência; o `atualizarPontuacao` que, depois do roteiro, não muda nenhuma linha; a segunda rodada com zero; os dois
roteiros só com o `INSERT` zerado e o `UPDATE` da pontuação; e o lugar no passo 7 e no 7d, fora do passo 0. O
`PermissoesComONomeDaTelaTest` passou a exigi-lo entre os roteiros do passo 7. Mutantes, um por vez, devolvidos pelo sha1: sem o
saldo zerado (S1 no MySQL, P1 no PostgreSQL) e o saldo criado também no par que já tem linha (S2): vermelhos; a soma com todo status
(R1, P2), a pontuação nula deixada (R2), o `valor` zerado junto (R3) e sem o recálculo (R4): vermelhos; a linha do 7d e a do passo 7
tiradas (L1, L2): vermelhas. O par sem o inquilino no `NOT EXISTS` do PostgreSQL (P3) ficou verde: equivalente com um inquilino só,
que é o que a suíte e o corte têm.

**A verificação** (07/11/2026, três lentes) confirmou quatro achados, consertados: **a conferência do fim** trazia só os pares sem
saldo e os divergentes, e o bloco pede a contagem antes e depois -- agora ela repete a contagem de antes, coluna por coluna, com
`_depois` no nome (as linhas de saldo, os pares sem saldo e os pontos deles, os divergentes, acima, abaixo, nulos e o líquido): se
der diferente de zero (PARE), quem opera a janela lê ali quantas ficaram acima e abaixo e o líquido. **O `GROUP BY` do `INSERT`
não tinha caso:** sem ele, um par com duas linhas `ativa` e sem saldo ganhava duas linhas de saldo (o par gêmeo do D72 criado pelo
próprio roteiro), e os 5 casos ficavam verdes porque o indicador do cenário tinha uma linha só; agora ele indicou dois amigos que
compraram na mesma outra unidade (20 na coluna do cadastro, duas linhas de 10 na unidade da compra) e ganha uma linha de saldo só,
com os 20. **A chave do inquilino no PostgreSQL** (o `NOT EXISTS`, o `GROUP BY` e o `JOIN` da soma) não tinha caso: o
`testNoPostgresOParLevaOInquilinoEmTodaConsulta` lê o roteiro sem os comentários e exige o inquilino em cada um, tantos quantos o
irmão do MySQL tem sem ele. O cenário do achado (um segundo inquilino com os mesmos ids de cliente e de unidade) não existe no
esquema: `clientes.id` e `unidades.id` são chave primária sozinhos, e com o dado coerente a chave não muda o resultado, com um
inquilino ou mais; ela guarda a linha incoerente (o histórico com um inquilino e o cliente com outro), e o cabeçalho do roteiro
diz isso. **O roteiro inteiro pelo cliente do banco, no banco do contrato:** num banco descartável montado pela receita do
contrato em cada banco (`descartavel_f106_contrato` pelo `scripts/montar-banco-contrato.sh`; `descartavel_f106_pgcontrato` pela do
`rodar-contrato-postgres.sh`, o dono superusuário, como no 7d), com o indicador de outra unidade (duas linhas) e os pontos
resgatados da 68 por cima das fixtures, o arquivo como está, com a transação, o `SET` e o `ON_ERROR_STOP` dele, duas vezes: nos
dois, `mysql` e `psql` saíram 0; a primeira contou 5 linhas de saldo, 1 par sem saldo de 20 pontos, 5 divergentes (+1.460), criou
1 linha e recalculou 6, e a conferência deu 6 linhas e o resto zero; a segunda contou zero, criou nada, recalculou 0. O indicador
ficou com 20 na unidade da compra e 0 na do cadastro; o resgatou, 200 pontos e os R$ 90,00 e R$ 12,50 de crédito. Os dois bancos
apagados no fim; o `franquiabv_contrato` e o `franquiabv_pgcontrato` não foram tocados. O ensaio fica em
`estado-sessao-d5c2211c/scratchpad/ensaio-106/` (fora do repositório; o CI não o roda). Mutantes novos, um por vez, devolvidos
pelo sha1: sem o `GROUP BY` do `INSERT` (G1 no MySQL, G2 no PostgreSQL), a conferência de antes, só com pares e divergentes (C1,
C2), e o inquilino tirado do `NOT EXISTS` do passo 3 (I1, o P3 de antes), do `JOIN` do recálculo (I2) e do da contagem (I3):
vermelhos.

**Não provado:** o roteiro na produção (nada roda lá antes da janela) e na janela (é parte do ensaio da etapa 1, o item 11); a
linha incoerente entre inquilinos por comportamento (a suíte entra como quem obedece ao RLS e só vê um inquilino; a chave é
guardada pela leitura do roteiro).

**O que fica:** rodá-lo na janela da etapa 1 (do dono). O código do cancelamento já estava corrigido desde 01/10/2026.

### D285 — Comprar e devolver crédito não são idempotentes: o pedido repetido cria duas vendas — PARCIAL em 03/10/2026: a devolução confere o saldo sob trava; a idempotência continua decisão — PREPARADO em 07/11/2026

**Achado pela leitura da fatia da fidelidade.** Dois `POST .../creditos/compras`
seguidos -- o duplo clique, a rede que reenvia -- criam duas vendas, dois pagamentos,
e sobem o caixa e o crédito duas vezes. A devolução confere e depois grava, sem
trava: duas em paralelo passam as duas. É a pergunta que o D160 registrou para os
pagamentos.

**O que a tela faz:** a compra confirma em duas etapas, o duplo clique não confirma e
o botão fica `aria-disabled` com o pedido no ar. **O servidor não guarda**: a chave
de idempotência é decisão de desenho da API, e vale para as quatro escritas de
dinheiro.

**O conserto da devolução (03/10/2026)** -- a corrida do D228 e do D232, com o molde
já decidido. `ClienteService::creditoDevolucao`, por onde a API
(`Adm\FidelidadeController::devolverCreditoAction`) e o ERP
(`ClientesController::creditoDevolucaoAction`) passam, abre uma transação, trava o
CLIENTE (a mesma trava da compra, `comprarCreditoTravado`: as duas regravam a mesma
linha de crédito), relê o crédito pela leitura com trava, confere o saldo em centavos
(`RegraDaFidelidade::cabeNoSaldo`, o D283) e só então grava; a recusa devolve `false`,
e as duas portas respondem a frase de sempre (o 409 da API, a mensagem do ERP). A API
mantém a conferência de antes, como atalho; a que vale é a do serviço. **O ERP
mudou de comparação:** comparava em `float`, fora de trava, e recusava devolver o
saldo inteiro quando a coluna `double` o guarda um fio abaixo (0,80 contra
0,7999...); agora passa, como na API desde o D283.

**Provado** (`DevolucaoDeCreditoSobTravaTest`, 10 casos, verdes no MySQL e no
PostgreSQL das fixtures): a primeira leitura é a do cliente com `FOR UPDATE`, e a
segunda a do crédito com `FOR UPDATE`; outra conexão segurando o cliente faz a
devolução parar na trava (erro de trava vindo do `FOR UPDATE`, com a espera em 1 s);
o crédito na memória com 100 e o banco com 10 recusa os 50 e deixa os 10; o saldo
exato com o ruído do `double` passa; fora da transação do teste, com a foto do MySQL
tirada antes e outra conexão devolvendo 60 e confirmando, a devolução de 80 é
recusada e a de 40 grava de verdade e solta a trava; e as duas portas, despachadas em
processo com a senha do gerente e o token do formulário do ERP, recusam o saldo que
mudou entre a conferência delas e a trava e passam pela trava do cliente. Sete
mutantes, cada um vermelho nos dois bancos: sem a trava do cliente; o `refresh` sem
trava (no PostgreSQL, que lê o confirmado a cada comando, só a ordem das leituras o
acusa; no MySQL, também a foto); sem a releitura; sem conferir o saldo; sem a
transação; a API ignorando a resposta do serviço; e o ERP como era. O ensaio da
fidelidade (24 de 24, nos dois bancos de contrato) continua verde -- ele não manda
credencial de gerente, então não chega à gravação: confere as recusas de antes dela.

**O que fica:** a idempotência (a chave é decisão, acima); e a devolução de valor negativo
pelo ERP (o D382). **A trava só enfileira quem a pega:** a compra pela API e a devolução. As
outras escritas do mesmo saldo (`clientes_creditos.valor`) leem e regravam sem a trava do
cliente, e uma devolução confirmada no meio delas é sobrescrita pela conta feita sobre a leitura
velha: a compra do ERP (`creditoComprarAction`, o D381); e o finalizar do PDV (o crédito usado e
o troco), a comissão de indicação e o cancelamento de venda (o D383, achado na revisão de
03/10/2026). O `DevolucaoDeCreditoSobTravaTest` prova a devolução contra a devolução e contra
outra conexão, e não contra essas escritas.

#### A idempotência entrou: um número por confirmação (07/11/2026, a fatia 103)

Pela resposta 87 do dono (12/10/2026, a opção (a), "Um número por confirmação": *"A tela gera um número ao confirmar e o
repete se o pedido falhar e o operador tentar de novo; o servidor responde ao número repetido o que respondeu da primeira
vez, sem gravar de novo. Os dois pagamentos de verdade são duas confirmações e passam."*). As três escritas de dinheiro da
API -- o pagamento da comanda, a compra e a devolução de crédito -- aceitam o número no cabeçalho `Idempotency-Key`, e a
tela nova o manda: o `Receber` da comanda e a fidelidade do cliente geram um número ao confirmar
(`frontend/src/api/confirmacao.ts`, 32 hexadecimais por `crypto.getRandomValues`), o repetem enquanto o pedido é o mesmo
(o corpo sem a senha) e o liberam quando dá certo. O servidor (`ISBase\Controller\EscritaConfirmada`,
`ISBase\Service\ConfirmacoesDaEscrita`, `ISBase\Regra\RegraDaConfirmacao`) grava o número numa tabela nova,
`confirmacoes` (quem, o número, a unidade, a escrita, o sha256 do pedido sem a senha, e a primeira resposta), ANTES da
escrita e na mesma transação, por um ponto de salvamento; ao número repetido devolve a primeira resposta (o mesmo código e
corpo, com `Idempotent-Replayed: true`), antes das recusas; com outro pedido, `422` `confirmacao_de_outro_pedido`; o
número que não serve, `400`. Só o que deu certo fica: a recusa volta ao ponto de salvamento sem o número, também a que vem
de dentro do serviço. Dois pedidos com o mesmo número ao mesmo tempo se enfileiram no índice único (`usuario`, `chave`): o
segundo espera e devolve a resposta do primeiro, sem gravar; se o primeiro desiste, o segundo grava. As decisões técnicas
(o cabeçalho, o 2xx que se guarda, a resposta antes das recusas, a janela que fica) estão na regra 12 de
`docs/convencao-api.md`.

**O esquema** (só acrescenta; a resposta 83 (a)): a tabela no `mysql.sql`, no `postgres/schema.sql` do gerador (com o
inquilino, o RLS ligado e forçado e a política), na carga (`scripts/migrar-dados.sh`, regerado), em
`scripts/migracao-confirmacoes.sql` no laço do passo 0 de `docs/procedimento-de-corte.md` e no do ensaio geral, e nas duas
cópias locais desde 07/11/2026 às 04:49 (o MySQL pela migração, duas vezes, o `SHOW CREATE TABLE` igual ao do `mysql.sql`;
o PostgreSQL por `postgres/migracao-confirmacoes.sql`, duas vezes, o retrato de 18 linhas igual ao do `schema.sql`) --
ensaiadas antes em quatro bancos descartáveis, apagados no fim. O `conferir-passo-zero-do-corte.php` sai 0 com ela.

**Provado** (`UmNumeroPorConfirmacaoTest`, 15 casos, e `RegraDaConfirmacaoTest`, 4, verdes no MySQL e no PostgreSQL das
fixtures): o reenvio grava uma vez e recebe a primeira resposta; dois pagamentos iguais de verdade passam; sem número, como
antes; o número com outro pedido e o que não serve são recusados sem gravar; a recusa (de fora e de dentro da escrita) não
guarda o número, e a nova tentativa grava; a resposta repetida vem antes do `409` da comanda já finalizada; o número é de
quem o mandou; sem a tabela, a escrita segue sem ele; a compra repetida cria uma venda só; a devolução repetida, com a
senha digitada de novo, sai uma vez e a senha não fica no registro; e a CORRIDA, com outro processo segurando o mesmo
número 1,5 s (o pedido daqui espera no índice e devolve a resposta do outro sem lançar nada; e, se o outro desiste,
grava). Pelo HTTP, os casos 239 a 242 do `ensaiar-recusas-adm-api.php`, nos dois bancos de contrato. Na tela, 9 casos
(`confirmacao.teste.ts`, o `Receber` e a fidelidade com a rede caída). Mutantes, cada um sozinho e devolvido pelo sha1,
vermelhos nos dois bancos: a porta sem a conferência do número (as três); o serviço guardando a recusa; o serviço sem a
chave repetida (a corrida cai); a resposta guardada sem conferir o pedido; a senha no que identifica o pedido; e, na
tela, sete (o número não mandado, não repetido, não liberado, o mesmo para outro pedido, o cabeçalho que não sai).

**O que fica:** a tabela em PRODUÇÃO, no passo 0 do corte (até lá, lá, o número é ignorado e as escritas gravam como
antes, sem erro); quanto tempo guardar as linhas (o **D735**, a pergunta ao dono); os nomes do cliente e do operador
dentro das respostas guardadas, que a anonimização da cópia não limpa (o **D736**, a pergunta ao dono); e o ERP antigo,
que não manda número. **O recorte na tela nova NÃO é do dono:** a resposta 87 (a) diz só "a tela gera um número ao
confirmar", e o contexto da pergunta põe a tela antiga dentro do problema ("a tela antiga também não impede"); quem
limitou a fatia à tela nova foram a triagem de 07/11/2026 e as notas da fatia ("A tela nova gera o número"). A pergunta
foi ao lote: "TELAS-RESTANTES [vendas] (07/11/2026) -- O número da confirmação também no ERP antigo? (D162, D285)". Até a
resposta, pelo ERP antigo, o pagamento do PDV, a compra e a devolução de crédito repetidos continuam gravando duas vezes (a
quitação, não: o `ja_quitado` sob a trava, desde 26/09/2026).

**A quarta escrita, a quitação do fiado** (`POST /adm/api/devedores/:id/quitacao`), entrou na correção da fatia, depois
da verificação: as notas da fatia a mandavam entrar "se for escrita de dinheiro", e ela é -- cria venda e pagamento e soma
ao caixa. Ela já não gravava duas vezes (o débito quitado é relido sob a trava, e o segundo pedido recebia `409`
`ja_quitado`, desde 26/09/2026); com o número, a nova tentativa depois da resposta perdida recebe o `200` da primeira vez,
e não o `ja_quitado` que ela mesma causou. A tela de devedores (`DevedoresLigado.tsx`) manda o número, o repete na nova
tentativa da mesma quitação e o libera quando dá certo.

**A correção da verificação (07/11/2026)**, caso a caso, vermelho sem o conserto e verde com ele, nos dois bancos: a
resposta repetida vem antes das recusas também na devolução (o saldo inteiro devolvido, e a nova tentativa recebe o `200`,
não o `409` do saldo que ela gastou: `testADevolucaoDoSaldoInteiroRepetidaRecebeAPrimeiraResposta`) e na compra (o caixa
fechado depois: `testACompraRepetidaComOCaixaFechadoRecebeAPrimeiraResposta`) -- antes, só a comanda tinha esse caso, e a
ordem na fidelidade estava protegida só pelo comentário; duas devoluções iguais de verdade, com dois números, saem duas
vezes (`testDuasDevolucoesIguaisDeVerdadeSaoDuasConfirmacoes`); a quitação repetida recebe a primeira resposta, e sem o
número o `ja_quitado` de sempre (`testAQuitacaoRepetidaRecebeAPrimeiraRespostaENaoOJaQuitado`), e a recusa da quitação não
guarda o número (`testARecusaDaQuitacaoNaoGuardaONumero`). Na tela, a devolução que deu certo libera o número (a seguinte,
igual, leva outro: com o mesmo, o servidor devolveria o `200` guardado e o dinheiro não sairia) e a quitação repete o
número na nova tentativa. Mutantes, cada um sozinho e devolvido pelo sha1: a resposta repetida descida para depois das
recusas na devolução, na compra e na quitação, e a quitação sem o número (vermelhos nos dois bancos); na tela, a devolução
que não libera o número, a quitação sem o número, com número novo a cada confirmação, e o `quitarDebito` sem o cabeçalho.

### D286 — O código de resgate não expira nem conta tentativas, e o 403 conta que a credencial é válida — REGISTRADO em 28/09/2026

**Achado pela leitura da fatia da fidelidade.** `setCodigoApp()` grava seis dígitos
sem validade, e a API não conta as tentativas erradas -- o 422 "Código de verificação
inválido" responde quantas vezes se pedir. Na autorização do gerente, "não confere"
(422) e "não autorizado" (403) contam a quem pede que aquele login e senha existem e
só não são de gerente; e não há limite de tentativas em lugar nenhum
(`UsuarioRepository::autenticacao`).

**O que mudou em 28/09/2026:** o resgate passou a responder 422 com os dois campos
para a credencial que não confere, como a devolução -- antes era 403 com a mesma
frase das duas recusas. **A diferença entre 422 e 403 continua**, e é o que o
registro pede decidir.

**Provado o 422:** o caso 19 do ensaio manda um login que não existe e confere os
dois campos marcados, e o `conferir-autorizacao-privilegiada.php` passou a exigir o
`cabeNoSaldo` na devolução (o D283).


### D287 — O resgate gravava em três passos soltos: a falha no meio devolvia os pontos para resgatar de novo — CORRIGIDO em 28/09/2026

**Achado pela revisão adversarial da tela nova da fidelidade.**
`ClienteService::programaFidelidadeResgatarPontuacao` -- chamado pela API e pelo ERP
-- gravava o crédito (os pontos zerados e o `valorPf` somado), depois a linha do
resgate, depois virava as linhas `ativa` em `resgatada`, cada passo com o seu commit.
Uma falha no segundo ou no terceiro deixava o dinheiro no `valorPf` e as linhas ainda
`ativa`: a próxima `atualizarPontuacao` devolvia os pontos, e o cliente resgatava de
novo.

**O conserto, no serviço (vale para as duas portas):** os três passos numa transação;
o aviso ao cliente fica depois do commit, como antes.

**Provado:** `FidelidadeTudoOuNadaTest::testAFalhaNaLinhaDoResgateDesfazOCredito` -- a
falha forçada na linha do resgate deixa o crédito e a linha `ativa` como estavam.


### D288 — Quem administrava usuários concedia a si mesmo qualquer funcionalidade, inclusive as de rede — CORRIGIDO em 29/09/2026 (decisão minha, com o caminho de volta)

**Achado pela leitura da frente de usuários, antes da tela nova.** O D148 fechou a
concessão por qualquer sessão, e sobrou a AUTO-CONCESSÃO: quem tinha
`configuracoes/usuarios`/`escrever` -- 107 pessoas não administradoras, em 54
unidades, na cópia de 16/08/2026 -- editava a PRÓPRIA linha de privilégio, pela
API e pela janela do ERP. Podia dar a si mesmo "Agendas unidades", "Configurações
unidades", "Configurações controle de acesso" -- as funcionalidades que abrem as
outras franquias no `SessaoAcl` -- e, no login seguinte, sair da própria franquia.
E dava a um colega o que ele mesmo não tinha. `PrivilegiosController` registrava a
escolha "de olhos abertos" desde a décima sexta fatia; a tela nova seria a primeira a
oferecê-la sem o disfarce da janela.

**A regra, nas duas portas** (`ISConfiguracao\Regra\RegraDoPrivilegio`):

1. ninguém altera os próprios privilégios -- nem o administrador;
2. quem não é administrador só CONCEDE o que tem, no mesmo nível;
3. quem não é administrador nunca concede uma funcionalidade de rede (as nove de
   `DE_REDE`, pelo nome).

"Conceder" é subir de falso a verdadeiro: tirar e manter não passam pela 2 nem pela
3. O que quem pede tem é lido do BANCO, e não da sessão.

**Medido antes de fechar:** das nove funcionalidades de rede, oito não estão com
nenhuma pessoa ativa não administradora; a nona ("API Integração") é do usuário de
integração, que continua com ela -- a regra só impede de conceder. **Ninguém perde
acesso que tem.** Quem perde é quem concedia: o gerente que dava a colegas o que ele
não tinha passa a pedir a um administrador.

**O caminho de volta, se a decisão for outra:** a regra é uma classe pura chamada em
dois lugares (`PrivilegiosController::recusa` e
`UsuariosController::permissoesSalvarAction`); relaxar a 1 ou a 2 é mudar
`RegraDoPrivilegio::recusa` e o `RegraDoPrivilegioTest`. A lista de rede é uma
constante.

**A TROCA DE GRUPO É A TERCEIRA PORTA, e a revisão adversarial a achou.** Cadastrar
alguém ou trocar o grupo dele reescreve os privilégios pelo molde do grupo
(`salvarPrivilegios`), e isso não passava pela regra: o gerente trocava o próprio
grupo de ida e volta e recuperava o que um administrador lhe tinha tirado. Agora
`UsuarioService::recusaDoGrupo`, no `editar` e no `novo` do ERP, aplica a **1** e a
**3** -- ninguém troca o próprio grupo; o molde com funcionalidade de rede é só do
administrador --, **e não a 2, medida antes de decidir: dos 107 que escrevem em
usuários, 99 não poderiam mais cadastrar alguém no grupo Colaborador** (o molde tem o
que o gerente não tem), 100 no grupo 2 e 98 no 8. O molde é decisão da rede, feita por
quem administra os grupos. Hoje nenhum molde oferecido a quem não é administrador tem
funcionalidade de rede.

**Provado:** `RegraDoPrivilegioTest` (6 casos, com as frases da tela amarradas às do
servidor), `PrivilegioDaTelaNovaTest` (a leitura do banco, a soma das linhas de quem
pede, a troca de grupo) e **`PrivilegiosPelosControllersTest`**: as duas portas --
a API e a janela do ERP -- despachadas em processo, sem HTTP, com cada recusa
conferida também no banco. A chamada no `editar` e no `novo` do ERP é vigiada por
`conferir-autorizacao-privilegiada.php`.


### D289 — A gravação do privilégio mexia numa linha e deixava a gêmea concedendo — CORRIGIDO em 29/09/2026 — PARCIAL, lido em 05/10/2026 — PREPARADO em 17/10/2026: as gêmeas juntadas e a chave única nas duas cópias e no `mysql.sql`; na produção, no passo 3 do corte

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: o índice único em (usuário, funcionalidade), esquema que espera o dono (a P8); e a limpeza das gêmeas, SQL da migração final.

**Achado pela leitura da frente de usuários.** Há 117 pares (usuário,
funcionalidade) com mais de uma linha em `privilegios_usuarios`, e o `Acl` SOMA as
linhas: basta uma conceder. O `PUT` da API e o `permissoesSalvar` do ERP gravavam
UMA linha -- tirar o `escrever` de uma deixava a gêmea com ele --, e o "excluir"
apagava uma e deixava a outra concedendo. As duplicatas vieram do próprio ERP: sem
`id` no pedido, `permissoesSalvar` criava linha nova sem olhar o par.

**O conserto:** a leitura soma as linhas (`PrivilegioJson::doPar`, com `linhas`); a
gravação deixa a mais antiga com o valor e apaga as gêmeas, numa transação
(`UsuarioService::gravarPrivilegio`); a exclusão tira o par inteiro
(`removerPrivilegio`). As duas portas usam os mesmos métodos. A janela do ERP que
trocava a funcionalidade de uma linha continua trocando: o par antigo sai inteiro
depois que o novo é gravado.

**A janela do ERP mostra o par somado** desde a mesma revisão: ela listava a linha
clicada, e salvar sem mexer na gêmea "só lê" tiraria o "escrever" da outra. E o
"novo" e a linha que muda de funcionalidade **somam** ao par que já existe, como
somavam as gêmeas que esse caminho criava -- "novo: só ler" não tira mais, em
silêncio, o que a pessoa já tinha.

**O que continua:** as 117 duplicatas que já estão na base ficam até a próxima
gravação de cada par. Uma limpeza de uma vez é SQL de migração, e fica para a
migração final (a produção não é tocada agora). **E a propagação do molde (D157)**
ainda trata uma linha por pessoa (`PrivilegioService::linhasDoPar` indexa por
usuário): com gêmeas, a edição do grupo atualiza uma e deixa a outra -- anterior a
esta fatia, e desfeito par a par pela gravação nova; a limpeza de uma vez resolve os
dois. Sem índice único em (usuário, funcionalidade), duas gravações simultâneas de um
par sem linha ainda podem criar duas -- a leitura soma, e a próxima gravação desfaz.

**Provado:** `PrivilegioDaTelaNovaTest` (a soma, a gravação numa linha, a exclusão do
par), e o caso 07 do `ensaiar-usuarios-adm-api.php` (as gêmeas viram uma entrada
com `linhas: 2`).

#### O resto, CORRIGIDO em 04/10/2026 -- a propagação do molde (o item GRUPOS-F0)

**O conserto:** a propagação do molde (D157) deixou de tratar uma linha por pessoa -- o D-ACL-2 do
desenho de Grupos e privilégios (`docs/f3-telas-restantes/tela-acl.md`). `PrivilegioService::linhasDoPar`
agrupa as linhas de cada pessoa numa lista, e a regra pura `RegraDoMolde`
(`module/ISConfiguracao/src/Regra/RegraDoMolde.php`) decide "em dia" pelo efetivo -- o OU das linhas,
como o `Acl` -- e não pela última linha lida. Quem estava em dia recebe o molde novo por
`UsuarioService::gravarPrivilegio` (o par numa linha só, a mais antiga) ou sai por `removerPrivilegio`
(o par inteiro): as mesmas portas da API e da janela do ERP. Não é a limpeza de uma vez: a gêmea de
quem tem um ajuste (não está em dia) fica até a próxima gravação daquele par.

**Provado** nos dois bancos: `RegraDoMoldeTest` (a gêmea igual e a diferente, nas duas ordens) e
`PrivilegioPropagacaoTest` (a gêmea em dia vira uma linha com o molde novo; [só ler] + [só escrever],
com o molde "ler e escrever" passando a "só ler", vira uma linha "só ler"; apagar o molde tira as
duas). Mutantes, um de cada vez: `linhasDoPar` voltando a uma linha por pessoa, e "em dia" pela última
linha -- vermelhos.

**O que continua:** sem índice único em (usuário, funcionalidade), duas gravações simultâneas de um par
sem linha -- índice é esquema, com o dono (a P8 do consolidado).

*Atualizado em 12/10/2026 (o item ESQUEMA-NAS-COPIAS):* a P8 do consolidado é a pergunta 83 do lote,
respondida em 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 os pede. **Ela não leva este índice às cópias:** na cópia, 117 pares (usuário,
funcionalidade) têm mais de uma linha em `privilegios_usuarios`, 235 no todo (contados de novo neste dia, só
leitura), e o índice único pede antes juntá-los -- apagar linha, o exemplo da própria resposta ("juntar os
privilégios repetidos"), que continua um a um. A resposta que decide é a da pergunta 125 do lote (o D289 e o
D374: juntar os 117 pares e pôr no banco a regra que impede repetir), ainda sem resposta. Nada foi aplicado.

#### PREPARADO em 17/10/2026 -- as gêmeas juntadas e a chave única (a fatia 11 da fila das telas que faltam, PRIVILEGIOS-GEMEOS)

**Decidido pelo dono 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 (...); no corte, os 117 juntados no passo 3 e o PostgreSQL nascendo com as
duas regras." A resposta é a autorização própria para apagar as linhas a mais.

**A regra de qual linha fica** é a deste débito e a da pergunta ("vale o que as linhas davam juntas"): a mais antiga do par
(o menor id), com o OU das linhas -- `ler` se alguma lia, `escrever` se alguma escrevia --, e as outras saem. É o que o `Acl`
já somava: ninguém ganha nem perde acesso.

**Nas duas cópias locais, em 17/10/2026**, contado antes com `SELECT` (só contagens): 117 pares (pessoa, funcionalidade) com
mais de uma linha, 235 linhas, 118 a mais; 1 par com valores diferentes (o `ler` nas duas, o `escrever` numa só); 1 par de
três linhas; nenhum molde repetido -- iguais no MySQL e no PostgreSQL. Ensaiado antes num banco descartável de cada um
(`gemeos_descartavel_f11`, com a cópia das duas tabelas, apagado em seguida): o efetivo de cada par -- o OU, como o `Acl` --
deu o mesmo resumo antes e depois (15.385 pares nos dois), a segunda rodada não mudou nada, e a gêmea seguinte foi
recusada. Depois, nas duas cópias: o MySQL por `scripts/migracao-privilegios-unicos.sql` (a junção numa transação, e as duas
`UNIQUE KEY`, `uq_privilegios_grupo_funcionalidade` e `uq_privilegios_usuarios_usuario_funcionalidade`); o PostgreSQL por um
roteiro do rascunho, numa transação só com a restrição `privilegios_usuarios_inquilino_id_usuario_funcionalidade_unico` (o
nome que o gerador dá; a do molde ele já tinha). Contado de novo: 0 pares, 15.385 linhas, o mesmo resumo do efetivo.

**No esquema:** as duas `UNIQUE KEY` no `mysql.sql` (a suíte do MySQL nasce com elas), e `privilegios_usuarios` na lista
`$unicidades` de `scripts/gerar-ddl-postgres.php` (o `postgres/schema.sql` ganhou a restrição; o gerador não emite duas
vezes a chave que a lista já declara). O `tests/Caracterizacao/fixtures.sql` não mudou: os dois bancos da suíte nascem do
`mysql.sql` e do `schema.sql`, e as dez linhas do gerente não se repetem.

**Na produção, no passo 3 do corte** -- a etapa 2, pela resposta 192 (a); nunca no laço do passo 0, que a etapa 1 copia:
`mysql franquiabv < scripts/migracao-privilegios-unicos.sql`, depois da limpeza (o bloco 5 dela tira todo molde repetido,
ficando o mais antigo, e lista antes o de valores diferentes -- desde a resposta 237 (c) do dono, de 17/10/2026, o D374). A
migração junta o molde repetido que chegar a ela e as gêmeas numa transação, pela mesma regra, e só depois cria as duas
chaves; o procedimento manda contar antes, na réplica, quantos moldes repetidos de valores diferentes há. De 17 a 18/10/2026 a
chave do molde nascia primeiro, e o molde repetido de valores diferentes parava o roteiro antes de juntar qualquer gêmea (ver
a nota de 18/10/2026 no fim desta seção). O portão do passo 0 aceita as duas chaves do `mysql.sql` pela linha `| 0 | **Roda no passo 3` dela, só com
as duas metades (a afirmação 8 de `scripts/conferir-passo-zero-do-corte.php`): a migração as cria, e o gerador rodado sobre
o dump de produção ainda as emite. O `scripts/ensaiar-sql-do-passo-3.php` a ensaia com dados inventados.

**E a escrita, com a chave:** a gravação que chega junto com outra do mesmo par sem linha -- as duas leem "sem linha" --
deixou de gravar a gêmea. A linha nova nasce direto pela conexão (`UsuarioService::linhaNova`), depois da trava da pessoa;
o banco recusa a segunda, e a gravação volta atrás inteira com `ParGravadoNoMeio`: a API responde 409 `mudou`, a janela do
ERP e o "Novo" do molde dizem a frase (`UsuarioService::PAR_GRAVADO_NO_MEIO`), e a propagação do molde novo volta com o
molde. Pelo `flush`, a recusa fecharia o EntityManager, e o pedido não teria como responder. Sem ponto de salvamento em volta
do `INSERT`: depois da recusa a gravação inteira volta atrás, e por isso nenhuma consulta pode rodar até o `rollback` de quem
abriu a transação -- no PostgreSQL a recusa a aborta. Sem a
chave -- a produção até o passo 3, a base do teste do BVIP até a próxima cópia --, a corrida grava a gêmea, como antes, e o
código continua a somá-las e a juntá-las.

**A verificação da fatia (17/10/2026) achou e consertou quatro coisas aqui:**

- **A troca de grupo, o cadastro e a reativação davam 500 numa corrida, no PostgreSQL.** Elas gravam as linhas do molde pelo
  `flush` (`salvarPrivilegios`), e a janela do ERP gravando ao mesmo tempo uma linha nova de quem é do grupo de administrador
  -- ou um nível que não muda o efetivo -- não travava a pessoa: o `DELETE` da troca de grupo não via a linha ainda sem
  `commit`, o `INSERT` do molde esbarrava na chave, e o `flush` fechava o EntityManager. Desde a verificação a linha nova
  trava a pessoa mesmo quando ela não sai (`UsuarioService::travarAPessoa`): toda porta que cria linha da pessoa segura a
  trava dela, e uma espera o `commit` da outra. `PrivilegioDerrubaASessaoTest::testALinhaNovaTravaAPessoaMesmoQuandoElaNaoSai`
  (o `FOR UPDATE` antes do `INSERT`; sem a trava, vermelho nos dois bancos).
- **O caso da corrida não vigiava a regra de nenhuma consulta entre a recusa e o `rollback`.** A suíte punha um ponto de
  salvamento em cada nível (`transacaoComPontosDeSalvamento`), que a produção não tem: o de `gravarPrivilegio`, aninhada na
  gravação do molde, isolava a recusa, e uma consulta posta no `catch` passava verde nos dois bancos -- o "mutante sem o
  ponto ficou verde" que este texto citava como prova não media nada. Desde a verificação a porta da pessoa roda aninhada
  como na produção, sem ponto (`CaracterizacaoTestCase::aninhadaComoNaProducao`), e a consulta no `catch` do molde da API, no
  do ERP ou depois do `rollback` da pessoa fica vermelha no PostgreSQL (no MySQL a recusa não aborta a transação).
- **As gêmeas voltaram aos casos, gravadas de verdade** (o D515): numa tabela temporária sem a chave, que esconde a de
  verdade dentro da transação do caso (`CaracterizacaoTestCase::semAChaveDasPessoas`). A gêmea em memória dos casos de antes
  era uma entidade nova, e o `remove` dela não fazia nada: tirar a gêmea ao gravar, ou tirar só a primeira ao excluir, ficava
  verde.
- **O passo 3 nunca parava no molde repetido de valores diferentes**: o bloco 5 da limpeza, que roda antes, tirava qualquer
  molde repetido (o D374).

**Provado** nos dois bancos: `PrivilegioDaTelaNovaTest` (o banco recusa a segunda linha e o segundo molde; a gravação na
corrida não grava, não tira a pessoa, e a seguinte grava; e, com as gêmeas na tabela sem a chave, a soma, a ordem do id, a
gravação que deixa a mais antiga e a exclusão do par inteiro), `PrivilegiosPelosControllersTest` (a corrida pelo `PUT` e pela
janela; com as gêmeas, o par somado e o par inteiro pela API e pela janela, e a lista com o par numa entrada),
`PrivilegioPropagacaoTest` (com as gêmeas: a em dia vira uma linha, a diferente é comparada pelo efetivo, a exclusão tira o
par), `MoldePelosControllersTest` (o molde novo na corrida volta atrás inteiro; com as gêmeas, o `JUNTA` e o `repetidas`) e
`PrivilegiosDoErpTest` (o "Novo" na corrida). **O que segue sem prova:** o D515.

*Atualizado em 18/10/2026 (a resposta 237 (c) do dono, de 17/10/2026, "Fica o mais antigo"; o D374):* o molde repetido de valores diferentes
deixou de parar o passo 3. O bloco 5 da limpeza tira todo molde repetido, ficando o mais antigo, e lista o de valores
diferentes; a migração faz o mesmo com o que chegar a ela, na transação das gêmeas, e só depois cria as duas chaves. A regra das
gêmeas das pessoas (a mais antiga com o OU) não mudou.


### D290 — A API do ERP não tinha barreira contra pedido de outra origem — CORRIGIDO em 29/09/2026

**Achado pela leitura da frente de usuários.** `/adm/api` autentica pela sessão do
ERP, e o cookie sai sem `SameSite` (o D198): um site qualquer aberto com a sessão
viva podia fazer o navegador do operador mandar um `PUT` de privilégio, uma compra de
crédito, um cancelamento de venda. Não havia barreira nenhuma.

**O conserto:** `ApiAdmController::onDispatch` recusa, antes de qualquer ação, a
escrita que vem de outra origem (`ISBase\Util\OrigemDoPedido`): com
`Sec-Fetch-Site`, só `same-origin` e `none` passam; sem ele, o `Origin` precisa ser
o próprio `Host`. Sem os dois, passa -- não é navegador (o `curl` dos ensaios, a
integração). 403 com `detalhes.motivo: origem`.

**Os cabeçalhos são lidos crus, e só quando o verbo escreve**: o leitor tipado do
Laminas lançava exceção para `Origin: chrome-extension://…`, e um GET de extensão do
navegador virava 500 (a revisão adversarial).

**O que continua:** o `SameSite` do D198 é a outra metade, e continua registrado lá. (Aplicado em 01/10/2026, no D198.)
**A janela de privilégios do ERP não tem esta barreira** -- ela não é de `/adm/api` --,
e é a porta mais sensível do ERP a um pedido forjado: um administrador vítima dele
concede funcionalidade de rede. Fica no D198, junto da decisão do token. Quando o
front for para uma origem própria, a barreira vai junto com o token (a decisão de
29/08/2026).

**Provado:** `OrigemDoPedidoTest` (a regra, 4 casos), a barreira no `dispatch()` em
`PrivilegiosPelosControllersTest`, e os casos 09 a 15 do `ensaiar-usuarios-adm-api.php`
-- que passou a rodar no CI -- (a escrita de fora é 403 sem chegar à ação, a da mesma
origem chega, a leitura nunca é barrada, e a origem de extensão não derruba nada).

**08/10/2026, a entrada do ERP inteiro (o item ERP-BARREIRA-NA-ENTRADA, a pergunta 38 do lote).** A
mesma regra (`OrigemDoPedido::recusaDoPedido`, sem cópia) vale agora para toda escrita do ERP antigo,
num ouvinte do despacho (`ISConfiguracao\Permissions\BarreiraDeOrigem`), e não só para a `/adm/api` e
para as cinco ações que a chamavam -- essas cinco chamadas saíram. A `/adm/api` continua com a dela, no
`onDispatch` deste débito, com a mesma resposta: o ouvinte a pula pelo controller (`ApiAdmController`).
O leitor cru dos cabeçalhos ficou público (`OrigemDoPedido::cabecalho`), para a barreira escolher a forma
da recusa. Ver o fim do D315.


### D291 — O privilégio alterado só vale no próximo login da pessoa — REGISTRADO em 29/09/2026 — CORRIGIDO em 17/10/2026: quem muda de privilégio sai do sistema (a 102 b)

**Achado pela leitura da frente de usuários.** O `SessaoAcl` é montado na entrada e
guardado na sessão: tirar um privilégio não tira o acesso de quem já está logado,
até ele sair e entrar de novo. Vale para as duas telas, a antiga e a nova.

**O que a tela nova faz:** diz, antes de gravar, que o que mudar vale a partir do
próximo login, e a frase de cada gravação repete.

**O que decidir:** uma VERSÃO do controle de acesso (um número por usuário, subido a
cada gravação e conferido a cada pedido) faria a revogação valer no pedido seguinte.
Mexe no `SessaoAcl` de todas as telas, e fica para quando for decidido.

#### CORRIGIDO em 17/10/2026 -- a resposta 102 (b), na fatia PRIVILEGIO-DERRUBA-A-SESSAO

**A resposta:** a 102 (b), *"A pessoa sai do sistema quando a permissão muda. Sem coluna; ela entra de novo digitando a
senha (o 'lembrar' do aparelho deixa de valer), já com as novas. A mudança do molde de um grupo tira todos dele (177
pessoas no exemplo do desenho)."* A recomendação era a (a), a versão por pessoa do texto acima; o dono escolheu a que não
pede coluna.

**O conserto, sem coluna:** o `token_web` da pessoa sai do banco na mesma gravação que muda o acesso dela
(`UsuarioService::tirarDoSistema`), como na senha nova pelo link (o D347) e na desativação (o D294). Toda sessão dela
guarda o token do login; a guarda de despacho (`UmDispositivoPorVez`, em toda página do ERP, em todo pedido da `/adm/api`
e no `validarHash` de 48 s da página antiga) acha o token diferente do banco e encerra a sessão no pedido seguinte, com a
frase do D347 ("Atualizamos o sistema, pra isso precisamos que você faça login novamente."); a API responde 401, e a tela
nova manda entrar. O cookie de entrar sem senha deixa de achar a pessoa. Ela entra com a senha, e o login monta a sessão
com o que vale agora. Na mesma transação das linhas e antes delas, a pessoa é relida do banco com a trava da linha (`FOR
UPDATE`), e o token que estiver lá sai: o login que gravou o token antes perde-o, e a sessão dele cai; o que vem depois
espera a trava e lê as linhas novas (ver "A verificação", abaixo).

**Quem sai:** a pergunta é uma só, `RegraDoPrivilegio::saiDoSistema` -- o efetivo da pessoa (o OU das linhas, como o
`Acl`) mudou, e ela não é do grupo de administrador. As portas:

- o `PUT` e a exclusão de `/adm/api/usuarios/:id/privilegios/:funcionalidade`, e o salvar e o remover da janela de
  permissões do ERP -- todos por `UsuarioService::gravarPrivilegio` e `removerPrivilegio`;
- o molde do grupo, pela API (a fatia 9) e pela tela antiga: a propagação grava pelas mesmas duas portas, e sai quem tem a
  linha regravada ou apagada com o efetivo mudado -- "todos dele" que mudam, os 177 do exemplo. O alcance e a gravação
  devolvem quantos são (`saem`, com as unidades), pela mesma conta (`PrivilegioService::destinos`); quase sempre é o
  `mudam`;
- a troca de grupo (`salvarPrivilegios`): mudam o grupo, os papéis e as linhas, e a pessoa sai sempre. A reativação e o
  cadastro passam pela mesma função; ali o token já é nulo, e o que sobrou de uma desativação de antes do D294 sai também.

**Não sai:** quem regrava o mesmo nível, quem perde ou ganha só uma linha "nada" (o molde "nada" que a tela antiga
deixou), quem é do grupo de administrador -- o `Acl` o libera em tudo, com linha ou sem, e sem isto o administrador que
mudasse um molde do grupo de administrador pela tela antiga tiraria todos os administradores, ele inclusive --, quem muda
(ninguém altera os próprios privilégios nem o próprio grupo) e as outras pessoas da unidade.

**As frases:** a aba Privilégios da tela nova dizia "O que mudar aqui vale a partir do próximo login de Ana", e cada
gravação "Vale no próximo login da pessoa"; agora "Ao gravar, Ana sai do sistema, se estiver nele, e entra de novo
digitando a senha, já com o que mudou" e "A pessoa sai do sistema, se estiver nele, e entra de novo com a senha"
(`privilegioDaTela.ts`, `avisoAntesDeGravar` e `fraseDaGravacao`; a prévia do Claude Design também). A tela antiga não
dizia nada; a janela de permissões (no salvar e no remover, com o "se", e, quando a pessoa é do grupo de administrador,
que ela não sai), o formulário do molde (novo e editar), o "Remover" da lista dos moldes e o campo "Grupo" da edição
ganharam a frase, uma por porta (`RegraDoPrivilegio::AVISO_DA_TELA_ANTIGA`). A
da tela de grupos é da fatia 12, e está no desenho (`docs/f3-telas-restantes/tela-acl.md`, seção 1.1). Sem esquema novo.

**Fica de fora:** a personificação aberta guarda o token de quem a abriu (o D315), e segue com o que a pessoa assumida
podia quando foi assumida -- o D514. E a frase que a pessoa vê ao sair é a do D347, que não diz por quê.

**Provado:** `PrivilegioDerrubaASessaoTest` (8 casos, novo: a regra pura; o `PUT` da API com as três pontas -- o token
no banco, a guarda de despacho chamada como o evento a chama, com a frase, e o cookie --; o mesmo nível, que é o controle
das três; a exclusão e a linha "nada"; o grupo de administrador; o salvar e o remover da janela do ERP; a troca de grupo,
com a sessão aberta caindo; e a reativação) e três casos no `MoldePelosControllersTest` (quem acompanha o molde sai, e quem
coincide, quem continua sem, o inativo e quem pede ficam; o molde "nada" que sai muda a linha e não tira ninguém; o molde
do grupo de administrador pela tela antiga, e o alcance de tirá-lo), mais "o alcance diz o que a gravação faz" conferindo
`saem` contra os tokens apagados nos quatro passos. Os testes da fatia e os vizinhos, 317 casos no MySQL e 317 no
PostgreSQL, saída 0; o front inteiro (1.971), o build e o lint (no piso). 17 mutantes, um de cada vez e restaurados byte a
byte, todos vermelhos no caso dele (14 do servidor, 3 do front). **Por HTTP**, o caso 24 do `ensaiar-sessao-do-erp.php`: o
gerente entra, o administrador do ensaio lhe dá "ler" numa funcionalidade pela janela de permissões do ERP, e a sessão do
gerente responde 401 no pedido seguinte e o cookie dele cai no formulário; quem mudou segue (200), e salvar o mesmo nível de
novo, num login novo, não tira ninguém -- 24 de 24 nos dois bancos; falsificado com a chamada arrancada de
`gravarPrivilegio`: a sessão seguiu 200 e o cookie entrou (302), 23 de 24. O contrato (74 e 74) e os doze ensaios nos dois
bancos (mais o `medir-adm-api-no-postgres`), rodados antes do caso 24, saída 0: nenhum roteiro tinha uma sessão que caísse
pela mudança -- os ensaios dão privilégio por SQL, antes do login; a única reescrita pelo serviço é a reativação do
colaborador no `ensaiar-usuarios-adm-api.php` (o caso 19), que não está logado ali.

**A verificação (17/10/2026), em três lentes, confirmou dez apontamentos, sete distintos (três deles nas frases da tela antiga):**

- **O login no meio da gravação, com o token lido nulo.** O primeiro conserto apagava o token pelo objeto, e o `flush` só
  grava o campo que mudou desde que a pessoa foi lida. Se ela tinha clicado em "Sair" -- ou se uma mudança anterior do
  molde já a tinha tirado --, o token lido era nulo; se ela entrasse pela senha depois da leitura (na propagação, a
  leitura é a do começo do lote; na troca de grupo, a do começo da edição; na janela do ERP que muda a linha de
  funcionalidade, o par antigo sai numa segunda transação, com o objeto já sem token), nulo sobre nulo não saía `UPDATE`
  nenhum, e a sessão nova ficava com o privilégio de antes até o próximo login -- o defeito deste débito, de volta. Agora
  `tirarDoSistema` relê a pessoa com `FOR UPDATE` -- a leitura que trava, e que no MySQL vê o último `commit`, e não a
  foto da transação (na propagação, a do começo do lote) -- e só então apaga o token, que é o do banco; a linha fica
  travada até o `commit`. Não é um `UPDATE` direto: o horário da pessoa mora na mesma linha, e o
  `conferir-escritores-do-horario.php` o recusa (a primeira versão da correção foi um, e o portão ficou vermelho). O
  `removerPrivilegio`, que não tinha transação, ganhou uma, como o `gravarPrivilegio`: a trava só vale dentro de uma.
- **A troca de grupo e o cadastro fora da transação.** O `atualizarDados` e o `inserir` faziam o `commit` e só depois
  refaziam as linhas pelo molde, cada uma no seu `commit`: quem entrasse no meio lia o molde pela metade, e a falha no
  meio deixava o grupo novo com parte do molde. Agora o molde vai na transação deles, com o token antes das linhas, como
  na reativação. O texto deste débito dizia "na mesma transação", e só a reativação era.
- **As frases da tela antiga.** A da janela dizia, sem condição, "Ao salvar ou remover, a pessoa sai do sistema", e só no
  modal de salvar; o servidor não tira quem é do grupo de administrador, nem quem salva o mesmo nível ou remove uma linha
  "nada". O "Remover" da janela e o da lista dos moldes -- o que mais tira gente: o molde 159 leva 177 pessoas -- não
  diziam nada. E nenhuma das frases tinha caso. Agora cada porta tem a sua, e diz o que o servidor faz nela.
- **O desenho.** A lista do que o alcance devolve (`tela-acl.md`, seção 2.3) não tinha o `saem`, e o exemplo da seção 1.1
  contava pelas que acompanham. Consertados.
- **O Artifact do roadmap**, que é do ciclo.

**Provado na verificação:** seis casos novos no `PrivilegioDerrubaASessaoTest` (14) -- o login no meio do `PUT` e da
janela que muda a linha de funcionalidade, com o token lido nulo; a pessoa relida com a trava antes das linhas e do token
(o SQL que sai, pelo `PUT` e pelo remover); o login no meio da troca de grupo; a troca de grupo e o cadastro desfeitos
inteiros pela falha forçada na linha do molde (o grupo, o token e as linhas; o cadastro não fica de pé); o remover, que
abre a transação antes da trava e é desfeito inteiro, com o token; e a tela antiga desenhada, porta a porta, com o grupo
de administrador -- e um no `MoldePelosControllersTest` (30), o login no meio da propagação. 13 mutantes, um de cada vez
e restaurados byte a byte, todos vermelhos no caso dele: o token pelo objeto lido (os três do login no meio e o da
trava); a pessoa relida sem a trava; o molde sem linha sem o `flush` do fim; o remover sem transação; a troca de grupo
e o cadastro com o molde depois do `commit`; e sete nas frases (a da janela também para o grupo de
administrador; sem a do remover, sem o aviso do administrador, sem a da lista dos moldes, a do novo, a do editar e a do
campo "Grupo"). O que não se mede aqui: dois pedidos de verdade ao mesmo tempo -- o caso simula o login no meio
gravando o token por fora do objeto, que é o que o login faz.


### D292 — O administrador via na API só os usuários da unidade em que entrou — CORRIGIDO em 29/09/2026

**Achado pela leitura da frente de usuários.** A listagem da API mandava sempre a
unidade da sessão ao repositório, e o ramo do administrador filtra por ela quando
vem: o administrador não tinha como ver outra franquia nem a rede, como a tela antiga
deixa (D148).

**O conserto:** `?unidade=` escolhe, só para o administrador -- o id de uma unidade
ou `todas`; sem ele, a da sessão. Para os outros, outra unidade ou `todas` é 422,
com a mesma regra dos cupons. E duas listas novas para os filtros:
`GET /adm/api/usuarios/unidades` e `GET /adm/api/usuarios/grupos` (sem o grupo de
administrador para quem não é).

**Provado:** os casos 01 a 06 do `ensaiar-usuarios-adm-api.php`, e as três rotas
novas no `ensaiar-permissao-adm-api.php` (403 para quem não tem o privilégio).


### D293 — Gravar um privilégio numa tela velha desfazia, em silêncio, o que outra aba tinha gravado — CORRIGIDO em 29/09/2026

**Achado pela revisão adversarial da tela nova de usuários.** O `PUT` do privilégio
leva os dois níveis, e a tela os tira do que leu ao abrir a pessoa. Se outra aba -- ou
a tela antiga, que continua gravando -- tirasse o "escrever" nesse meio-tempo,
desmarcar o "ler" na tela velha mandava `{ler: false, escrever: true}` e religava o
"escrever"; excluir o par levava junto o nível que outra pessoa acabara de dar.

**O conserto:** o `PUT` e a exclusão aceitam `antes` -- o que a tela mostrava --, e o
banco diferente é **409** com `motivo: mudou`; a tela relê por baixo e diz. Sem
`antes`, vale o contrato de antes (o ERP não o manda).

**Provado:** `PrivilegiosPelosControllersTest` (o 409 sem gravar, o igual que passa, o
malformado que é 422) e `UsuariosLigado.teste.tsx` (o corpo com `antes`, e o 409 que
relê).


### D294 — Desativar usuário era um link que alternava, a sessão de quem era desativado seguia aberta, e o login ganhava `_old` a cada vez — CORRIGIDO em 29/09/2026

**Achado pela leitura da fatia 2a da tela nova de usuários, e pela revisão adversarial
dela.** Cinco defeitos na mesma ação:

- o botão da lista antiga apontava para `GET /mod-configuracoes/usuarios/ativarRemover/ID`,
  e a ação ALTERNAVA o estado: o link aberto por quem escreve desativava alguém, e duas
  navegações ao mesmo link -- a pré-carga, o duplo clique, o histórico -- se desfaziam
  (a família do D199, o molde do D263);
- nada impedia desativar a si mesmo;
- o login ganhava `_old` a cada desativação e não o perdia na reativação: a pessoa
  voltava ativa com um login que ela não sabe, e desativada duas vezes virava
  `x_old_old`. E duas `ana` desativadas em momentos diferentes davam dois `ana_old` --
  o login não tem índice único no banco (D295);
- **a sessão aberta de quem era desativado seguia valendo**, no ERP e na API nova, até
  a pessoa sair: a identidade guardada na sessão é só o id, relido do banco a cada
  pedido, e ninguém olhava o `status`. E o desativado com a senha certa, no formulário
  de entrada, dava 500 (`criarToken(null)`) no lugar do "Dados inválidos.";
- o campo "Status" da tela de editar gravava cru -- sem o sufixo e sem a trava de si
  mesmo: uma segunda porta para o mesmo estado.

**O conserto:**

- a ação do ERP só aceita POST, com o estado pedido; a API ganhou
  `POST /adm/api/usuarios/:id/ativacao` e `/desativacao`, com 409 para o que já está no
  estado pedido;
- ninguém ativa nem desativa a si mesmo, nem o administrador -- pelo botão, pela API ou
  pela edição;
- o sufixo entra uma vez, com número quando o `_old` já é de outra pessoa (`_old2`,
  `_old3`...), e **sai inteiro na reativação quando o login original está livre** -- se
  outra pessoa o pegou nesse meio-tempo, fica, e a tela diz (`RegraDoUsuario`, função
  pura; decisão minha, com o caminho de volta);
- a guarda de login (`ISConfiguracao\Module::handleAuthRedirect`) encerra a sessão de
  quem foi desativado no pedido seguinte: a rota do ERP cai no "entrar", e a API
  responde 401. O `token_web` sai na desativação, e o `credential_callable` recusa o
  desativado;
- a edição passa o estado pelo mesmo `UsuarioService::mudarEstado` do botão.
- a lista antiga diz "Desativado com sucesso." -- dizia "Excluído" de quem só saiu dos
  ativos (achado no navegador).

**O que continua:** o privilégio TIRADO de quem segue ativo só vale no próximo login
dele (D291). A desativação não depende mais disso. *(Até 17/10/2026: desde a resposta 102 (b) do
dono, quem tem o acesso mudado também sai no pedido seguinte, pelo mesmo `token_web` -- ver o fim do D291.)*

**Provado:** `RegraDoUsuarioTest` (9 casos, com o padrão do sufixo lido do arquivo do
front), `UsuarioAtivacaoTest` (14: a API, a tela antiga e a edição despachadas em
processo, a guarda de login chamada como o evento de despacho a chama, e o
`credential_callable` da própria configuração), a sessão aberta de verdade, por
HTTP, enquanto o gerente desativava no navegador (a API respondeu 401 e expirou o cookie; o ERP mostrou a entrada), os casos 16 a 19 do
`ensaiar-usuarios-adm-api.php`, os casos de "ativar e desativar" do
`UsuariosLigado.teste.tsx`, o `conferir-remocao-por-post.php` (a trava desceu de 17
para 16), e 38 de 38 mutantes mortos (21 do servidor, 17 do front; um sobreviveu à
primeira rodada -- o GET sem estado no corpo -- e ganhou o caso que o mata).

> **03/10/2026 -- a trava de si mesmo passou a conferir também quem entrou** (achado na revisão do
> D315, consertado no item SESSAO-SENHA-EMAIL): ela comparava só com o `usuarioId`, que na
> personificação é a pessoa assumida, e A, personificando um administrador X, desativava a si mesmo.
> As três portas e o `proprio` da tela nova perguntam agora a `CrudController::ehASiMesmo`. Ver o fim do
> D315. A trava irmã do D288 -- ninguém altera os próprios privilégios nem o próprio grupo -- tem o
> mesmo furo, e ficou registrada como D386.
>
> **06/10/2026 -- o botão da lista só da mesma origem** (a pergunta 3 do lote, decidida pelo dono): o POST
> de outro site, de outro subdomínio ou com o `Origin` de outro host é 403 antes de ler a pessoa, pela
> `OrigemDoPedido` (o D290), que a API já usava. Ver "A barreira de origem nos quatro botões", no fim do
> D315.


### D295 — O login e o e-mail de usuário não têm índice único no banco — REGISTRADO em 29/09/2026

**Achado pela revisão da fatia 2a de usuários.** `usuarios` só tem a chave primária e
os índices de `grupo` e `unidade`. Quem garante que dois usuários não têm o mesmo
login é a validação das telas (`validarCampoUnico`), que consulta e depois grava: dois
cadastros simultâneos com o mesmo login passam os dois. E o login repetido é pior que
um dado feio -- o adaptador de autenticação acha o usuário pelo login.

No `franquiabv_dev` de hoje não há login repetido, nem em caixa diferente.

**O que fazer:** um índice único em `login` (e decidir o de `email`) na migração
final, depois de conferir os dados de produção -- não se mexe no banco de produção
agora. A desativação já não cria repetidos (D294).

> **Desde 03/10/2026 a conferência existe:** `scripts/medir-logins-com-maiuscula.sql` conta, na
> réplica e sem imprimir login, os logins e e-mails repetidos que **só diferem pela caixa** -- no MySQL
> são um login só, no PostgreSQL dois, e baixar a caixa dos dois os torna iguais. O que fazer com eles
> é pergunta no lote (D31, D349), e o índice único deste débito espera a resposta.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 8): a opção (b), "Renomear o login do
inativo de cada par, e baixar a caixa de todos" -- o passo 3 do corte o faz, com os quatro `UPDATE` de
`scripts/medir-logins-com-maiuscula.sql` (o D31). O par de caixa some antes do PostgreSQL, e o índice
único deste débito deixa de esbarrar nele. Ele ainda esbarra no repetido EXATO de login (uma grafia só,
o D31), e no de e-mail: o índice continua esperando a conferência na produção, e a pergunta de estender
o renomear aos inativos desses está no lote (DECISOES-NO-REGISTRO-E-NO-CORTE). O índice não foi criado.

**Decidido pelo dono em 07/10/2026** (pergunta 32 da página do lote): a opção (a), "A mesma regra para
eles" -- "Sobra só o caso de dois ativos, que já é seu, e o índice fica possível depois da janela." O
passo 3 do corte passou a renomear também o inativo do repetido exato de login e de e-mail (o D31, desde
08/10/2026). O ensaio do passo 3 prova a frase da opção no banco descartável dele: depois do roteiro, o
que barra o índice único do login -- na chave da entrada do PostgreSQL, o minúsculo byte a byte -- são só
os dois ativos da 4c, e tirado um de cada (a decisão do dono, simulada), ele nasce. **O índice não foi
criado**, e criá-lo continua sendo o que este débito pede, na migração final, depois de o dono resolver os
dois ativos da 4c. No MySQL, a collation iguala também o acento e o espaço do fim, e o par de acento (a
pergunta 9) barraria lá um índice na coluna; no PostgreSQL, não. O de e-mail segue por decidir: além dos
dois ativos, os e-mails vazios se repetem, e o renomear não os toca. E uma sobra de texto (a revisão de
08/10/2026; o D31 a descreve): o docblock de `UsuarioRepository::selecionarPorLogin` ainda diz que o que
fazer com o par e com o repetido exato "e pergunta do dono" -- o código não mudou neste item.


### D296 — Em Arquivos, o endereço do link virava caminho em disco: o download entregava a configuração do servidor, e o envio a sobrescrevia — CORRIGIDO em 29/09/2026 — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: apagar do servidor os órfãos de public/arquivos, se o dono autorizar, na janela do corte; o token do formulário de Arquivos é o D198.

**Achado pelo mapa da etapa "marketing e aplicativo".** O cadastro de Arquivos guarda o
endereço do link na mesma coluna do nome físico (`arquivos.nome`). A edição que trocava
"Link" por "Arquivo" reaproveitava o que estava lá: o envio ia para
`arquivos/<o link de antes>`, e o "abrir" e o "baixar" liam `./public/arquivos/` mais o
nome gravado, sem olhar o nome. Com o link `../../config/autoload/local.php`, o download
entregava a configuração do servidor -- e o envio a sobrescrevia; com `../x.php`, o
arquivo caía em `public/`, onde o nginx executa PHP. E o `Armazenamento`, por onde
passam banners, capas e certificados, colava qualquer caminho na raiz.

De quebra, no mesmo caminho: o link aceitava qualquer coisa, e o "abrir" seguia
`javascript:` com o domínio do ERP; o envio aceitava qualquer extensão; o nome original
ia cru para o `Content-Disposition`; e trocar o arquivo deixava um xlsx guardado como
`.pdf`.

**O conserto:**
- o `Armazenamento` recusa caminho com `..`, barra invertida, byte nulo ou esquema: a
  escrita é erro, a leitura é "não existe" -- nos dois drivers;
- o nome físico é sempre gerado pelo servidor, a cada arquivo guardado, e o de antes sai
  do disco (o nginx serve `/arquivos/<nome>` sem sessão);
- a entrega só lê o nome com a forma de um nome gerado -- as 109 linhas de hoje casam --,
  pelo `Armazenamento`;
- o link é só http(s), sem aspas nem sinais de maior e menor, no salvar e no "abrir"; e
  o botão "Abrir Link" das duas listas antigas passa pelo "abrir", em vez de imprimir o
  endereço cru num `href`;
- o envio aceita pdf, xlsx e xlsm (102, 5 e 2 hoje), e o tipo é 0 ou 1;
- o `Content-Disposition` leva o nome limpo, com `filename*`, e a resposta vai com
  `nosniff` (`RegraDoArquivo`);
- a entrega lê o conteúdo por `Armazenamento::conteudo()`, que no S3 não deixa uma cópia
  do documento no disco a cada download;
- as duas listas antigas escapam o nome, a descrição e a categoria.

**O que continua:** o formulário de Arquivos não valida o token `security` (a tela não o
desenha, e validar quebraria o envio) -- é o D198. E os órfãos de `public/arquivos`
continuam lá: apagá-los é pergunta ao dono.

**Provado:** `ArmazenamentoConfinadoTest` (o disco e o S3, este contra uma porta fechada:
a recusa vem antes da rede), `RegraDoArquivoTest` e `ArquivosTravessiaTest` (o "abrir", o
"baixar" e o "salvar" do ERP despachados em processo, com um armazenamento de mentira e um
`local.php` falso ao lado da raiz -- onde a travessia chegaria; e o serviço sozinho, sem a
tela na frente).


### D297 — A audiência das campanhas de marketing voltava do navegador e era gravada como veio — CORRIGIDO em 29/09/2026 — DECIDIDO em 25/10/2026 (255 b, 256 c, 259 a, 260 c, 261 b)

**Achado pelo mapa da etapa "marketing e aplicativo".** A tela de campanhas pesquisa a
audiência no servidor e DEVOLVE A LISTA DE IDS ao navegador, que a manda de volta no
"salvar" -- e o servidor gravava a lista como veio. O teto de 200 contatos do WhatsApp
conferia o total que o navegador dizia; o recorte da franquia e o "nunca enviar a quem
desligou o marketing" valiam só na pesquisa. Uma lista escrita à mão mandava WhatsApp,
com o nome da barbearia, a qualquer cliente da rede. E quem desligava o marketing depois
do agendamento recebia mesmo assim: a rotina de envio não conferia de novo.

De quebra: a edição não conferia a unidade da campanha (o D237); a edição não trazia o
destinatário de volta, e a campanha de usuários virava de clientes; o tipo que não era
`push` saía por WhatsApp; e o cancelar era um link.

**O conserto:** o "salvar" refaz a audiência no servidor com a mesma regra da pesquisa --
o cliente ativo, com o marketing ligado, da última visita na unidade de quem não é
administrador (e que já abriu o aplicativo, no push); o usuário ativo da unidade -- e
grava a lista e o total que o servidor aceita, com o teto de 200 sobre essa conta. A
rotina de envio pergunta, id a id, se o destinatário ainda recebe
(`NotificacaoService::destinatarioParaEnvio`): quem desligou o marketing ou foi
desativado, não. A edição confere a unidade, usa o destinatário gravado e **guarda a lista
gravada** -- não acrescenta ninguém, e não recorta pela unidade de quem edita (a campanha
que o administrador fez para a rede perdia os clientes das outras unidades). Só se edita e
se cancela o que não começou a sair. Tipo e destinatário são de lista fechada, e
"usuários" é de quem a tela o oferece: o gerente e o administrador. O cancelar é por POST.

**O que continua:** a tela de campanhas está parada até a decisão sobre o WhatsApp
(76% das campanhas de 2026) -- o push é do OneSignal, que fica fora da migração. Os ids
da audiência continuam indo ao navegador na tela antiga; a nova não os devolverá.

**Provado:** `CampanhaAudienciaTest` (o "salvar" e o "cancelar" do ERP despachados em
processo: a lista escrita à mão perde quem não devia estar; o administrador alcança a
rede, mas nunca quem desligou; o teto conta a lista do servidor; a campanha de outra
unidade não se edita; a de usuários continua de usuários; a edição guarda a lista; o que
começou a sair não se edita nem se cancela; o teto na fronteira de 200) e
`RegrasDoMarketingTest`. A rotina de envio não roda nos testes -- ela fala com o WhatsApp
e o OneSignal de verdade --; a pergunta que ela faz a cada id, sim.

**As respostas de 25/10/2026 (a fatia 40b; as 255 a 261 da página).** O desenho da tela das campanhas
(`docs/f3-telas-restantes/tela-notificacoes.md`), feito porque a tela que este débito dizia parada em "O que continua" não
esperava mais nada, mandou ao lote as perguntas da tela nova, e o dono as respondeu: a campanha para os colaboradores **fica** na tela nova, para o gerente e o administrador, só por
WhatsApp (255 b); o administrador **escolhe a unidade**, e a campanha passa a ser dela, pelo WhatsApp dela -- "a rede
inteira" sai das duas telas (256 c); o texto **se mede na produção**, só com números, pelo
`scripts/medir-texto-das-campanhas.sql`, que o dono roda (259 a); há um **limite por unidade e por dia, e o mesmo cliente
não recebe duas campanhas no dia** -- o segundo sai da lista (260 c); e um **"mandar para mim primeiro"** pelo WhatsApp da
unidade ao telefone de quem cria (261 b). **Uma parte do conserto deste débito muda** (a verificação da 40b): o "o
administrador alcança a rede, mas nunca quem desligou" sai pela 256 (c) -- a campanha do administrador passa a ser de uma
unidade, com a lista só dos clientes dela, nas duas telas --, e o caso que o prova
(`tests/Caracterizacao/CampanhaAudienciaTest.php:110-116`, "a rede inteira, mas nunca quem desligou") é reescrito na 92a,
no mesmo commit; o caso da edição que guarda a lista da rede (`:253-274`) fica, para as campanhas já gravadas. O resto do
conserto -- a audiência refeita no servidor, o teto pela lista do servidor, quem desligou fora na hora do envio -- não
muda. As respostas são regras novas da tela, e entram na fila 92 (a 92a e a 92b no ERP antigo e na rotina, a 92c e a 92d na
API, a 92e e a 92f na tela; a 92g espera a medida do texto). O número do limite do dia (o exemplo, 300) voltou ao dono numa pergunta do lote; até a resposta, 300.

**A 260 (c) na tela antiga e na rotina (26/10/2026, a fatia 92b).** O "Salvar" grava pelo `NotificacaoService::agendar`:
sob a linha da unidade travada (`PESSIMISTIC_WRITE`), a campanha de clientes perde quem já está na lista de outra campanha
de WhatsApp de clientes daquele dia que ainda vai sair (a pausada, a agendada e a que está saindo; a `enviado` não, desde a
verificação: o que sobrou na lista dela não recebeu), de qualquer unidade, ou já recebeu uma (`jaRecebemNoDia`, as duas
fontes só com `destinatario = 'cliente'`: a linha de envio da da equipe guarda o id do usuário na mesma coluna); o limite
do dia (`RegraDaCampanha::LIMITE_POR_DIA`, 300, com o comentário que cita a pergunta 267) soma as campanhas de WhatsApp não
canceladas da unidade, de clientes e da equipe, sem a editada; a edição recorta pelo dia novo. A tela diz quem saiu
depois de recarregar, e recusa com a frase do limite. Na hora do envio, o `destinatarioParaEnvio` pula o cliente que já
recebeu hoje outra campanha de WhatsApp de clientes (conta como "não recebeu"). Provado no `CampanhaDoDiaTest`, nos dois
bancos, com os mutantes. A API (a 92c e a 92d) usa o mesmo `agendar`; o "Contar" com o dia é da 92c.

**A verificação da 92b (26/10/2026)** achou que a lista "ainda por mandar" lia também a campanha `enviado`: quem sobrou
nela -- o cliente que a rotina pulou por este débito, ou quem faltou na parada encerrada (a 258 (a)) -- ficava barrado no
dia sem ter recebido nada, e a frase dizia "já recebem outra campanha". Agora a lista é só das que ainda vão sair
(`NotificacaoRepository::POR_MANDAR`), e as duas frases dizem "já está em outra campanha de dd/mm/aaaa (por sair, atrasada
ou já recebida)". A atrasada continua segurando o dia (a letra do desenho, "não cancelada"); se deve, foi ao dono
(26/10/2026, no D609). Provado no `CampanhaDoDiaTest::testAListaDaEnviadaNaoSeguraODia`, nos dois bancos, com dois
mutantes (a `enviado` de volta na conta; a pausada fora dela).

**O "Contar" da porta nova sem os ids (26/10/2026, a fatia 92c).** O `GET /adm/api/notificacoes/audiencia` refaz a
pesquisa da tela antiga no servidor (`ClienteRepository::marketingPesquisarClientes`, com o marketing ligado e a unidade
da campanha; a da equipe pelo `UsuarioRepository::marketingPesquisarUsuarios`), com cada filtro numa lista fechada
(`ISMarketing\Regra\FiltrosDaAudiencia`: o valor fora das opções, o dia que não existe, o dinheiro que não é decimal, a
idade de um lado só e o filtro do outro destinatário são 422 com o nome), e devolve SÓ OS NÚMEROS -- o total, quem já
recebe no dia (a mesma `NotificacaoRepository::jaRecebemNoDia` do `agendar`, em lotes de mil ids), quantos ficam, o teto,
se cabe, e o dia da unidade (`::doDiaDaUnidade`, a mesma conta do limite). Os ids nunca saem do servidor nessa porta. Os
23 filtros da pesquisa rodaram, pela primeira vez, no PostgreSQL (o `NotificacaoApiTest`, um por um, verdes nos dois
bancos). **Os filtros são só os que a tela antiga mostra** (a verificação da 92c, 26/10/2026): a porta nova oferecia também
o `fidelizado_unidade`, que o formulário do relatório de clientes tem e a tela das campanhas nunca mostrou; saiu, e o
`NotificacaoApiTest::testOsFiltrosSaoOsDaTelaAntiga` compara a lista da regra com os campos da `index.phtml`. O lote de
mil ids é o que deixa o "Contar" de pé acima dos 65.535 parâmetros do PostgreSQL
(`::testOContarEmLotesPassaDoTetoDoPostgres`; no MySQL, o prepare emulado do PDO aceita o `IN` inteiro). A tela antiga continua mandando a lista ao navegador (o N4 do desenho) até sair do menu; a gravação pela porta
nova é da 92d, e o cadastro da 92f.

**A API de escrita (26/10/2026, a fatia 92d, NOTIFICACOES-API-ESCRITA).** O `POST /adm/api/notificacoes` não recebe
lista nenhuma: os filtros vão no corpo (`audiencia`), e o servidor refaz a pesquisa da tela antiga, confere quem ainda
recebe e o teto, e grava pelo `NotificacaoService::agendar` (o "mesmo cliente" e o limite do dia, a 260 c); a lista de ids
no corpo é recusada com o nome. A campanha da equipe (a 255 b) e a unidade escolhida pelo administrador (a 256 c) valem
também ali, e o "mandar para mim primeiro" (a 261 b) é o `POST .../teste`, pelo WhatsApp da unidade ao telefone de quem
pede, sem gravar nada -- nos testes, pelo cliente que anota; no ensaio, pelo provedor de mentira do loopback, com a guarda
que sai com 2 sem pedir nada quando o endereço não é o dele. **O que continua**: a tela nova (a 92e e a 92f).

**A tela nova (26/10/2026, a fatia 92f, NOTIFICACOES-TELA-CADASTRO).** O cadastro da tela nova manda os filtros e nunca a
lista: o "Contar" mostra só os números (quem recebe, quem sai da lista por já receber outra campanha no dia, o limite da
unidade), e o corpo do agendar leva os filtros do destinatário escolhido -- o caso
`NotificacoesLigado.cadastro.teste.tsx`, "agendar manda os filtros e a unidadeNaTela", confere o corpo inteiro, sem ids nem
total, e um mutante que acrescenta o total ao corpo fica vermelho. A edição não manda lista nenhuma (só o que mudou). A
campanha da equipe só a quem a cria, e a unidade só ao administrador, pelo que o servidor devolve nas opções. **O que
continua**: a tela antiga manda a lista ao navegador (o N4 do desenho) enquanto estiver no menu; a 259 (a) espera a medida
do dono (a 92g).

### D298 — Desconectar o WhatsApp da unidade era um link — CORRIGIDO em 29/09/2026

**Achado pelo mapa da etapa "marketing e aplicativo".** O "Desconectar" da tela da
instância era um `<a href>` para uma ação que aceitava GET: a pré-carga do navegador, o
histórico ou uma imagem numa página de fora derrubavam o WhatsApp da unidade -- e as
mensagens do período desconectado se perdem, não esperam (a rotina tira da fila sem olhar
o resultado). Fora do portão do D199, porque não passava pelo modal.

**O conserto:** a ação exige POST, e o botão é um formulário. O
`conferir-remocao-por-post.php` ganhou a lista das ações de estado sem modal
(`ACOES_SO_POST`): a guarda tem de ser o primeiro comando da ação, e a tela tem de ter o
formulário POST e nenhum link para ela. E o leitor da guarda, nos dois portões que o têm,
passou a ignorar comentários: a guarda comentada ficava verde.

**Provado:** `EstadoPorPostNoMarketingTest` (com um cliente de WhatsApp de mentira: o GET
não chega a ele, o POST chega).

> **06/10/2026 -- só da mesma origem** (a pergunta 3 do lote, decidida pelo dono): o POST de outro site,
> de outro subdomínio ou com o `Origin` de outro host é 403 antes de falar com a instância, pela
> `OrigemDoPedido` (o D290); a guarda de POST continua o primeiro comando. Ver "A barreira de origem nos
> quatro botões", no fim do D315.


### D299 — Cancelar um sorteio derrubava o que já tinha sido sorteado — CORRIGIDO em 29/09/2026

**Achado pelo mapa da etapa "marketing e aplicativo".** O "cancelar" comparava o estado
com `sorteado`, que não existe -- os estados são `ativo`, `aguardandoSorteio`,
`finalizado` e `cancelado`. O sorteio finalizado, com ganhador avisado, era cancelado. A
lista escondia o botão de quem tem cupom (pela mesma comparação errada, mostrava-o no
finalizado sem cupom), e o servidor não conferia. E era um link.

**O conserto:** uma regra só para a lista e para a ação (`RegraDoSorteio`): recusa o
finalizado, o cancelado e o que tem cupom de venda ATIVA, com a frase na tela -- cancelar a
venda não tira o cupom, e o sorteio só com cupons de vendas canceladas não sorteava nem se
cancelava (há dois assim na cópia). O cancelar é por POST.

**Provado:** `EstadoPorPostNoMarketingTest` e `RegrasDoMarketingTest`; a trava do
`conferir-remocao-por-post.php` desceu de 16 para 14 (este e o cancelar das campanhas).


### D300 — O link da campanha de WhatsApp nunca é enviado — REGISTRADO em 29/09/2026 — CORRIGIDO em 12/10/2026: o link vai no fim

**Achado pelo mapa da etapa "marketing e aplicativo".** A rotina de envio acrescenta o
link à mensagem quando ele está VAZIO: `if (empty($notificacao->getLink()))`
(`CronJobService.php`, em `marketingEnvios`). A campanha com link sai sem ele.

**Por que não foi consertado:** muda o texto que os clientes recebem, e a tela de
campanhas está parada até a decisão sobre o WhatsApp. É um caractere.

#### O conserto — 12/10/2026 (o item WHATSAPP-F0-E-CENTRAL; a resposta 116 do dono)

**Decidido pelo dono** (a resposta 116, a opção (a)): *"O link vai no fim da mensagem -- Vale para as campanhas que
ainda não saíram; as já enviadas não mudam."* A condição de `CronJobService::envioDeCampanha` virou
`if (!empty($notificacao->getLink()))`: o link vai depois de um espaço, no fim do texto, e a campanha sem link sai como
antes, sem o espaço. As enviadas não mudam porque nada as reenvia. **Provado** no `WhatsAppQueFalhaTest`
(`testOLinkDaCampanhaDeWhatsAppVaiNoFim`, a campanha de verdade pelo serviço e pelo `$enviar` da rotina, com o WhatsApp
de mentira que anota o texto), nos dois bancos; com o `empty` de volta, o caso fica vermelho.


### D301 — A rotina de campanhas diz "segunda a sábado" e envia no domingo — REGISTRADO em 29/09/2026 — CORRIGIDO em 12/10/2026: o domingo continua, e a frase fala só do horário

**Achado pelo mapa da etapa "marketing e aplicativo".** A janela de envio confere só a
hora (9h às 20h); a mensagem de recusa fala de "segunda a sábado", e nenhum código olha o
dia da semana. Decidir se o domingo sai é do dono.

#### O conserto — 12/10/2026 (o item WHATSAPP-F0-E-CENTRAL; a resposta 117 do dono)

**Decidido pelo dono** (a resposta 117, a opção (a)): *"O domingo continua -- A campanha sai no dia que o gerente marcou,
e a frase passa a falar só do horário."* O código já fazia isso; mudou a frase, que agora é
`CronJobService::FRASE_FORA_DO_HORARIO` ("Fora do horário permitido (das 09h às 20h)."), e a janela saiu do corpo de
`marketingEnvios` para `CronJobService::foraDoHorarioDasCampanhas`, que olha só a hora -- a rotina não roda em teste (chama
o WhatsApp de verdade), e a janela sem caso não tinha quem a segurasse. **Provado** no `WhatsAppQueFalhaTest`
(`testODomingoContinuaEAFraseFalaSoDoHorario`): o domingo às 10h e às 20h dentro, 8h59 e 20h01 fora, e a frase sem dia da
semana; a frase de antes de volta e um "domingo fora" acrescentado à janela, cada um vermelho.

**A verificação do item (12/10/2026)** achou que o caso provava a função e a constante, e não que a rotina usasse as duas:
trocar a frase de `marketingEnvios` pelo literal antigo, ou apagar o `if` da janela, não deixava nada vermelho. Agora a
janela vem **antes** da trava (fora do horário a rotina não toca na trava nem nas campanhas), o instante é do teste quando
ele o dá (`CronJobService::usarAgoraDasCampanhas`), e `WhatsAppQueFalhaTest::testARotinaDasCampanhasRecusaForaDoHorarioComAFraseNova`
chama a rotina às 8h59 e às 20h01 de um domingo e às 6h de uma segunda **com a trava dela tomada pelo caso**: sem a janela,
a rotina para na trava ("Processo já em execução"), e nada chega às campanhas nem sob o mutante; o WhatsApp é o de
mentira, e o HTTP do push lança. Só instantes fora do horário: dentro dele, só a trava seguraria a rotina. Os dois
mutantes (a frase de antes na rotina; o `if` da janela apagado), vermelhos.


### D302 — O WhatsApp que falha é contado como enviado — REGISTRADO em 29/09/2026 — CORRIGIDO em 12/10/2026: a falha fica registrada

**Achado pelo mapa da etapa "marketing e aplicativo".** A rotina de campanhas descarta o
retorno de `enviarMensagem`, que devolve erro em vez de lançar exceção (telefone vazio,
instância desconectada): o cliente sai da lista, o envio é contado e a campanha termina
"enviada". O mesmo vale para a fila `push_app`, que remove a mensagem sem olhar o
resultado -- e o código de acesso ao aplicativo passa por essa fila.

#### O conserto — 12/10/2026 (o item WHATSAPP-F0-E-CENTRAL; a resposta 115 do dono)

**Decidido pelo dono** (a resposta 115, a opção (b), e não a recomendada): *"Perdem-se, mas a falha fica registrada -- A
campanha mostra quantos não receberam, e a fila guarda a falha."* Sem nova tentativa, e sem esquema novo:

- **Na campanha**, o `$enviar` de `CronJobService::envioDeCampanha` devolve se o WhatsApp entregou (`enviarMensagem`
  devolve o erro em vez de lançar), e `NotificacaoService::enviarCampanha` não conta quem não recebeu: o cliente sai da
  lista (a rodada que retoma não o manda de novo), sem somar `total_envios` e sem a linha de `notificacoes_clientes`. A
  lista antiga (Marketing › Notificações) ganhou a coluna **"Não receberam"**: os contatos menos os envios, na campanha
  de WhatsApp enviada depois do conserto (a verificação, abaixo, tirou dela a campanha `enviando`, a de antes e a de push)
  -- o que inclui quem desligou o marketing depois do agendamento, que também não recebeu. Só
  `false` é falha: o push segue contado como antes (o despachante registra a falha dele no registro das rotinas), e a
  campanha que já saiu não muda.
- **Na fila**, `CronJobService::marketingPush` não apaga a linha de WhatsApp que falhou: ela fica, com a rotina trocada
  por `falhou:<a rotina>` (`PushApp::marcarFalha`), que nenhuma rotina lê, e sem a chave e a instância -- uma linha que
  não será enviada não guarda credencial (o D350). O registro é a própria linha: a rotina continua sem escrever no
  registro das rotinas (o D68).
  A linha do código de entrada do aplicativo sem o WhatsApp central configurado (o D59) falha do mesmo jeito.

**Provado** no `WhatsAppQueFalhaTest` (6 casos, nos dois bancos; a rotina e a campanha em processo, com o WhatsApp de
mentira -- `CronJobService::usarFabricaDeWhatsApp` -- que falha para um telefone escolhido: nada sai da máquina): a que saiu
sai da fila e a que falhou fica marcada e sem a chave, e a rodada seguinte não a tenta; a campanha de dois contatos termina
com 1 envio, a lista vazia, uma linha em `notificacoes_clientes` e "2, 1, 1" nas colunas da lista antiga, renderizada.
Sete mutantes, um de cada vez, vermelhos: a fila apagando a que falhou, a linha marcada com a chave, a campanha contando a
falha, o `$enviar` sempre "recebeu", quem não recebeu ficando na lista, a coluna errada, e a linha do central saindo com a
credencial da linha.

**A verificação do item (12/10/2026)** achou duas contas erradas na coluna "Não receberam", consertadas na regra da
campanha (`RegraDaCampanha::naoReceberam`, que a lista chama):

- **na campanha `enviando`**, a diferença incluía quem ainda nem foi tentado: a rodada manda um por vez, e numa de 500
  contatos, depois de 37 envios, a lista dizia 463 "não receberam" -- e o mesmo número ficava na campanha que a rodada
  deixou no meio (o D303), até a seguinte retomar;
- **nas de antes do conserto**, dizia "0" justamente onde o código antigo contava a falha como envio: na cópia, as 526
  de WhatsApp enviadas têm todas os contatos iguais aos envios.

Agora só a campanha de **WhatsApp**, **enviada**, com a data a partir de 12/10/2026 (`RegraDaCampanha::NAO_RECEBERAM_DESDE`)
mostra a diferença; nas outras, o travessão -- e também na de push, que conta a falha como envio até hoje (o despachante a
registra no registro das rotinas), e na de `sms`, do passado. **Provado** em
`WhatsAppQueFalhaTest::testONaoReceberamSoContaAWhatsAppEnviadaDepoisDoConserto`, com a lista renderizada (a que está
saindo, a de 01/10/2026, a de push e a enviada hoje) e a fronteira do dia; quatro mutantes vermelhos (a de push contando,
a `enviando` contando, a de antes contando, e a conta de antes na lista). **O que resta, e é o D479:** na produção, as
campanhas entre 12/10/2026 e o deploy saem pelo código antigo, e a coluna diria "0" nelas.

**O que fica de fora, e é o D478:** a linha que falhou não diz o motivo nem a hora (a data é a do pedido), e nada a apaga.


### D303 — Cancelar uma campanha no meio da rodada não cancela — CORRIGIDO em 05/10/2026

**Achado pelo mapa da etapa "marketing e aplicativo".** A rotina guarda a campanha em
memória e, no fim do laço, grava "enviado" -- por cima do "cancelado" que alguém gravou
enquanto ela rodava. Conserto: um `UPDATE ... WHERE status = 'enviando'`.

#### O conserto — 05/10/2026

**O fim condicional sozinho não bastava, e o código de hoje mostrou por quê.** Desde
a revisão do D297 a tela só cancela o que não começou a sair (`aguardando` e
`agendado`). A janela que sobra é a de antes do primeiro envio: `marketingEnvios`
lê as campanhas devidas no começo da rodada, e as de antes de uma campanha levam
minutos. Cancelada nesse meio-tempo, a tela dizia "Cancelado com sucesso", o
`flush` do primeiro envio regravava "enviando" por cima -- a entidade em memória
ainda dizia "agendado" --, a campanha saía INTEIRA, e o fim gravava "enviado".

Três `UPDATE ... WHERE status IN` do `NotificacaoRepository`, que decidem numa linha
só e dizem se mudaram:

- **`assumirEnvio`** (`agendado` -> `enviando`, e só a que ainda está devida, com o
  `ultimoEnvio` gravado junto -- os dois da revisão, abaixo): a rotina assume a
  campanha antes do primeiro envio e PULA a que não conseguiu assumir; depois a relê
  (`$em->refresh`), e o que sai é o que está gravado -- o texto editado depois da
  leitura da rodada inclusive; a adiada não é assumida. A que retoma (`enviando`)
  já é da rotina;
- **`encerrarEnvio`** (`enviando` -> `enviado`): o conserto que o registro pedia --
  o fim só a partir de `enviando`;
- **`cancelar`** (`aguardando`/`agendado` -> `cancelado`, com a data de alteração):
  `NotificacaoService::cancelar` grava por ele e devolve se cancelou, e a tela
  responde a frase de "já começou a ser enviada" quando a rotina assumiu a
  campanha entre a leitura da tela e a gravação.

**Provado** (`CampanhaCanceladaNaRodadaTest`, 5 casos, nos dois bancos): os três
métodos isolados, contra cada estado (a rotina só assume o agendado, e uma vez; o
fim só sai de `enviando` e deixa o `cancelado`; o cancelamento só pega os dois
editáveis, com a data de alteração); e a corrida pelo controller do ERP
despachado em processo, com a campanha lida ANTES -- a tela que leu "agendado" e
cancela depois de a rotina assumir recebe a frase e a campanha segue `enviando`; a
cancelada depois da leitura da rodada não é assumida nem encerrada. Mutantes,
todos vermelhos: o assumir que aceita o cancelado (nos dois bancos), o fim que sai
do cancelado, o cancelar como era (a entidade em memória, nos dois bancos), a tela
que ignora a resposta e o cancelar sem a condição do estado.

**O que não foi provado rodando** (até a revisão, abaixo): a fiação em
`CronJobService::marketingEnvios` -- o assumir antes do primeiro envio, o `refresh`
e o fim pelo repositório. A rotina chama o WhatsApp e o OneSignal de verdade e NÃO
roda em teste nem em ensaio; a fiação foi lida, e nenhum mutante nela ficava
vermelho em caso nenhum.

#### A revisão — 05/10/2026

A revisão adversarial do conserto achou três coisas, todas verdadeiras no código:

- **A campanha assumida que caísse antes do primeiro envio ficava presa para
  sempre — uma regressão do próprio conserto.** `assumirEnvio` gravava só o estado,
  e a campanha nasce com o `ultimo_envio` nulo (nulável nos dois bancos). Antes do
  conserto, nada era gravado antes do `flush` do primeiro envio: no mesmo tropeço a
  campanha continuava `agendado`, e a rodada seguinte tentava de novo. Depois, a
  rodada que morresse entre o assumir e o primeiro envio -- um erro de banco, o
  `max_execution_time` (300 s no contêiner), o contêiner reiniciado -- deixava a
  campanha `enviando` com o `ultimo_envio` nulo: `selecionarEnviosCronJob` só
  retoma `enviando` com `ultimoEnvio <= agora`, e `NULL <= agora` não é verdadeiro;
  e a tela não cancela nem edita o que está `enviando` (D297). **O conserto:**
  `assumirEnvio` grava o `ultimoEnvio` junto, no mesmo `UPDATE`. Ele não aparece em
  tela nenhuma (o `grep` em `module/*/view` e no `frontend/src` não o acha), e só a
  retomada o lê. O outro lado -- a retomada aceitar o `ultimoEnvio` nulo -- foi
  descartado: mudaria a seleção de QUALQUER campanha `enviando` e nula, e não só a
  que o assumir deixou, e as mandaria na primeira rodada depois da subida (na cópia
  não há nenhuma `enviando`: 2.669 `enviado` e 118 `cancelado`; a produção não foi
  medida). Gravar no assumir só toca a campanha que a própria rodada assumiu.
- **O assumir não conferia a data.** A rodada lê a data no começo, e o gerente pode
  adiar a campanha enquanto as de antes saem -- o estado continua `agendado`, que é
  editável. O `UPDATE ... WHERE status = 'agendado'` casava, e a campanha saía hoje,
  com o texto novo e fora da hora nova -- e este registro prometia "a edição feita
  depois da leitura inclusive". Não era regressão (antes ela também saía, com o
  texto velho). **O conserto:** `AND noti.data <= :agora` na condição do assumir. A
  adiada fica `agendado` e sai na rodada da hora nova.
- **A fiação não tinha caso.** O corpo do laço de `marketingEnvios` -- assumir,
  reler, conferir o destinatário, contar, gravar o envio, encerrar -- saiu para
  `NotificacaoService::enviarCampanha($notificacao, $enviar)`, igual ao que era, e o
  envio de verdade (as tags, o push, o WhatsApp) ficou na rotina, numa função que
  ela passa como `$enviar`. A rotina monta o serviço com o gerenciador dela, como
  já fazia.

**Provado** (`CampanhaCanceladaNaRodadaTest`, mais 8 casos, nos dois bancos, com um
`$enviar` que só anota -- nada é enviado, e o serviço não alcança o WhatsApp nem o
OneSignal, que só a rotina constrói): a campanha devida sai uma vez a quem recebe, é
contada, grava o envio e termina `enviado`; a cancelada depois da leitura da rodada
não sai, não é contada e fica `cancelado`; a adiada depois da leitura não sai e
fica `agendado`; o texto que sai é o gravado depois da leitura; quem para a campanha
à mão no banco no meio do envio fica com o estado que gravou, e não com o "enviado"
da entidade; a rodada que cai no primeiro envio deixa a campanha `enviando`, a
rodada seguinte a lê, e ela sai; e, no repositório, o assumir recusa a campanha
ainda não devida e grava o `ultimoEnvio`, e a assumida volta na leitura da rodada
seguinte. Cinco mutantes novos, um de cada vez, cada um vermelho nos dois bancos e
com os mesmos casos nos dois: o assumir sem o `ultimoEnvio` (2 casos), o assumir
sem a data (2), o serviço sem o assumir (4 -- a cancelada e a adiada saem, a que
caiu fica `agendado`, e o fim regrava), o serviço encerrando pela entidade (1) e o
serviço sem o `refresh` (3).

**O que continua sem rodar:** a função `$enviar` da rotina -- as tags, o push, o
WhatsApp, código que só mudou de lugar -- e a chamada `enviarCampanha` no laço.
Continuam lidas, pelo mesmo motivo de antes.

**O que fica:** a edição da campanha também confere o estado lido e grava sem
condição, e grava o `status` que vier no POST -- é o D418.


### D304 — A etapa 6 da adesão à fidelidade enfileira WhatsApp a cada envio, para qualquer telefone — REGISTRADO em 29/09/2026 — MEDIDO em 08/10/2026, na cópia — CORRIGIDO em 10/10/2026 — CORRIGIDO em 11/10/2026, o teto em 10

**Achado pelo cético do mapa de pesquisas.** `/fidelidade/salvarDados` é rota pública, e a
etapa 6 (`FidelidadeService.php`) roda a cada POST, também depois da adesão: cada telefone
novo cria um cliente e enfileira uma mensagem de WhatsApp. A medir, na frente da
fidelidade.

#### MEDIDO em 08/10/2026, na cópia -- e o que a cópia não sabe dizer

Só leitura e só contagem, com âncora ASCII (`origem LIKE 'Indica% - %'`). Basta o `id` e o
`hash` de um cliente -- o link de qualquer um -- para mandar a etapa 6.

O rastro que ela deixa: o amigo nasce **inativo**, com a origem `Indicação - <id do
indicador>`, e a mensagem vai para a fila (`push_app`) quando a unidade da última visita do
indicador tem o WhatsApp ligado. **Os 10 pontos da etapa 6 vão a quem a manda, no próprio
envio, com amigo ou sem** (corrigido na verificação, 08/10/2026; o texto dizia que o indicador
os ganhava quando o amigo completasse a etapa 3): todo POST válido chama `salvarDados` e logo
depois `salvarPontuacaoCadastro($cliente, $request['etapa'])` (`FidelidadeController.php`,
linhas 122-123), e a validação da etapa 6 aceita os campos do amigo vazios. Quando o amigo
completa a etapa 3, a origem dele vira só `Indicação` (o id se perde) e o serviço chama
`salvarPontuacaoCadastro($indicador, 6)` (`FidelidadeService.php:216`) -- que acha o histórico
"Completou a etapa 6" do próprio envio e não pontua nada. **E o reenvio do mesmo amigo cria
outro, com outra mensagem:** `selecionarPorTelefone` procura só cliente ATIVO, e o amigo da
primeira vez nasceu inativo (lido no código, não exercitado).

| na cópia (até 16/08/2026) | |
|---|---|
| amigos ainda com a origem do indicador | **183**, de **183** indicadores -- nenhum com dois |
| ... criados em 2026 | 110 (38 em 2018, 35 de 2019 a 2023) |
| ... cujo indicador tem hoje a última visita numa unidade com WhatsApp ligado | 144 (79%) |
| clientes que mandaram a etapa 6 desde 14/04/2026 (o histórico "Completou a etapa 6", 10 pontos no próprio envio) | **493** -- e não 493 conversões |
| ... desses envios, os que criaram um amigo (o amigo nasceu até 5 s do histórico) | **110**: são os 110 pendentes de 2026, um por indicador |
| ... amigo criado pela etapa 6 desde 14/04 que completou a etapa 3 (cliente de origem `Indicação` sem id nascido até 5 s de um envio) | **0** |
| amigo pendente com outro cliente de origem `Indicação` criado na mesma unidade até 2 minutos antes ou depois -- o reenvio provável, ou o mesmo amigo cadastrado pelo balcão com a origem do ERP (a cópia não separa) | **5**, contra 1 a 2 no controle (a mesma busca deslocada de 1 e de 7 dias) |
| mensagens da etapa 6 na fila local | 0 |

**O que a cópia NÃO separa: o mesmo telefone ou um telefone novo.** A anonimização
reescreveu todo telefone a partir do id do cliente (242.586 de 242.597 no padrão
`(11) 90000-`), e o destino da fila também; dois amigos com o mesmo telefone na produção têm
telefones diferentes aqui, e dois diferentes podem colidir (o id módulo 10.000). O título das
mensagens antigas da fila virou "Título de exemplo". Então, na cópia: a etapa 6 repetida pelo
mesmo indicador é coisa de poucos casos -- 5 contra o 1 ou 2 do acaso --; para que telefone
foi, a cópia não sabe.

**A conta que separa, para rodar na produção** (só leitura, sem imprimir telefone; quem roda
é quem tem acesso a ela -- conferida na cópia, onde leva 5 s e não mede nada: a segunda dá 183
de 183 pela colisão do id):

```sql
SELECT COUNT(*) AS amigos, COUNT(DISTINCT origem) AS indicadores,
       COUNT(*) - COUNT(DISTINCT telefone) AS repetem_um_telefone,
       COUNT(*) - COUNT(DISTINCT origem, telefone) AS repetem_o_telefone_do_mesmo_indicador
  FROM clientes WHERE origem LIKE 'Indica% - %';
SELECT COUNT(*) AS amigo_com_telefone_de_outro_cliente FROM clientes a
 WHERE a.origem LIKE 'Indica% - %'
   AND EXISTS (SELECT 1 FROM clientes b WHERE b.telefone = a.telefone AND b.id <> a.id);
```

As duas contam só os amigos que ainda não completaram a etapa 3 -- os que completaram
perdem o id do indicador na origem, e se misturam às 119.335 origens `Indicação` sem id da
cópia, que também é uma das opções de "origem" do cadastro do ERP (`Arrays.php`).

O freio -- recusar o telefone que já é de cliente inativo, um teto por indicador, fechar a
etapa depois da adesão -- é decisão do dono, e foi ao lote (WHATSAPP-P3) com estes números.

**As três linhas da etapa 6 na tabela, reproduzíveis** (só contagem; uns 4 s na cópia, que
devolvem 493 e 493, 110 e 0):

```sql
SELECT COUNT(*) AS envios, COUNT(DISTINCT cliente) AS clientes
  FROM pontuacoes_historicos WHERE descricao LIKE 'Completou a etapa 6 %';
SELECT COUNT(*) AS envios_que_criaram_amigo FROM pontuacoes_historicos h
 WHERE h.descricao LIKE 'Completou a etapa 6 %'
   AND EXISTS (SELECT 1 FROM clientes a WHERE a.origem LIKE 'Indica% - %'
                 AND CAST(TRIM(SUBSTRING_INDEX(a.origem, '-', -1)) AS UNSIGNED) = h.cliente
                 AND ABS(TIMESTAMPDIFF(SECOND, h.data_criacao, a.data_criacao)) <= 5);
SELECT COUNT(DISTINCT c.id) AS amigos_que_completaram FROM clientes c
  JOIN pontuacoes_historicos h ON h.descricao LIKE 'Completou a etapa 6 %'
   AND ABS(TIMESTAMPDIFF(SECOND, h.data_criacao, c.data_criacao)) <= 5
 WHERE c.origem LIKE 'Indica%' AND c.origem NOT LIKE 'Indica% - %' AND c.data_criacao >= '2026-04-14';
```

O que isso abre -- a página promete pontos "na primeira compra" do amigo, e a etapa 6 dá 10
pontos no envio e não liga o amigo à indicação que paga a comissão da primeira compra -- é o
**D446**, e foi ao lote como pergunta.

**Decidido pelo dono em 08/10/2026 (pergunta 51 da página do lote):** a opção (b), *"A (a), e um teto
de indicações por indicador por dia"* -- a (a) sendo "Não criar outro cliente nem mandar outra
mensagem para telefone que já é de cliente, ativo ou inativo" (o ex-cliente inativo indicado por alguém
deixa de receber o convite). **O número do teto veio vazio:** a opção dizia "o número é seu; sugiro 5,
na observação", e a observação não o trouxe. A implementação é o item R16, e não este registro; nada
mudou no código aqui.

#### O conserto (10/10/2026, o item ADESAO-ETAPA-6: as respostas 51 e 53 do dono)

A etapa 6 saiu do `FidelidadeService::salvarDados` para o `FidelidadeService::indicarAmigo`, que o
`FidelidadeController::salvarDadosAction` chama antes de gravar e de pontuar, com a linha do indicador travada (o
`comOClienteTravado` do D406). Três regras, nesta ordem:

1. **O teto (a 51, opção b).** A indicação além do teto do dia do mesmo cliente é recusada, com a frase "Você já fez
   10 indicações hoje, o limite do dia. Para concluir o cadastro, deixe os campos do amigo em branco; amanhã você pode
   indicar de novo." (o número sai da constante) -- qualquer que seja o telefone, e antes de olhar o telefone, para a
   recusa não dizer se ele é de cliente. A conta é das linhas de `indicacoes` do indicador no dia
   (`IndicacaoRepository::contarDoIndicadorNoDia`), pelo relógio do PHP, o mesmo que grava `data_convite`; entram as da
   etapa 6 e as do convite do aplicativo, que são a mesma ligação. **Desde 11/10/2026 (a resposta 67) a conta soma a
   elas os amigos que nascem sem a ligação** -- os de quem indica inativo, ou é o cadastro do sistema --, pela origem
   deles (a quarta rodada, abaixo). **O número é o do dono desde 11/10/2026:**
   `FidelidadeService::INDICACOES_POR_DIA = 10` (a resposta 70). Em 10/10 era 5, a sugestão da própria pergunta,
   porque a observação da 51 veio vazia -- a quarta rodada, abaixo.
2. **O telefone que já é de cliente (a 51, opção a).** Ativo ou inativo, de qualquer unidade: a pergunta do convite do
   aplicativo (`appSelecionarClientesPorTelefone` sem status, contra `telefone_sem_mascara`). Não cria cliente, não
   manda mensagem, não liga ao indicador o cliente que já existe (o aplicativo também não liga), e a resposta é a mesma
   do amigo aceito: a página vai ao agradecimento. A frase "Telefone já cadastrado no sistema" saiu da validação: ela
   contaria a quem tem o link de qualquer cliente quem é cliente da rede -- e pela página nem saía (comparava o número
   com a máscara e sem o DDI com a coluna, que guarda "+DDI" e os dígitos; só casava no pedido montado à mão). O
   telefone do amigo vira só os dígitos, com o DDI sem zero na frente (`FidelidadeService::telefoneDoAmigo`): para o
   que a página manda, é o texto da regra do cadastro (`RegraDoCadastroDeCliente::telefoneSemMascara`); o DDI é campo
   livre nesta página, e "+55" ou "055" faziam o telefone de um cliente passar por novo, com a mensagem indo ao número
   dele do mesmo jeito.
3. **O amigo novo (a 53, opção c)** nasce como antes -- inativo, com a origem `Indicação - <id>` e o WhatsApp pela
   unidade da última visita do indicador -- e com a ligação do convite do aplicativo: é o D446. Desde 11/10/2026, com o
   país da unidade de quem indica (a resposta 66, o D460), e sem a ligação quando quem indica é inativo ou o cadastro
   do sistema (a 67, o D446).

O `ClienteRepository::selecionarPorTelefone` (só cliente ativo, pelo `telefone` com a máscara) saiu junto: a etapa 6
era quem o chamava (grep em `module/`).

**Provado nos dois bancos, em série** (`tests/Caracterizacao/D304EtapaSeisDaAdesaoTest.php`, pelo despacho em
processo; os números são os da rodada final, na verificação abaixo -- os "13 casos, 82 asserções" que estavam aqui
eram de uma versão do arquivo anterior à última edição): o amigo novo nasce inativo, com a ligação e uma mensagem na fila; o reenvio do mesmo amigo
não cria outro nem manda outra; o telefone de cliente ativo de outra unidade, o de inativo, os dois com "+55" e "055"
no DDI e o só com os dígitos não criam nem mandam, e a resposta é a mesma do aceito; o pedido montado à mão com o
telefone no formato da coluna não ouve "já cadastrado"; a sexta do dia é recusada, também com o telefone de cliente, e
no dia seguinte passa; os convites do aplicativo entram na conta. Nenhum WhatsApp sai: a etapa só põe a mensagem na
fila (`push_app`).

**Mutantes**, cada um sozinho e restaurado (conferido pelo `md5sum` do conserto), no PostgreSQL. Os quatro primeiros
rodaram contra a versão anterior do teste; o "sem o teto" e o "teto depois da pergunta" rodaram de novo na
verificação, contra a versão final, com o mesmo caso vermelho (a tabela de lá):

| mudança | caso vermelho |
|---|---|
| sem o teto | a sexta do dia; os convites do aplicativo na conta |
| o teto depois da pergunta do telefone | a sexta com o telefone de cliente (a recusa diria que não é cliente) |
| a pergunta do telefone só com cliente ativo | os três casos de cliente inativo; o reenvio |
| sem a pergunta do telefone | os cinco casos de telefone de cliente; o reenvio |
| o telefone pelo `removerMascaraTelefone`, sem só os dígitos | o "+55" e o "055" no DDI; o caso do telefone do amigo |
| o dia inteiro fora da conta (a data sem filtro) | a do dia seguinte passa |
| o controller sem chamar o `indicarAmigo` | o amigo novo, o reenvio, os dois do teto e o da comissão |
| a frase "Telefone já cadastrado no sistema" de volta | o pedido montado à mão |

**Não provado:** a corrida de dois envios do mesmo indicador ao mesmo tempo (a trava é a do D406, lida e não
exercitada aqui); a página no navegador (a escrita iria à cópia, que é só leitura); e a produção -- a conta do
telefone que se repete, acima, continua sendo a que separa.

**Ficou no lote, com a recomendação:** a inferência pelo teto -- o teto conta só o que grava, e quem tem um link pode
descobrir se um número é de cliente, com o "sim" de graça (corrigido na verificação, abaixo); fechar pede guardar a
tentativa, uma tabela nova -- e
o convite do aplicativo, que responde "Este telefone já está vinculado a uma indicação." e não tem teto (o D458). O
número do teto também é do dono.

#### A verificação (10/10/2026): o erro que contava quem é cliente, o teto de cada indicador, as rodadas refeitas

1. **O erro 500 contava, de graça, quem é cliente -- corrigido.** O telefone de cliente saía do `indicarAmigo` antes de
   montar o amigo, com a resposta do aceito; o telefone novo ia ao `flush`, e o banco recusava o que não cabe:
   `clientes.nome` é `varchar(100)` e `clientes.telefone`, `varchar(30)`, nos dois bancos, e a tabela do MySQL é
   `utf8mb3`. O nome de 101 letras (ou o de 100 que o `setNome` alonga, com o espaço depois de cada ponto), o emoji (o
   MySQL recusa, erro 1366/3988; o PostgreSQL grava), o número com enchimento (a pergunta é pelos dígitos, e o
   `telefone` era gravado como veio) e o DDI comprido davam 500 num caso e 200 no outro; o texto que não é UTF-8 saía
   com um Deprecated no corpo (o `setNome` o apaga: a classe do D100). Nada ficava gravado, nem o teto era gasto. Agora
   o `indicarAmigo` monta o amigo e o mede ANTES da trava e da pergunta: o nome depois do `setNome`, o telefone como a
   coluna o guarda; o que não cabe ouve uma frase só, a mesma com telefone de cliente ou não
   (`FidelidadeService::AMIGO_FORA_DA_FORMA`, "Confira os dados do amigo: o nome com até 100 letras e sem emoji, e o
   telefone, com o DDI, em até 30 caracteres."). O `telefone` gravado passa a ser montado dos mesmos dígitos da
   pergunta (`numeroDoAmigo`: os dígitos e a máscara da página, os espaços juntados e aparados): o enchimento sai, e a
   coluna com máscara, a sem máscara e o destinatário da mensagem dizem o mesmo número. Para o que a página manda
   ("(47) 99999-0184", o nome até 100), nada muda. O caractere nulo ficou de fora: medido em 10/10, os dois bancos o
   gravam (o PostgreSQL corta o nome nele), e a resposta não muda com o telefone.
2. **O teto é de cada indicador, e agora um caso o prova:** as cinco de hoje de outro cliente da mesma unidade não
   entram na conta do indicador. Sem o filtro do indicador -- ou com o da unidade no lugar --, o caso fica vermelho; antes,
   os 13 casos ficavam verdes, porque toda linha de hoje era do indicador.
3. **As rodadas foram refeitas na versão final.** Os números de antes eram de uma versão anterior à última edição do
   arquivo (o `assertIsArray` do `comissao()` veio depois deles), e os mutantes M1 a M6 rodaram contra ela. A rodada
   final, em série: o arquivo inteiro, 24 casos e 139 asserções, verde no MySQL e no PostgreSQL; e os vizinhos
   (adesão, fidelidade, indicação, crédito, cancelamento, avulso, hash, D184, D420, D432), 493 casos e
   2.294 no MySQL e 2.293 no PostgreSQL asserções, verdes nos dois bancos.
4. **A pergunta da inferência pelo teto, no lote, foi corrigida:** a resposta "é cliente" sai de graça. Com a conta em
   4, cada número perguntado vai seguido de um telefone que se sabe de cliente -- o do próprio dono do link, que nunca
   entra na conta: se vier a recusa do teto, o perguntado contou (não é cliente); se vier o aceito, é cliente, e a conta
   continua em 4. O dia do link só se perde no primeiro "não". A recomendação continua a de deixar como está, agora
   pelo motivo certo (o que vaza é "é cliente da rede", sem nome; fechar pede tabela nova no corte e mudar o
   aplicativo publicado; e as duas portas, a da página e a do D458, respondem "sim" de graça).

| mutante (sozinho, restaurado e conferido pelo `md5sum`; no PostgreSQL, contra a versão final) | caso vermelho |
|---|---|
| sem as duas conferências do amigo (o nome e o tamanho) | os seis casos do amigo que não cabe, que caem com o telefone de cliente (ouve o aceito), e o do DDI comprido, que cai no 500 do telefone novo (os casos seguintes caem em cascata, "EntityManager is closed") |
| a medida do tamanho depois da pergunta "já é de cliente?" | os três de tamanho (o nome de 101, o que cresce no `setNome`, o enchimento de parênteses): o de cliente ouve o aceito |
| o `telefone` gravado como veio, sem o `numeroDoAmigo` | o enchimento: o telefone passa de 30, e os dois ouvem a recusa em vez do aceito |
| o nome medido antes do `setNome` | o nome que passa de 100 só depois do `setNome` |
| sem a conferência do nome (o emoji e o que não é UTF-8) | o emoji e o texto que não é UTF-8: o de cliente ouve o aceito |
| a conta sem o filtro do indicador | o teto de cada indicador |
| a conta pela unidade do indicador | o teto de cada indicador |
| sem o teto (o M1, de novo) | a sexta do dia; os convites do aplicativo; o teto de cada indicador |
| o teto depois da pergunta do telefone (o M2, de novo) | a sexta com o telefone de cliente |

Os mutantes da comissão, dos 10 pontos por compra, do indicador inativo e do país estão no D446 e no D460.

#### A quarta rodada (11/10/2026, o item ADESAO-QUARTA-RODADA: as respostas 67, 69 e 70 do dono)

**Decidido pelo dono em 11/10/2026 (a página do lote):** a **70** -- o teto passa de 5 a 10 (a observação dele foi
"10"); a **69**, opção (a) -- as duas portas da pergunta "o que a resposta neutra e o teto não fecham" ficam como estão:
a inferência pelo teto (quem tem um link descobre, de graça, se um número é de cliente) e o "já vinculado" do
aplicativo (o D458); e a **67**, opção (c), que mexe no teto -- quem indica inativo, ou é o cadastro do sistema, indica
sem a ligação, e o teto conta também esses amigos (a regra está no D446).

**O conserto.** `FidelidadeService::INDICACOES_POR_DIA = 10`, e o "provisório" saiu do código, dos testes e da
documentação (aqui, acima, e no roadmap de 10/10, onde o 5 ficou dito como era). A frase do teto sai da constante. Os
casos do teto leem a constante: as contagens escritas (`5`, `6`) e os telefones fixos que colidiriam com um teto maior
saíram (`telefoneNovo`); só `testOTetoDoDiaEODoDono` escreve o 10, e é ele que prova a resposta. A conta do dia
(`IndicacaoRepository::contarDoIndicadorNoDia`) soma às ligações os amigos que nasceram sem ela: a origem
"Indicação - <id>" que a etapa 6 grava, sem `indicacao`, criados no dia, na unidade de quem indica. A unidade entra
porque é a coluna com índice -- `clientes` não tem índice em `origem` nem em `data_criacao`; a unidade vem da linha do
indicador que o `indicarAmigo` acabou de reler sob a trava (o `find` a devolve do mapa de identidade). Lido o plano na
cópia (`EXPLAIN`, só leitura): o MySQL acha o amigo pelo cruzamento dos índices de `unidade` e de `indicacao`; o
PostgreSQL, pelo índice de `unidade`. Nenhum dos dois varre a tabela; o tempo não foi medido. A primeira redação
cruzava `clientes` consigo mesma pelo indicador, e o portão `conferir-booleano-em-coluna-inteira.php` não resolvia o
segundo apelido (as não resolvidas subiram de 13 para 14): a unidade passou a ir como parâmetro, e o portão voltou a 13.
A 69 não mudou nada no código.

**O limite da conta, provado e não fechado:** o amigo sem a ligação que conclui a etapa 3 no mesmo dia sai da conta --
a etapa 3 troca a origem por "Indicação", e o número de quem indicou se perde --, e o amigo (ou quem o indicou) que
mudou de unidade no dia também. Cada vaga a mais pede um amigo que recebeu a mensagem e concluiu o endereço. Fechar
pede guardar a tentativa -- a tabela nova da opção (c) da 69, que o dono não escolheu.

**Provado nos dois bancos, em série** (`D304EtapaSeisDaAdesaoTest`: 33 casos e 207 asserções, verde no MySQL e no
PostgreSQL; os vizinhos -- 38 arquivos, os que o grep acha por finalizar, convite do aplicativo, etapa 6, origem de
indicação e pontuação, mais os da rodada de 10/10 --, 758 casos e 3.369 asserções, verdes nos dois): a indicação além
do teto é recusada, qualquer que seja o telefone, e a do dia seguinte passa; os convites do aplicativo entram na conta;
o teto é de cada indicador; o teto de quem indica inativo conta os amigos sem a ligação -- e não os de outro cliente da
mesma unidade, nem o de ontem --; e o amigo que conclui a etapa 3 no dia sai da conta (o limite, acima).

| mutante (sozinho, restaurado e conferido pelo `sha1`; no PostgreSQL) | caso vermelho |
|---|---|
| o teto de volta a 5 | só `testOTetoDoDiaEODoDono` (os outros leem a constante) |
| a conta sem os amigos sem a ligação | o teto de quem indica inativo; o do amigo que conclui a etapa 3 |
| os amigos contados com a ligação também (sem o `indicacao IS NULL`) | a indicação além do teto; o teto de cada indicador (as ligações contadas duas vezes) |
| os amigos sem a ligação de qualquer indicador da unidade | o teto de quem indica inativo |
| os amigos sem a ligação desde ontem | o teto de quem indica inativo |
| os amigos sem a ligação das outras unidades, e não da de quem indica | o teto de quem indica inativo; o do amigo que conclui a etapa 3 |

#### A verificação da quarta rodada (11/10/2026)

1. **Nenhum caso prendia a SOMA das duas parcelas -- agora um prende.** Em cada caso do teto, uma das duas valia zero
   (as ligações, ou os amigos sem ela), e a conta que tomasse a maior delas (`max`), ou que para quem indica ativo
   olhasse só as ligações, ficava verde: quem indicasse inativo até o teto e fosse ativado no mesmo dia mandaria outras
   tantas mensagens. O caso novo (`testOTetoSomaAsLigacoesEOsAmigosSemALigacaoDoMesmoIndicadorNoDia`) faz metade do
   teto sem a ligação, ativa quem indica, faz o resto com ela, e a seguinte é recusada. O código já somava: nada mudou
   nele.
2. **A regra 1 do conserto de 10/10, acima, dizia que a conta é só das linhas de `indicacoes`** -- reescrita para o 10
   e não para a 67. Ganhou a soma; e a regra 3, o país e a falta da ligação.

**Provado nos dois bancos, em série:** `D304EtapaSeisDaAdesaoTest`, 37 casos e 242 asserções, verde no MySQL e no
PostgreSQL (os casos novos da verificação estão aqui, no D446, no D460 e no D466). Só o arquivo de teste mudou; os
vizinhos não foram rodados de novo.

| mutante (sozinho, restaurado e conferido pelo `sha1`) | caso vermelho |
|---|---|
| a conta pela maior das duas parcelas (`max`), no PostgreSQL e no MySQL | só o da soma |
| a conta só das ligações quando quem indica é ativo, no PostgreSQL | só o da soma |


### D305 — Abrir a tela do WhatsApp reinicia a sessão da instância — REGISTRADO em 29/09/2026 — CORRIGIDO em 12/10/2026: abrir só consulta, e o código sai por POST

**Achado pela revisão da Fase 0 de marketing.** O `instanciaAction` é um GET, e ele cria,
inicia e reinicia a sessão no servidor do WhatsApp (e segura o pedido até 45 s). Abrir a
tela -- ou a pré-carga dela -- mexe na instância da unidade. Fica com a decisão sobre o
WhatsApp.

#### O conserto — 12/10/2026 (a F0 do desenho do WhatsApp; o item WHATSAPP-F0-E-CENTRAL)

**A decisão que ele esperava veio:** a resposta 71 tirou a dependência da P3, e a 136a (*"Tela nova, e só gerente ou
administrador desconecta"*) confirmou que o WhatsApp fica no sistema. A F0 vale em qualquer resposta, e é a tela antiga
(`docs/f3-telas-restantes/tela-whatsapp.md`, seção 3). Junto, os rótulos W3, W4, W5, W6 e W8 do desenho, que não tinham
número e ficam com este (a tabela do `README.md` daquela pasta). `ISConfiguracao\Controller\WhatsAppController`:

- **Abrir só consulta.** O `instancia` chama só `instanciaStatus()`. O QR sai de uma ação nova, `codigo`, só por POST (a
  guarda é o primeiro comando, e o `conferir-remocao-por-post.php` a tem na lista das ações só-POST), com a barreira de
  origem da entrada do ERP, uma trava por unidade (`acquireLock`; o segundo pedido volta com "Já há um pedido de código em
  andamento nesta unidade") e, com a instância já conectada, sem pedir o `connect` (o W5). Ela devolve a própria tela com
  o QR, e o botão vira "Gerar outro código"; quando a consulta vê o conectado, a página vai ao GET da tela -- recarregar a
  resposta do POST pediria o código de novo.
- **O `whatsapp_ativo` e a credencial do banco,** a cada pedido, nas quatro ações (o W2; o D350): a sessão dizendo ligado
  e o banco desligado é 404 na tela, no código e no desconectar, e a consulta recusa.
- **Sem credencial, nada fala com o provedor** (o W6): ligado e com a chave ou a instância vazias (só de espaços conta como
  vazia), a tela diz "A chave do WhatsApp desta unidade não está cadastrada. Quem a cadastra é o administrador, em
  Unidades.", sem o botão; o código e o desconectar voltam com a frase, e a consulta recusa. A fixture é a unidade
  90001170 (o D360).
- **A consulta de estado** (o W4) -- e, desde a verificação (abaixo), a linha "Mensagem API" da tela -- só devolve `{sucesso, estado}`; na falha, a frase nossa, sem o `dados` nem o texto do
  provedor.
- **O desconectar diz o resultado** (o W3), com frase nossa nos dois casos.
- **A consulta da tela em fila** (o W8): só na tela do QR, encadeada por `setTimeout` (a próxima sai 5 s depois que a
  anterior voltou), com `timeout` de 35 s no `$.ajax` -- acima dos 30 s do cliente do provedor, para a desistida não
  seguir presa no servidor enquanto a seguinte sai -- e parada em 3 minutos. O comentário que explica isso é PHP, e não
  JavaScript: a página entregue não o leva, e o caso que lê a página lê só o código.

**Provado** no `WhatsAppTelaAntigaTest` (10 casos, em processo, com um cliente de WhatsApp de mentira que conta as
consultas, os `connect` e as desconexões -- nada sai da máquina), nos dois bancos: o GET consulta uma vez e não conecta, e
a tela renderizada não tem QR e tem o formulário POST; o GET do código é 404 e o POST conecta e mostra o QR; conectada,
o código não conecta; com a trava tomada, nada vai ao provedor; banco desligado com a sessão ligada, as três ações 404 e a
consulta recusa, sem chamada; banco ligado com a sessão desligada, a tela abre; a 90001170 (e a instância só de espaços)
sem nenhuma chamada nas quatro ações; a consulta só com `sucesso` e `estado`, e sem o texto do provedor na falha; o
desconectar com as duas frases; e a tela do código com `setTimeout`, o tempo limite, os 3 minutos e o GET no conectado.
Os dois casos vizinhos que despacham o desconectar (`EstadoPorPostNoMarketingTest`, `BotoesDoErpSoDaMesmaOrigemTest`)
passaram a ligar o WhatsApp da unidade no banco, dentro da transação. Doze mutantes, um de cada vez, vermelhos (a tabela
no roadmap de 12/10/2026). O `conferir-remocao-por-post.php` sai 0, e 1 com a guarda do código arrancada ou com o
formulário dele em GET.

**A verificação do item (12/10/2026)** achou três coisas, consertadas:

- **O texto do provedor chegava à tela:** a linha "Mensagem API" imprimia o `mensagem` da consulta e do código, que na
  falha é o `message` cru da resposta do provedor (ou o erro do cURL, com o host). O conserto da F0 tinha posto frase
  nossa só no JSON e no desconectar. Agora a linha diz `FRASE_ESTADO_CONSULTADO`, `FRASE_ESTADO_INDISPONIVEL` ou
  `FRASE_CODIGO_INDISPONIVEL`, e `testATelaNuncaMostraOTextoDoProvedor` renderiza a tela com a consulta que falha e a do
  código que o provedor não devolveu. (Fica de fora o `message` em lista, como o das validações do WAHA: o
  `WahaApi::retornoErro` tipa a mensagem como texto, e a recusa lançaria antes de chegar à tela; não foi medido.)
- **A checagem da chave não tinha caso:** na fixture a chave e a instância são nulas, e o caso da instância só de espaços
  despachava só o GET da tela. O `testSemCredencialNadaFalaComOProvedor` mede agora cada metade sozinha -- a instância só
  de espaços, a chave só de espaços e a chave nula -- nas quatro ações.
- **O W8 era provado pelo texto:** o caso conferia o que o script declara, e não o uso; arrancar a parada dos 3 minutos,
  ou as novas consultas de dentro da resposta, deixava tudo verde. Agora `frontend/src/telas/whatsappDaTelaAntiga.teste.ts`
  roda o script da tela servida (o do `instancia.phtml`, com o endereço do `json_encode` trocado pelo que ele imprime) com
  um jQuery, um relógio e um `setTimeout` de mentira, em quatro casos: a primeira consulta aos 5 s, com o tempo limite; a
  próxima só depois da resposta, pelo sucesso e pelo erro; o conectado indo ao GET da tela; e a parada aos 3 minutos.

Sete mutantes, um de cada vez, vermelhos: a tela com o texto do provedor na consulta e no código, a tela sem conferir a
chave e sem conferir a instância, e no script a parada arrancada e a próxima consulta tirada do sucesso e do erro (a
tabela no roadmap).

**O que não foi provado:** a tela aberta num navegador, e o QR e o conectado de verdade -- o provedor está no loopback (o
D360), e o provedor de mentira é a F5. A tela nova (as fatias F1 a F5, a 136a) é outra série de rodadas; quando ela vier,
o GET da API também só lê.


#### Na API nova -- 23/10/2026 (a fatia 30 da fila das telas que faltam, WHATSAPP-ESTADO; a F1 do desenho)

O `GET /adm/api/whatsapp` (`ISConfiguracao\Controller\Adm\WhatsAppController`) nasceu sem o defeito: só `instanciaStatus()`,
nunca `instanciaConectar()`, e com a unidade fechada, desligada ou sem credencial o cliente do provedor nem é construído.
Provado no `WhatsAppPelaApiTest::testOGetSoConsultaENuncaConecta` (o cliente de mentira conta uma consulta e nenhum
`connect`), nos dois bancos; o mutante que põe o `connect` antes da consulta, vermelho. O título não muda: o conserto é o de
12/10, e a tela nova o herda.

#### Na escrita da API nova -- 23/10/2026 (a fatia 31, WHATSAPP-CONEXAO; a F2 do desenho)

O único caminho da API nova que chama `instanciaConectar()` é o `POST /adm/api/whatsapp/conexao`: por POST (a barreira de origem
vale), com `escrever`, com a trava da unidade -- a MESMA do "gerar código" da tela antiga (`whatsappCodigo` e o id), então duas
abas, duas pessoas ou as duas telas pedem um código por vez -- e depois de consultar o estado: conectada é 409 `ja_conectado`
sem o `connect` (o W5), e o WAHA em `STARTING` é 409 `iniciando` (o `connect` dele reiniciaria a sessão). Provado no
`WhatsAppPelaApiTest` (`testConectadaNaoPedeOConnect`, `testIniciandoSoGeraNoEvolution`, `testATravaTomadaE409`), nos dois bancos,
com os mutantes vermelhos. O título não muda. **Depois da verificação da fatia:** o "gerar código" da tela antiga deixou de
reiniciar o WAHA em `STARTING` e de conectar com a consulta que falha, e o desconectar dela toma a mesma trava (o D589); o WAHA sem
a sessão (a consulta em 404) é o único `indisponivel` que segue para o `connect`, que a cria (o D588).

### D306 — Os relatórios que ficaram fora da primeira lista do dono voltam à mesa antes de fechar a etapa — REGISTRADO em 29/09/2026 — PARCIAL em 13/10/2026: o Radar e o Fluxo viraram opções do painel (a resposta 137); os outros itens seguem com as respostas deles — PARCIAL em 25/10/2026: a Fidelização saiu — PARCIAL em 01/11/2026: o Dash. Faturamento foi absorvido pelo Atendimentos (a pergunta 6); sai na A4 — PARCIAL em 01/11/2026: as respostas 274 a 276, todas (a), dizem como ele sai — PARCIAL em 02/11/2026: a Vendas Ver. 2 virou a Cota-parte v2 (tela e arquivo); os PDFs e o XLS antigos seguem no ERP — PARCIAL em 03/11/2026: os desenhos da Carteira, das Datas da equipe e da Capacidade × realizado — PARCIAL em 04/11/2026: as Datas da equipe voltaram ao menu novo com a API (a DE0); a tela é a DE1 — PARCIAL em 04/11/2026: a tela das Datas da equipe (a DE1); o Aniversariantes antigo sai na DE2 — PARCIAL em 04/11/2026: o Aniversariantes antigo saiu do ERP (a DE2); as Datas da equipe só na tela nova — PARCIAL em 04/11/2026: a Capacidade × realizado voltou ao menu novo com a API (a CR0); a tela é a CR1 — PARCIAL em 04/11/2026: a tela da Capacidade × realizado (a CR1); o Potencial antigo sai na CR2 — PARCIAL em 04/11/2026: o Potencial antigo saiu do ERP (a CR2); a Capacidade × realizado só na tela nova — PARCIAL em 04/11/2026: a ocupação da rede da Capacidade × realizado com o aviso (a CR3); a desativada que vendeu é a CR4 — PARCIAL em 04/11/2026: a desativada que vendeu na Capacidade × realizado (a CR4); a Capacidade fechou as cinco fatias — PARCIAL em 04/11/2026: a Carteira de clientes voltou ao menu novo com a API (a CA1); a tela é a CA2 — PARCIAL em 04/11/2026: a tela da Carteira de clientes (a CA2); a Evasão antiga sai na CA3 e o Clientes antigo na CA4 — PARCIAL em 04/11/2026: as unidades hoje fechadas com cliente são linha da Carteira da rede (a CA8) — PARCIAL em 04/11/2026: a Evasão antiga saiu do ERP (a CA3); o Clientes antigo sai na CA4 — PARCIAL em 04/11/2026: os quatro filtros da Carteira (a CA5) — PARCIAL em 04/11/2026: a exportação da Carteira (a CA6); o Clientes antigo sai na CA4 — PARCIAL em 04/11/2026: o Clientes antigo saiu do ERP (a CA4); a Carteira de clientes só na tela nova

**Decisão do dono em 29/09/2026, depois do levantamento de todos os relatórios
existentes.** O menu Relatórios da tela nova passa a ter doze itens: Atendimentos,
Caixa, Programa de Indicação, Profissional Parceiro (o Dash. Colaboradores), Descontos,
NPS, Produtos/Serviços, Retirada de Produtos, Royalties (só administrador), Vendas (a
tela nova de Vendas Realizadas), Vendas Canceladas e Cota-parte (o Relatórios › Vendas
de hoje). A home do gerente de unidade vira um painel do mês. **O que ficou fora da
lista é este débito**, e volta à mesa antes de a etapa dos relatórios fechar -- nenhum
deles é apagado do ERP antigo, só sai do menu novo.

| Item de hoje | O que é | O que o levantamento achou | A decidir |
|---|---|---|---|
| Aniversariantes | datas da EQUIPE e da rede: nascimento e tempo de casa dos colaboradores; inauguração de unidades e nascimento de sócios, só para administrador | não lista cliente nenhum, apesar do nome; 107 não administradores têm o privilégio | fica como "Datas da equipe"? O aniversário de CLIENTE é filtro de Clientes e seleção própria do cron de mensagens |
| Clientes (tela, PDF, XLS, TXT) | a lista de clientes por perfil, com exportação de contato | recorta pela unidade da ÚLTIMA VISITA: 30.023 ativos têm essa unidade diferente da do cadastro, e 11.691 não têm nenhuma e somem de todo recorte. O mesmo filtro monta a audiência das campanhas (D297). O TXT e o XLS exportam contato sem teto e sem impor o consentimento. As faixas de NPS se sobrepõem (a nota 8 é promotor e passivo). O filtro de pontuação compara uma linha e mostra a soma | uma carteira de clientes com faixas de recência; uma porta só para exportar contato |
| Consultor Visagista | os testes de visagismo finalizados | fora da migração por decisão do dono. O PDF (`gerarPdf`) não confere de que unidade é o teste | nada, até o visagismo voltar |
| Dash. Faturamento | a unidade mês a mês, por ano | a home nova cobre o mês; a série anual vem de uma rotina com os defeitos do D307 | a série de 12 meses entra no painel? |
| Radar de Temperatura | a soma das linhas do Dash. Colaboradores, contra o mesmo período do ano anterior; atalho na barra do topo | soma só o grupo 1: em jul/2026 ficam fora R$ 54.181,45 de itens vendidos por caixas e gerentes. A ocupação é média simples (27,37% contra 28,90% ponderada) | a comparação com o ano anterior vira opção do painel? |
| Evasão | por colaborador, os clientes que pararam de vir | escondido no menu antigo e visível no novo. Janelas de 12 meses que terminam há 2 a 6 meses; quem sumiu há mais de 18 meses nunca entra. O cache em disco guarda nome, telefone e e-mail. O sistema tem cinco réguas de "cliente sumido" (30, 31, 120 dias, a da evasão e o filtro livre) | um limiar só, junto da carteira de clientes |
| Fluxo de Movimento | a média do faturamento por dia da semana, contra a média da rede | a tabela tem linhas repetidas (D307), e a média as conta | o dia da semana vira um corte do painel? |
| Fidelização de Clientes | nenhuma tela: não há controlador | no menu novo leva a 404 para 109 pessoas; a rotina das 03:10 calcula todo dia um arquivo que ninguém lê, e o que ela mede é o cliente que PASSOU PARA OUTRO colaborador (D105) | sai com a rotina, ou vira "Retenção" com regra escrita |
| Potencial de Franquia | capacidade da agenda × ticket × 70%, por unidade; só administrador | a mesma fórmula do "planejado" do Yungas, sobre outro conjunto de pessoas: as unidades 10 e 39 têm dois potenciais. O campo editável do cadastro é sobrescrito pela rotina das 04:10 | "Capacidade × realizado" para a rede |
| Vendas Ver. 2 | o mesmo relatório do Cota-parte, com a comissão gravada na venda; só administrador | a cota-parte tem quatro contas nos quatro arquivos (PDF e XLS de cada versão). Na rede, em jul/2026, a comissão avulsa de serviços é R$ 643.785,25 gravada contra R$ 645.796,99 recalculada com o percentual de hoje | qual comissão é a oficial -- antes de redesenhar o Cota-parte. **RESPONDIDO em 01/10/2026: vira a Cota-parte v2, só do administrador, com a gravada e o aviso** (a pergunta 7; a tela na fatia 67 e o arquivo na 68) |
| VIP Class (relatório) | quem fez o checkpoint de cada curso | duplicado no administrador do VIP Class; EM USO (2.131 checkpoints de 341 usuários, o último em 10/08/2026); o relatório por curso exige `escrever`; D237 | fica com a etapa do VIP Class (item 8 da ordem) |

Fora do menu, pela mesma decisão:

- **O painel da unidade do Power BI** (Dash. Unidade) saiu em 08/09/2026 e nada entrou
  no lugar; a funcionalidade 61 ficou órfã, com 105 pessoas. O que ele mostrava nunca
  foi inventariado (D9). Parte disso a home nova cobre; o resto é pergunta ao dono.
- **As telas que funcionam como relatório fora do menu** -- histórico de consumo e de
  pontos do cliente, extrato de vendas do convênio, estatísticas do cupom, planilha do
  sorteio, faturas, clientes novos e sem retorno da agenda, acessos por unidade e por
  usuário -- ficam onde estão, e entram na mesma revisão.
- **O que nenhuma tela mostra hoje:** o fiado em aberto (R$ 67.181,85), o crédito dos
  clientes (R$ 93.448,83), os pontos ativos (18,2 milhões, perto de R$ 910 mil), os
  royalties vencidos (180 faturas, R$ 286.170,34, 50 delas de unidades inativas), as
  comandas abandonadas, os caixas esquecidos abertos (139) e os envios da mensageria
  automática.

O levantamento inteiro -- os cartões de cada relatório, as 35 duplicidades e o
veredito de cada uma -- está em `docs/f3-relatorios.md`.

#### O Radar e o Fluxo viraram opções do painel (13/10/2026, a resposta 137 do dono)

**A escolha** (a 137, a opção (a)): *"Viram opções do painel, calculadas na hora -- A comparação com o ano anterior e o
corte por dia da semana, com todos que venderam. Saem o atalho, as duas telas, a tela antiga do 'Dash. Colaboradores' (o
Profissional Parceiro já a substitui) e as rotinas; a tabela antiga fica guardada, como você decidiu em 01/10. Com a tela
antiga saem as estrelas do plano de carreira, que só os administradores veem, até você voltar a elas (a sua de 30/09)."*

- **O Radar de Temperatura é a comparação do painel**: "comparar com o mês anterior" (o de sempre) ou "o ano anterior"
  (`?comparar=ano-anterior` em `GET /adm/api/painel` e em `/painel/clientes`; `RegraDoPainelDaUnidade::interpretar`). O
  mesmo mês do ano anterior inteiro, ou os mesmos dias no mês em andamento; o retorno do mês anterior e as janelas do
  retorno da comparação contam do fim desse mesmo mês. As buscas são duas faixas, e não treze meses; com o mês anterior,
  a consulta é a de antes. É a base do painel -- a venda finalizada com item, fechada por alguém da unidade --, e não a
  soma do grupo 1 em `dashboard_colaboradores`: entram os caixas e os gerentes que venderam (os R$ 54.181,45 de julho), e o
  mês fecha com o cancelamento.
- **O Fluxo de Movimento é o "Movimento por dia da semana"**, fechado por padrão (`GET /adm/api/painel/semana`, a parte
  porque lê a rede do mês): para cada dia da semana, o faturamento da unidade, os dias com venda e o faturamento por dia
  com venda -- a régua do painel --, ao lado da média de uma unidade da rede no mesmo dia (o faturamento da rede pelos
  pares unidade e dia com venda). A tela antiga dividia pelos dias do calendário e somava as linhas repetidas da rotina.
- **Saíram**: o atalho do topo do ERP; os itens Dash. Colaboradores, Radar de Temperatura e Fluxo de Movimento do menu
  antigo; as três telas (os controllers, as factories, os formulários, as views, e o gráfico do fluxo e o extrato do plano
  de carreira em `mod-relatorios.js` e no `.min.js`); as rotinas `relatoriosDashboardColaboradoresAtualizar` (23:30) e
  `relatoriosDashboardFluxoMovimentoAtualizar` (07:00 do dia 1), com as rotas, a documentação da API e as rotas públicas;
  e as consultas que só elas usavam em `DashboardColaboradorRepository` e `DashboardMovimentoRepository`. Com a tela antiga
  saíram as estrelas do plano de carreira.
- **Ficaram guardadas** `dashboard_colaboradores` -- a régua antiga da ocupação do Profissional Parceiro (a B6, lida por
  `parceiroReguaAntiga`) -- e `dashboard_movimentos`. Nenhuma tabela e nenhuma linha apagadas; ninguém mais grava nelas.
- **O Profissional Parceiro virou destino só da tela nova** (`/profissional-parceiro`, em `SO_NA_TELA_NOVA`): o endereço
  antigo é 404, e o menu novo desenha o item como botão.
- **No corte**, o passo 9 tira as duas rotinas do crontab guardado da produção antes de instalar o novo (23 linhas, das 25
  lidas em 27/08/2026) -- sem isso elas voltariam, com 404 todo dia e o aviso do `MAILTO`; a tabela do crontab em
  `docs/f1-6-agendamento.md` as tirou, e diz que a produção as chama até o corte.

**O que fica deste débito:** os outros itens da tabela -- Aniversariantes, Clientes, Evasão, Fidelização de Clientes,
Potencial de Franquia, Vendas Ver. 2, VIP Class e o Dash. Faturamento -- seguem com as respostas deles (a 138, as decisões 6
e 7), e não com este item. E três perguntas foram ao lote (PARCEIRO-RADAR-E-FLUXO): quem perdeu o Radar e o Fluxo -- o
painel só abre para o gerente de unidade que não é administrador, e o administrador e quem tinha o privilégio sem ser
gerente não chegam a ele (o **D487**) --; os quatro números do Radar que o painel não compara (o **D488**); e os três
privilégios que ficaram sem tela (o **D485**).

**A revisão de 13/10/2026** (o mesmo item, depois da verificação) achou e consertou, além do D311 e do D487:

- **A semana lia a rede do mês sem fila nem trava.** Trocar de mês com o cartão aberto punha várias leituras da rede no
  servidor ao mesmo tempo -- a de antes continuava lá, só descartada na tela. A semana anda agora numa fila da aba
  (`filaDaRede.ts`, a mesma dos clientes da rede e do melhor da rede): com uma no ar, a do mês novo espera ela voltar, e
  a que a tela deixou de querer nem sai. O servidor não a trava -- a 118 fala do Profissional Parceiro --: outra aba, ou a
  API direto, ainda põe duas no ar.
- **Abrir a semana com os números de outro mês no ar** não tinha caso: tirar a guarda pedia a semana do mês que a tela
  estava deixando. Caso novo, e o mutante vermelho.
- **A rota da semana só se provava pelo ensaio HTTP** (`pedir` despacha pela ação, sem a rota): o
  `PainelDaUnidadeTest::testOsCaminhosDoPainelLevamCadaUmASuaAcao` casa os três caminhos pelo roteador da aplicação. E o
  `medir-adm-api-no-postgres.php` pede da semana o faturamento da unidade (`PRECISA_SOMAR`): os sete dias vêm sempre, e a
  lista sozinha passava com a semana zerada.
- **O cenário do ano anterior não tinha compra de crédito**: ler as compras só da faixa do mês passava verde. O
  `CREDITO_EM_2024` estreia comprando crédito em junho de 2024, e os clientes novos do ano anterior são 3, e não 2.
- **O menu Relatórios do ERP antigo** (`SessaoAcl::menuAcesso` e `::menuAtivo`) mudou sem caso:
  `MenuDosRelatoriosSemAsTelasQueSairamTest`.
- **O ensaio do passo 9 não tinha a irmã que fica** (`relatoriosDashboardFaturamentoAtualizar`, das 05:35): um filtro por
  `relatoriosDashboard` inteiro passava verde e tirava a rotina viva no corte. Ela entrou no crontab inventado, e o filtro
  largo e o filtro arrancado ficam vermelhos.
- **Quatro números do Radar não têm par no painel** -- a ocupação da agenda, os agendamentos pelo app e o valor dos
  serviços e dos produtos contra o ano anterior --, e nada registrava a perda: o **D488**, e a pergunta foi ao lote.

**Provado:** a tela -- `PainelLigado.teste.tsx` com 43 casos, onze novos: os nove do item (a comparação no pedido, nos
rótulos e no mês trocado; a escolha parada com pedido no ar; a semana fechada sem pedido, aberta com os sete dias,
acompanhando o mês, sem pedir de novo na troca da comparação e pedindo de novo no atualizar; a falha e o "tentar de
novo"; os de outra comparação e de outro mês "contando") e os dois da revisão (a semana aberta com os números no ar, e a
semana na fila) -- e doze mutantes da tela vermelhos, um por vez: os dez do item e os dois da revisão; o front inteiro
(1.714 de 1.714, em 47 arquivos), o build e o lint; os portões estáticos (a cobertura por par, a concatenação, as referências, o recorte, os links mortos, as
rotas, o inventário, o agendamento com o D486) e o ensaio do passo 9, com os dois filtros errados vermelhos. **No PHP do
host, sem banco** (o Docker continuava sem subir na revisão): o `RegrasDoPainelDaUnidadeTest` e o
`RegrasDoProfissionalParceiroTest` inteiros, por um arremedo do PHPUnit (49 e 42 casos, verdes), e sete mutantes das
regras vermelhos ali, um por vez (a comparação sempre com o mês anterior, as faixas sem juntar, o mês antes do comparado
errado, o 29 de fevereiro sem o teto, a semana contando o dia sem a unidade, a semana com a venda sem unidade, e a semana
pelo calendário); a mesma conta do caso das rotas, com o roteador do `module.config.php`, verde, e vermelha com a semana
levando à ação dos clientes; e a do caso do menu, verde, e vermelha com o Dash. Colaboradores de volta na conta e com o
Radar de volta no menu ativo. **Na revisão, não provado: o servidor, nos bancos.** Os casos do `PainelDaUnidadeTest`
(junho de 2025 contra junho de 2024, com a compra de crédito de lá, e a semana de junho, contra um oráculo das linhas
cruas; os caminhos pelo roteador) e o `MenuDosRelatoriosSemAsTelasQueSairamTest` foram escritos e não rodaram, e o
PHPUnit não rodou em banco nenhum -- nem os dois `Regras*`, que só o arremedo exercitou: o Docker da máquina travou
neste dia. Ficaram para a rodada seguinte, com os mutantes do controller -- que é o parágrafo abaixo.

**Provado no servidor (13/10/2026, depois do reinício da máquina).** O PHPUnit de verdade (o 10.5, no PHP 8.1 do
contêiner), nos bancos só de fixtures que o CI monta, filtrado nos arquivos do item e nos vizinhos (26 classes, 651
casos): **MySQL, 651 de 651 com 10.453 asserções; PostgreSQL, 651 de 651 com 10.452** -- nos dois, 5 pulados com o
motivo escrito, nenhum deste item (o faturamento de 2015, o desconto fora da unidade, a VIP Class com usuário em duas
unidades). Entram o `PainelDaUnidadeTest` (42, com o ano anterior e a compra de crédito de 2024, a semana contra o
oráculo e os três caminhos pelo roteador), o `MenuDosRelatoriosSemAsTelasQueSairamTest` (2), os dois `Regras*` no
PHPUnit de verdade (49 e 42), o `CronTravaSimultaneaTest`, o `D203TextoDigitadoNasTelasDoErpTest`, o
`ColaboradoresNoRelatorioTest`, o `VendasNoRelatorioTest`, e os que leem as rotas, os controllers, o menu e as rotinas
(`PrivilegiosPelosControllersTest`, `GuardaDeLoginTest`, `SegredosForaDoCodigoTest`, `BarreiraDeOrigemNaEntradaTest`,
entre outros). **O vermelho da primeira rodada** foi o piso das rotinas do cron no `SegredosForaDoCodigoTest` -- 26
rotinas com token, e 22 sem as quatro que cobram ou enviam --, que não acompanhou as duas rotinas que saíram: são 24
e 20, e o piso desceu, com a data no comentário. **Os dois mutantes do controller, um por vez, no MySQL, com o
arquivo devolvido e conferido por sha1, vermelhos:** a semana lendo a unidade em vez da rede
(`testOMovimentoPorDiaDaSemanaContraUmOraculo`, "os pares da rede": 1 no lugar de 2) e as compras de crédito só da
última faixa (`testOsClientesContraOMesmoMesDoAnoAnterior`: o anterior 2 no lugar de 3, e a variação 150,0 no lugar
de 66,7). **Não rodou:** os ensaios HTTP (o `medir-adm-api-no-postgres.php` com o `PRECISA_SOMAR`) e a tela no
navegador.

#### A Fidelização de Clientes saiu, com a rotina (25/10/2026, a resposta 138 (a) do dono; a fatia 41, FIDELIZACAO-SAI)

**A escolha** (a 138, a opção (a), "A proposta de cada um"): *"A Fidelização sai, junto com a rotina; a fidelização ao
colaborador já está no Profissional Parceiro, com a sua janela de 45 dias."* Esta fatia faz só a Fidelização; Clientes e
Evasão (a carteira), Aniversariantes ("Datas da equipe") e Potencial seguem com as fatias deles na fila das telas.

- **Saíram:** a rotina `relatoriosFidelizacaoAtualizar` (`CronJobService`, a das 03:10, que gravava um arquivo por unidade
  em `data/cache/relatorios/fidelizacao/` que nenhuma tela lia, e que contava o cliente que PASSOU PARA OUTRO colaborador),
  a ação do `CronJobController`, a rota `isapi-cronjob-relatorios-fidelizacao-atualizar` (o pedido é 404), a entrada da
  documentação da API (`cronjob-fidelizacao-relatorio`), a linha das rotas públicas e do `conferir-rotas-sem-login.php`;
  o item escondido do menu, o `Form/Fidelizacao.php`, a view que o desenhava e a linha do `menuAtivo` (o D105); e as duas
  consultas sem outro chamador: `VendaRepository::fidelizacaoPorColaborador` (só a rotina a chamava) e `ClienteRepository::fidelizacao`
  (ninguém a chamava desde 13/10/2026, quando saiu a rotina das 23:30 do Dash. Colaboradores; uma sobra do D485, e não
  desta rotina). A tabela do crontab e a das janelas em `docs/f1-6-agendamento.md` a tiraram, e o
  inventário de ações foi refeito (352 ações, 26 rotinas).
- **Ficou:** a `fidelizacaoAtualizar`, das 23:50, que é outra coisa -- desfaz a fidelização do cliente sem retorno em 120
  dias (o `FidelizacaoTest`) -- e anda junto em cada caso e no ensaio do crontab, para um conserto largo demais ficar
  vermelho. Os arquivos que a rotina já gravou na produção ficam onde estão, e ninguém mais os escreve (o **D610**; a
  pergunta foi ao lote).
- **No corte:** o primeiro bloco do passo 9 de `docs/procedimento-de-corte.md` tira a linha das 03:10 do crontab guardado da
  produção, como as duas de 13/10/2026 (22 linhas, das 25 lidas em 27/08/2026), e o recuo do passo 12 de
  `docs/procedimento-de-corte-etapa-1.md` também. A produção, congelada, segue chamando-a no código de lá até o corte; nada
  foi mexido nela nem no BVIP.

**Provado:** o `FidelizacaoDeClientesSaiuTest` (4 casos), nos dois bancos -- o roteador da aplicação não casa
`/api/cronjobs/relatoriosFidelizacaoAtualizar` e o nome da rota não existe, e o da irmã das 23:50 casa a ação dela; a ação,
a rotina e o formulário não existem, e os da irmã sim; o menu desenhado sem o item; o `menuAtivo`. O piso das rotinas com
token do `SegredosForaDoCodigoTest` desceu de 24 para 23 (e o das inofensivas, de 20 para 19), com a data no comentário. O
`ensaiar-crontab-do-passo-9.sh` ganhou a linha das 03:10 e a da irmã das 23:50 no crontab inventado, e o caso novo do
`grep -vE` do recuo da etapa 1, tirado do documento. Mutantes, um por vez, restaurados por sha1: a rota de volta, a ação de
volta, o item de volta ao menu, a linha de volta ao `menuAtivo` (o caso vermelho em cada um); a rotina de volta no
crontab do passo 9, o filtro largo que pega a irmã, e a rotina de volta no recuo da etapa 1 (o ensaio vermelho em cada um).

**Depois da verificação da fatia (25/10/2026, catorze achados confirmados, treze distintos, todos tratados).** Dois tinham prova faltando:
(1) a entrada `cronjob-fidelizacao-relatorio` podia voltar à `ApiDocsController` com tudo verde -- o
`SegredosForaDoCodigoTest` só cobrava a rota sem página, e não a página sem rota; agora cobra as duas direções (o mutante,
a entrada de volta: vermelho só por esse caso); (2) a linha das 03:10 de volta à tabela do `f1-6-agendamento.md` ficava
escondida no `conferir-tudo.sh` atrás do vermelho conhecido do D50, porque ele só lia o código de saída do
`conferir-agendamento.php`; o roteiro ganhou o `--conhecidas=`, que tira da conta só a `conveniosAtualizarVencimento`, e o
conjunto o roda por `rodar` (o mutante: a órfã acusada, saída 1). A view órfã do relatório saiu (o D105), e o contrato do
aplicativo rodou nos dois bancos. O resto foi texto: a seção 8 do `docs/f3-relatorios.md` atribuía a 138 à decisão 8 do
Profissional Parceiro, o `docs/f1-4-consultas.md` falava da `fidelizacaoPorColaborador` no presente, três contagens da
etapa 1 ficaram em "duas", e a seção dos portões da etapa 1 não dizia que o ensaio do crontab lê a linha do recuo.

**01/11/2026 (a fatia 64, REL-ATENDIMENTOS-API; a A2 de `docs/f3-relatorios/spec-atendimentos.md`).** A linha do Dash.
Faturamento é absorvida pelo Atendimentos novo (a pergunta 6 de 01/10/2026): a linha da unidade da tela nova traz, mês a mês no
ano, o faturamento, os atendimentos, os clientes, as médias por cliente, o ticket e o crescimento em porcentagem e em reais,
calculados na hora. A tela antiga, as duas rotinas e o cache saem na A4, depois do corte; até lá o Dash. Faturamento continua
no menu antigo. Os outros itens desta lista seguem com as respostas deles. PARCIAL.

**01/11/2026 (a fatia 65, REL-ATENDIMENTOS-TELA; a A3 de `docs/f3-relatorios/spec-atendimentos.md`), as respostas 274 a 276 do
dono, as três (a), a recomendada.** A 274 (a): a linha da unidade fica com a régua nova das médias por cliente (só os clientes
com cadastro, nos dois lados da conta), como já estava na fatia 64; a diferença para o antigo está agora escrita no "Como os
números são contados" da tela (`atendimentos.mediasDaUnidade`) e nas notas do PDF e do XLS (`AtendimentosJson::NOTAS`). A 275
(a): uma unidade e um ano por vez; a visão da rede espera o "mês fechado", e o Dash. Faturamento sai na A4 sem substituto para a
tabela de várias unidades e vários anos (escrito na A4 do desenho). A 276 (a): na A4, quem vê o Dash. Faturamento e não vê o
Atendimentos (duas pessoas na cópia em 01/11/2026) recebe o Atendimentos, contadas de novo na A4, sem nome (escrito na A4 do
desenho; nenhum privilégio muda agora). A linha do Dash. Faturamento desta lista fecha com a A4. PARCIAL.

**A Vendas Ver. 2 respondida (02/11/2026, a fatia 68, REL-COTA-PARTE-ARQUIVO; a CP5 do `docs/f3-relatorios/spec-cota-parte.md`).** A
linha "Vendas Ver. 2" da tabela acima foi respondida em 01/10/2026 (a pergunta 7: "por padrão, a recalculada. Mas crie um espelho
deste relatório como cota-parte v2 - disponivel apenas para gerente geral, com a gravada e com aviso"): é a Cota-parte v2 da tela
nova, só do administrador, com a cota gravada na venda e o aviso -- a API na fatia 66, a tela na 67 e o PDF e a planilha `.xlsx`
na 68. **O que resta deste item:** a Ver. 2 antiga (`PdfVendasVersao2`, `Xls/VendasVersao2`) continua no ERP antigo até a
aposentadoria dos arquivos antigos, e desde a 68 só o administrador a abre (o `?versao=2` dos outros sai na versão 1, o D376).
PARCIAL.


#### Os três desenhos da 138 (a) (03/11/2026, a fatia 76, RELATORIOS-DA-138-DESENHO; só documentação)

Os três itens que a resposta 138 (a) deixou depois da Fidelização ganharam a especificação, no molde das dos outros relatórios:
a **Carteira de clientes** (Clientes e Evasão, `docs/f3-relatorios/spec-carteira.md`), as **Datas da equipe** (o Aniversariantes,
`docs/f3-relatorios/spec-datas-da-equipe.md`) e a **Capacidade × realizado** (o Potencial de Franquia,
`docs/f3-relatorios/spec-capacidade.md`), com as medidas na cópia, as fatias e os testes de cada um. Três pontos foram ao dono no
lote: o contato na Carteira (D690), os arquivos da Evasão na produção (D693) e o potencial em reais (D692); a verificação, no
mesmo dia, levou o quarto: a lista filtrável do Clientes antigo, que a Carteira não repete (D696). **Resta** cada fatia
rodar; o Consultor Visagista e o VIP Class seguem com as decisões deles, e os PDFs e o XLS antigos do Cota-parte seguem no ERP.

**As Datas da equipe no servidor (04/11/2026, a fatia 76f, DATAS-DA-EQUIPE-DE0; a DE0 de `docs/f3-relatorios/spec-datas-da-equipe.md`).**
A linha "Aniversariantes" da tabela acima foi respondida pela 138 (a) ("Aniversariantes vira 'Datas da equipe'"): a política
`datasDaEquipe` ligada com a API (`GET /adm/api/relatorios/datas-da-equipe`, a forma de período nova `mesDoAno`), o item "Datas da
equipe" no menu Relatórios da tela nova (com a tela não migrada até a DE1) e a tela antiga com a mesma política no mesmo commit (a
E6: na cópia, 1 caixa e 1 "outro" com o privilégio perdem a tela antiga; os 105 gerentes e os administradores seguem). **Resta** a
tela (a DE1) e a saída do Aniversariantes antigo com as quatro consultas mortas do D73 (a DE2). PARCIAL.

**A tela das Datas da equipe (04/11/2026, a fatia 76g, DATAS-DA-EQUIPE-DE1; a DE1 de `docs/f3-relatorios/spec-datas-da-equipe.md`).**
O item "Datas da equipe" do menu Relatórios abre a tela nova (`frontend/src/telas/DatasDaEquipe.tsx` e o invólucro
`DatasDaEquipeLigada.tsx`), no lugar da tela não migrada: os doze meses sem ano, os aniversários e o tempo de casa da equipe e,
para o administrador, as inaugurações com os sócios e os aniversários dos sócios, com a data inteira e o contato. **Resta** a
saída do Aniversariantes antigo com as quatro consultas mortas do D73 (a DE2). PARCIAL.

**O Aniversariantes antigo saiu do ERP (04/11/2026, a fatia 76h, DATAS-DA-EQUIPE-DE2; a DE2 de `docs/f3-relatorios/spec-datas-da-equipe.md`).**
Saíram o item "Aniversariantes" do menu Relatórios do `layout.phtml`, o `AniversariantesController` (com a guarda da E6), a fábrica,
o invokable e o alias `rel-aniversariantes` do `module/ISRelatorio/src/Module.php`, o `Form/Aniversariante.php`, a view, as linhas do
`SessaoAcl::menuAcesso` e do `::menuAtivo`, e as quatro `relatorioSelecionarAniversariantes*` (o D73 tem as quatro mortas, que
saíram junto). A linha do `menuAcesso` não tira só o menu vazio de quem tinha só o das Datas: a conta é uma lista curta, e quem
tinha o das Datas com um dos seis itens de fora dela (o **D702**) perde o grupo com o item -- ninguém na cópia. `/mod-relatorios/rel-aniversariantes/index` dá 404 (o curinga casa, o gerente de controllers não tem quem atenda); o
privilégio `relatorios/aniversariantes` fica, e é o da tela nova. O caminho `/isrelatorio/rel-aniversariantes/index` ficou como a
chave da tela nova no front, sem endereço no ERP: o item do menu virou botão (`SO_NA_TELA_NOVA`), e a ponte da tela não migrada não
o oferece. Provado no `AniversariantesSaiuTest`, nos dois bancos; pelo HTTP no contrato MySQL, como o gerente de teste com o
privilégio, o endereço antigo 404 e a API 200. A linha "Aniversariantes" da tabela acima fecha aqui. **Resta deste débito** o que
não é das Datas: a Carteira (CA1 em diante), a Capacidade × realizado, o Consultor Visagista, o VIP Class, o Dash. Faturamento (a
A4) e os PDFs e o XLS antigos da Cota-parte. PARCIAL.

**A Capacidade × realizado no servidor (04/11/2026, a fatia 76i, CAPACIDADE-CR0; a CR0 de `docs/f3-relatorios/spec-capacidade.md`).**
A linha "Potencial de Franquia" da tabela acima foi respondida pela 138 (a) ("Potencial vira 'Capacidade × realizado' da rede, só
para o administrador") e pela 291 (a) (sem potencial em reais): a política `capacidade` ligada com a API
(`GET /adm/api/relatorios/capacidade` e `.../unidades`, o mês do painel, só o administrador pelo papel), o item "Capacidade ×
realizado" no menu Relatórios da tela nova (pelo `exige`, com a tela não migrada até a CR1) e, no mesmo commit, a tela antiga do
Potencial (`FranquiasController::potenciaisAction`) e o item do `layout.phtml` pela mesma política (a E6) -- na cópia, nas duas, nenhum
não administrador ativo tem o privilégio `relatorios/franquias/ler` (contado só com SELECT, pelo grupo e por usuário): o efeito hoje
é zero. **Resta** a tela (a CR1) e a saída do Potencial antigo (a CR2, que fecha o D692 e o D695). PARCIAL.

**A tela da Capacidade × realizado (04/11/2026, a fatia 76j, CAPACIDADE-CR1; a CR1 de `docs/f3-relatorios/spec-capacidade.md`).**
O item "Capacidade × realizado" do menu Relatórios abre a tela nova (`frontend/src/telas/Capacidade.tsx` e o invólucro
`CapacidadeLigada.tsx`), no lugar da tela não migrada: o administrador abre na rede, no mês de hoje pelo servidor, com a navegação
do mês (o › desligado no mês em andamento) e a escolha da unidade; a tabela das unidades em ordem do nome, com a linha da rede; a
régua antiga numa linha à parte, marcada, e nunca somada com a do horário da época (nem na rede); a taxa nula em travessão, nunca
0%; a cobertura dita; e o aviso das unidades hoje fechadas que venderam no mês (o `rede.foraDasLinhas`, a (a) do D704 até a
resposta). **Resta** a saída do Potencial antigo do ERP (a CR2, que fecha o D692 e o D695). PARCIAL.

**O Potencial de Franquia saiu do ERP antigo (04/11/2026, a fatia 76k, CAPACIDADE-CR2; a CR2 de
`docs/f3-relatorios/spec-capacidade.md`, a resposta 291 (a) do dono).** Saíram o item "Potencial de Franquia" do menu Relatórios
do ERP, a `FranquiasController::potenciaisAction` (com a guarda da E6 que a CR0 lhe pôs), o `calcularPotenciais` e o
`totalHorariosDisponiveisAgenda` do controller, o `Form/FranquiaPotencial.php` e a view; o controller fica, com os Royalties. A
Capacidade × realizado mora só na tela nova, e o caminho `/isrelatorio/rel-franquias/potenciais` ficou como a chave dela, sem
endereço no ERP (`SO_NA_TELA_NOVA`: o item do menu novo é um botão). Saíram junto, escritos: o potencial em reais e a única conta
de capacidade de um mês futuro ou de um intervalo livre (a E5). Que não volte, o `PotencialSaiuTest` cobra, nos dois bancos. Dos
relatórios que ficaram fora da primeira lista do dono, **restam** os que seguem no ERP antigo: Clientes e Evasão (a Carteira, a CA1
em diante), o Consultor Visagista, o VIP Class, o Dash. Faturamento (a A4; o item `rel-dashboard-faturamentos` segue no
`layout.phtml`) e os PDFs e o XLS antigos da Cota-parte (a Vendas Ver. 2). PARCIAL.

**A ocupação da rede com o aviso (04/11/2026, a fatia 76l, CAPACIDADE-CR3; a resposta 299 (b) do dono, o D703).** Na Capacidade ×
realizado, a linha da rede passou a ter a ocupação da soma das unidades também nos meses em que falta o horário gravado de alguém,
com "N pessoas sem o horário gravado ficaram fora da conta" na própria linha. **Resta**, além dos relatórios acima, a CR4 da
Capacidade (a unidade hoje desativada que vendeu no mês como linha, o D704, que espera a resposta do D705). PARCIAL.

**A unidade desativada que vendeu (04/11/2026, a fatia 76m, CAPACIDADE-CR4; as respostas 300 (b) e 301 (c) do dono, o D704 e o
D705).** Na Capacidade × realizado, num mês passado, o administrador vê também as unidades hoje fechadas que trabalharam nele,
marcadas "desativada", na soma da rede (a agenda delas só na régua com algum horário ocupado, e a linha diz o que ficou fora), e pode
escolher uma delas no filtro. A Capacidade × realizado fechou as cinco fatias do desenho (a CR0 a CR4). **Restam** os relatórios que
seguem no ERP antigo, acima. PARCIAL.

**A Carteira de clientes no servidor (04/11/2026, a fatia 76b, CARTEIRA-CA1; a CA1 de `docs/f3-relatorios/spec-carteira.md`, a
resposta 294 (a) do dono).** As linhas "Clientes" e "Evasão" da tabela acima foram respondidas pela 138 (a) (uma carteira de clientes
com faixas de recência, no lugar das duas): a política `carteira` ligada com a API (`GET /adm/api/relatorios/carteira`, `.../clientes`
e `.../unidades`, o retrato de hoje e a lista por página, para o gerente e o administrador pelo papel), o item "Carteira de clientes"
no menu Relatórios da tela nova (com a tela não migrada até a CA2) e, no mesmo commit, o Clientes antigo pela mesma política nas
quatro ações (a E10). **Resta** a tela (a CA2), a saída da Evasão antiga (a CA3), os quatro filtros da lista (a CA5), a exportação (a
CA6) e a saída do Clientes antigo (a CA4); e a resposta do D708 (as unidades hoje fechadas na rede). Dos outros: o Consultor
Visagista, o VIP Class, o Dash. Faturamento (a A4) e os PDFs e o XLS antigos da Cota-parte. PARCIAL.

**A tela da Carteira de clientes (04/11/2026, a fatia 76c, CARTEIRA-CA2; a CA2 de `docs/f3-relatorios/spec-carteira.md`).** O gerente
e o administrador abrem Relatórios › Carteira de clientes na tela nova, no lugar da tela não migrada: o retrato de hoje (o "hoje" do
servidor), os oito cartões das faixas com o em dia e o sumido em destaque, o resumo por colaborador na unidade e a tabela das unidades
na rede, com a frase de quantos estão numa unidade hoje fechada -- pela última visita, ou pelo cadastro de quem nunca veio (a E6) --,
e a lista de uma faixa ou de um colaborador, 50 por página, com o link da ficha, o telefone, a marca "não aceita mensagens" e, só
para o administrador, o e-mail. **Resta** a saída da Evasão antiga (a CA3), os quatro filtros da lista (a CA5), a exportação (a
CA6), a saída do Clientes antigo (a CA4) e a CA8 (a fatia 76q: as unidades fechadas como linha marcada na rede, pela resposta 304 (a)
do dono ao D708, que chegou em 04/11/2026; até ela, a frase); dos outros, o Consultor Visagista, o VIP Class, o Dash. Faturamento (a
A4) e os PDFs e o XLS antigos da Cota-parte. PARCIAL.

**As unidades hoje fechadas na Carteira da rede (04/11/2026, a fatia 76q, CARTEIRA-CA8; a resposta 304 (a) do dono ao D708).** Na
rede, o administrador vê uma linha para cada unidade hoje fechada que tem cliente na carteira (quem veio nela pela última vez, ou se
cadastrou nela e nunca veio), marcada "desativada", e a soma das linhas é a rede -- na cópia, as 25 fechadas com os 56.781 clientes
delas; a frase de quantos ficavam fora das linhas saiu. O filtro do administrador oferece essas fechadas, com " (desativada)", e
escolhida uma, o retrato e a lista são os dela; o gerente segue só com a unidade da sessão. **Resta** a saída da Evasão antiga (a
CA3), os quatro filtros da lista (a CA5), a exportação (a CA6) e a saída do Clientes antigo (a CA4); dos outros, o Consultor
Visagista, o VIP Class, o Dash. Faturamento (a A4) e os PDFs e o XLS antigos da Cota-parte. PARCIAL.

**A Evasão antiga saiu do ERP (04/11/2026, a fatia 76d, CARTEIRA-CA3; a CA3 de `docs/f3-relatorios/spec-carteira.md`).** O item
escondido do menu Relatórios, a tela e o PDF da Evasão e a rotina das 04:10 que escrevia o arquivo dele saíram; a Carteira de
clientes, na tela nova, é o lugar dele (o D691, CORRIGIDO). Os arquivos já gravados na produção ficam (a 290 (b), o D693), e o
privilégio fica no cadastro dos grupos, sem tela (o D485). **Resta** os quatro filtros da lista (a CA5), a exportação (a CA6) e a
saída do Clientes antigo (a CA4); dos outros, o Consultor Visagista, o VIP Class, o Dash. Faturamento (a A4) e os PDFs e o XLS
antigos da Cota-parte. PARCIAL.

**A exportação da Carteira (04/11/2026, a fatia 76e2 da fila das telas que faltam, CARTEIRA-CA6; a CA6 e a 1.10 de `docs/f3-relatorios/spec-carteira.md`).** O gerente e o administrador baixam a lista da Carteira que está na
tela em TXT, PDF e planilha, como no Clientes antigo (o D690, o D699, o D700 e o D706, CORRIGIDOS). **Resta** a saída do Clientes
antigo (a CA4, que deixa de esperar); dos outros, o Consultor Visagista, o VIP Class, o Dash. Faturamento (a A4) e os PDFs e o XLS
antigos da Cota-parte. PARCIAL.

**O Clientes antigo saiu do ERP (04/11/2026, a fatia 76e, CARTEIRA-CA4; a CA4 de `docs/f3-relatorios/spec-carteira.md`).** O item
"Clientes" do menu Relatórios, a lista com os 26 filtros e as 8 ordenações e os três arquivos saíram; a Carteira de clientes, na tela
nova, com a busca, os três filtros e os três arquivos, é o lugar dele, e no menu novo o item é um botão (o D696, CORRIGIDO). As
linhas "Clientes" e "Evasão" da tabela acima estão fechadas: as duas telas antigas saíram, e a Carteira fechou as nove seções do
desenho (da CA0 à CA8), em dez fatias: a 76a, a 76b, a 76c, a 76d, a 76e, a 76e1, a 76e2, a 76o e a 76p (a CA7 e o zero dela) e a 76q.
**Resta**, dos outros, o Consultor Visagista, o VIP Class, o Dash. Faturamento (a A4) e os PDFs e o XLS antigos da Cota-parte.
PARCIAL.

### D307 — As rotinas noturnas dos painéis antigos gravam números errados em cinco pontos — REGISTRADO em 29/09/2026 — PARCIAL em 13/10/2026: os pontos 3 e 4 saíram com as duas rotinas (a resposta 137); os 1, 2 e 5 são do Dash. Faturamento — PARCIAL em 01/11/2026: o Atendimentos novo calcula na hora e não tem os pontos 1, 2 e 5; as rotinas saem na A4

**Achado pelo levantamento dos relatórios, conferido por um cético com SQL na cópia.**
Boa parte da definição dos números dos painéis antigos mora nas rotinas que os
pré-calculam, e não nas telas. Cinco delas gravam errado:

1. **O dezembro fantasma.** `relatoriosDashboardFaturamentoAtualizar` recalcula a
   janela de quatro meses tirando o MÊS de `strtotime('-3 month')` e o ANO de
   `strtotime('-2 month')` (`CronJobService.php:55-57`). Em todos os dias de março ela
   grava zeros em "dezembro do ano corrente" e deixa de recalcular o dezembro anterior.
   Na cópia há 28 linhas de 12/2026, criadas em 01/03/2026; as 34 de 12/2025 nasceram
   do mesmo jeito e só foram sobrescritas em dezembro.
2. **O fim de 31/12 some do ano.** O cache de atendimentos do ano que acabou é refeito
   pela última vez na rodada de 31/12 às 02:10, e as vendas do resto do dia ficam fora
   do Atendimentos e do Dash. Faturamento: em 12/2025, R$ 2.240.162,09 no painel contra
   R$ 2.333.901,09 no recálculo -- R$ 93.739,00 a menos.
3. **O Fluxo de Movimento tira média de cópias.** `relatoriosDashboardFluxoMovimentoAtualizar`
   sempre insere, sem procurar a linha do mês (`CronJobService.php:873`): vieram da
   produção 159 trincas (unidade, mês, ano) repetidas, e a tela soma as linhas e divide
   pela contagem.
4. **O mês do Dash. Colaboradores não fecha.** A rotina das 23:30 só refaz o mês
   corrente; o que muda depois do dia 1º -- um cancelamento, um lançamento atrasado --
   não entra nunca. Em 2026 a tabela diverge do recálculo em até 10 linhas por mês; a
   pior, em 05/2026, com R$ 8.376,00 a menos.
5. **O crescimento zera** nos meses que saem da janela depois de um dia 29 a 31 (da
   família do D209) -- medido por um leitor em cinco dos últimos dezenove meses, e não
   conferido de novo.

**Por que não foi consertado:** são rotinas de produção que alimentam telas que o dono
ainda não decidiu se ficam (D306), e o painel novo da home não as usa -- calcula na hora,
das vendas. O conserto, ou a remoção, decide-se com o D306.

**Os pontos 3 e 4 saíram em 13/10/2026, com as rotinas** (a resposta 137 do dono; o D306): a do Fluxo de Movimento e a do
Dash. Colaboradores deixaram o código, com as telas que liam as tabelas delas. O movimento por dia da semana é opção do
painel, e o mês de cada colaborador é o Profissional Parceiro -- os dois calculam na hora, das vendas, e o cancelamento
de depois do dia 1º muda o mês passado. As duas tabelas ficam guardadas, com as linhas repetidas e o mês que não fechou:
`dashboard_colaboradores` é a régua antiga da ocupação (a B6), lida só para os meses sem a vigência do horário. **Os
pontos 1, 2 e 5 continuam**: são da rotina do Dash. Faturamento, que sai com o Atendimentos (a decisão 6 do dono).

**01/11/2026 (a fatia 64, REL-ATENDIMENTOS-API; a A2 de `docs/f3-relatorios/spec-atendimentos.md`).** Os pontos 1, 2 e 5
não existem no Atendimentos novo, que calcula na hora a partir das vendas: nenhum "dezembro fantasma" (o mês que ainda não
chegou vem marcado `futuro`), nenhum fim de 31/12 perdido (na unidade 1, dezembro de 2025 é R$ 315.337,28, e não os
R$ 305.960,28 da rotina; conferido pelo oráculo da fatia, `p64-oraculo-copia-*.txt` do rascunho) e nenhum crescimento zerado
(janeiro de 2026 dá −20,5%, contra o dezembro inteiro). As rotinas continuam gravando errado na tabela `dashboard_faturamento`
até a A4, e o Dash. Faturamento antigo continua as lendo. PARCIAL.

**01/11/2026 (a fatia 65; a A3).** A resposta 275 (a) do dono confirma que as rotinas saem na A4 sem ficar para a tabela de
várias unidades do Dash. Faturamento (a outra opção mantinha as rotinas, e os três pontos, até a visão da rede existir). Nada
mudou nas rotinas nesta fatia. PARCIAL.


### D308 — O "cliente novo" do painel lê cada venda pela tabela, e um índice de três colunas tira essa leitura — PREPARADO em 29/09/2026: criado na cópia local com a autorização do dono; em produção, na migração final — EM ABERTO em 04/11/2026 (294 a): a migração do passo 0 da etapa 1 — PREPARADO em 04/11/2026: a migração no laço do passo 0 da etapa 1 (a CA1 da Carteira)

**Achado ao medir o painel do gerente (F3, `docs/f3-relatorios.md`, seção 3).** Para saber
quem é cliente novo, o painel pergunta, de cada cliente das janelas da unidade, se ele já
tinha venda finalizada antes de uma data em qualquer unidade, e busca a primeira venda de
quem não tinha. As duas perguntas usam `FK_VENDAS_cliente`, que só tem `cliente`; o
`status` e a `data_criacao` de cada venda vêm da tabela, uma leitura por linha.

**Medido na cópia, 29/09/2026, unidade 1, julho de 2026 (3.682 clientes em quatro lotes),
cronometrado dentro da sessão do banco:**

| Buffer pool | A primeira versão (o MySQL fazia semi-join) | A de hoje (para na primeira venda) |
|---|---|---|
| 128 MB, o de fábrica | 27 a 41 s nos quatro lotes | 5 a 8 s nos quatro lotes |
| 2 GB, com as páginas na memória | 37 a 50 ms por lote | 45 a 74 ms por lote |

No PostgreSQL, as duas formas param no primeiro achado, de 6 a 83 ms por lote. O que
decide o tempo é o buffer pool de produção, que nunca foi lido (a mesma incógnita do
D189).

**O índice:** trocar `FK_VENDAS_cliente (cliente)` por `(cliente, status, data_criacao)`
no MySQL, e `vendas_FK_VENDAS_cliente (inquilino_id, cliente)` por
`(inquilino_id, cliente, status, data_criacao)` no PostgreSQL. As duas perguntas passam a
ser lidas só no índice, e a chave estrangeira continua servida pelo prefixo.

**Por que não foi feito:** índice em produção fica para a migração final (decisão do
dono); na cópia local, criar índice pede autorização (o precedente é o D189), e o painel
funciona sem ele. A consulta de hoje já não depende dele para ser correta.

**Criado na cópia local em 29/09/2026, com a autorização do dono** ("crie o indicie na copia
local"). Nos dois bancos o índice foi TROCADO, com o mesmo nome, para a chave estrangeira
continuar servida:

- MySQL (`franquiabv_dev`): `ALTER TABLE vendas DROP INDEX FK_VENDAS_cliente, ADD INDEX
  FK_VENDAS_cliente (cliente, status, data_criacao), ALGORITHM=INPLACE, LOCK=NONE` -- 18 s,
  2.269.253 vendas.
- PostgreSQL (`franquiabv_dev`, esquema `bvip`): `CREATE INDEX CONCURRENTLY` do novo
  `(inquilino_id, cliente, status, data_criacao)`, `DROP INDEX CONCURRENTLY` do antigo e
  `ALTER INDEX ... RENAME` para `vendas_fk_vendas_cliente` -- 14 s.
- Para desfazer: o mesmo `ALTER TABLE` com `(cliente)` no MySQL; o mesmo trio com
  `(inquilino_id, cliente)` no PostgreSQL. As definições de antes estão acima.
- **Os DDLs do repositório (`mysql.sql` e `postgres/schema.sql`) continuam com o índice
  antigo**, de propósito: eles montam os bancos dos testes e o de produção só muda na
  migração final. O teste que lê o plano roda sem o índice.

**Na mesma noite, a pergunta mudou -- e o índice passou a resolver menos.** O dono decidiu
que cliente novo é quem teve o primeiro SERVIÇO ou a primeira COMPRA DE CRÉDITO na rede
(`docs/f3-relatorios.md`, seção 2). A pergunta "comprou antes?" virou "teve serviço ou
crédito antes?": para cada venda do cliente, além do `status` e da data, ela olha
`venda_credito` e os itens da venda. O índice tira da tabela a busca das vendas, mas não a
dos itens. Medido na cópia, unidade 1, julho de 2026, os quatro lotes, com a máquina sem
outra carga:

| | MySQL, 128 MB | MySQL, 2 GB, na memória | PostgreSQL, cache quente |
|---|---|---|---|
| A pergunta nova, sem o índice | 13 a 14 s | — | 35 a 68 ms por lote |
| A pergunta nova, com o índice | 8 a 9 s | 33 a 100 ms por lote | 25 a 119 ms por lote |

Com pouca memória, o índice corta um terço do tempo; com as vendas na memória, ele quase
não muda nada, porque o que sobra é a leitura dos itens. Fazer da pergunta nova uma
leitura só de índice pediria mais dois índices -- `venda_credito` no fim deste, e
`(venda, produto)` em `vendas_produtos` --, que NÃO foram criados: a autorização foi para
este. A alternativa que tira o custo de vez é guardar a estreia de cada cliente, gravada
quando a venda é finalizada (`docs/f3-relatorios.md`, seção 4).

**30/09/2026: o índice entrou no gerador do PostgreSQL**, com a F2 do Profissional Parceiro
(`docs/f3-relatorios.md`, seção 5). A fidelização ao colaborador lê as visitas de cada cliente
da base numa faixa de datas (`VendaRepository::parceiroVisitasComServico`), e é o índice que faz
disso uma leitura de faixa: 1,0 s no mês da unidade 1 com ele, 29 a 32 s sem ele (emulado), no
MySQL com 128 MB. Produção é gerada pelo gerador no corte (`docs/procedimento-de-corte.md`), e
sem a linha em `$indicesAmpliados` de `scripts/gerar-ddl-postgres.php` ela levaria o índice de
uma coluna. O que isso muda na frase de cima sobre os DDLs do repositório:

- o **`postgres/schema.sql`** é saída do gerador (o CI compara os dois), e foi regenerado -- ele
  passou a ter o índice ampliado, e a linha do D189, que estava atrasada desde 88a7a54. Os bancos
  PostgreSQL de teste e de contrato, montados dele, também. As suítes nos dois bancos seguiram
  verdes, e o teste que lê o plano da pergunta "teve serviço ou crédito antes?" também;
- o **`mysql.sql`** continua com o índice antigo: é o retrato da estrutura de produção de hoje,
  que não muda antes do corte, e o banco de destino do corte é o PostgreSQL.

Continua "PREPARADO": em produção, o índice nasce com o schema no corte.

**A resposta 294 (a) do dono (04/11/2026, ao D697, "Aceitar a espera, com a conta exata"; registrada na fatia 76u,
RESPOSTAS-294-A-300) muda a decisão de cima.** O "índice em produção fica para a migração final" valia para o MySQL de produção
durante a etapa 1: o PostgreSQL da etapa 2 já nasce com o índice ampliado (o `postgres/schema.sql`, pelo gerador), mas a Carteira
de clientes (a CA1 do `docs/f3-relatorios/spec-carteira.md`) roda na etapa 1, sobre o MySQL de hoje, e sem o índice a conta dela
leva 6,8 a 6,9 s em vez de 1,7 a 1,8 s (o D697). Pela 294 (a), **a troca entra no passo 0 da etapa 1**
(`docs/procedimento-de-corte-etapa-1.md`, o passo 4 e o item 15 de "O que falta fazer antes da janela"): o mesmo `ALTER TABLE
vendas DROP INDEX FK_VENDAS_cliente, ADD INDEX FK_VENDAS_cliente (cliente, status, data_criacao), ALGORITHM=INPLACE, LOCK=NONE`
ensaiado acima na cópia (18 s, 2.269.253 vendas), com o mesmo nome -- a chave estrangeira segue servida pelo prefixo --, como a 196
(b) fez com o índice da agenda (o D189). Não apaga linha; a volta não o desfaz, e o código antigo não o sente. **Conferido de novo
nas duas cópias, só no catálogo** (`p76u-01-permissoes-indice-*.sql` e o `.txt` de mesmo nome, no rascunho da sessão): no MySQL o
`FK_VENDAS_cliente` tem `cliente,status,data_criacao` e serve à chave `FK_VENDAS_cliente` para `clientes`; no PostgreSQL, o
`vendas_fk_vendas_cliente (inquilino_id, cliente, status, data_criacao)`. O `mysql.sql` do repositório continua com `(cliente)`, o
retrato da produção de hoje, e muda junto com a migração. **O que falta, e é código:** a migração, na CA1 da Carteira (nenhum
`scripts/migracao-*.sql` a faz), com a linha `| 0 |` e o laço do `docs/procedimento-de-corte.md`, o laço do ensaio geral e o `mysql.sql` juntos (o
`scripts/conferir-passo-zero-do-corte.php` exige os quatro), e o gerador do PostgreSQL conferido contra o `mysql.sql` novo (a linha
de `$indicesAmpliados` passa a repetir o que o `mysql.sql` já diz). **EM ABERTO**: a migração; PREPARADO quando ela estiver no
laço, e CORRIGIDO depois do corte (a marca nova no fim do título).

**PREPARADO em 04/11/2026 (a fatia 76b, CARTEIRA-CA1; a resposta 294 (a) do dono ao D697).** A troca tem a sua migração,
`scripts/migracao-vendas-indice-cliente-status-data.sql` (o `DROP INDEX` e o `ADD INDEX` do mesmo nome num `ALTER` só,
`ALGORITHM=INPLACE, LOCK=NONE`; idempotente: com as três colunas, diz que já está ampliado; com outra forma, para com saída 1 sem mudar
nada), na linha `| 0 | **Aplicar` e no laço do passo 0 do `docs/procedimento-de-corte.md`, no laço do `docs/ensaio-geral-do-corte.md`
e com o `KEY` de três colunas no `mysql.sql` (o `conferir-passo-zero-do-corte.php` saiu 0; falsificado sem a migração no laço, sem o
`KEY` ou com o índice de outra forma, 1). O `postgres/schema.sql` regerado do `mysql.sql` novo é o mesmo, byte a byte. Ensaiada num
banco descartável (`p76b_ca1_descartavel`: a `vendas` da cópia, 2.269.254 linhas, os ids dos clientes, a chave estrangeira e o índice
de uma coluna; apagado no fim): a troca em 2,3 s (a tabela recém-carregada, na memória), a segunda vez em 0,08 s sem efeito, a chave
estrangeira de pé. Nas duas cópias locais o índice já estava (conferido no `information_schema` e no `pg_indexes` em 04/11/2026), e
nada foi rodado nelas. **O que resta:** rodar o laço na produção, no passo 0 da etapa 1.

### D309 — O Dash. Colaboradores deixa o barbeiro ler o extrato de um colega, e a marca que o esconde só vale no menu — PARCIAL em 30/09/2026: o extrato e a marca corrigidos; a tela antiga ainda esconde quem não tem a área — CORRIGIDO em 13/10/2026: a tela antiga saiu (a resposta 137)

**Achado no levantamento do Profissional Parceiro** (`docs/f3-relatorios.md`, seção 5),
lendo `module/ISRelatorio/src/Controller/DashboardColaboradoresController.php` e medindo na
cópia.

- **O extrato do plano de carreira não prende o colaborador a si mesmo.**
  `extratoPlanoCarreiraAction` (:81-127) exige o privilégio de ler o dashboard e a mesma
  unidade (D155), mas não o do plano de carreira (`dashboardColaboradoresPlanoCarreira`, que
  nenhum não administrador tem), e não compara o `colaboradorId` do POST com quem pede --
  a `indexAction` faz isso para o grupo 1 (:30), a ação do extrato não. Um barbeiro com o
  privilégio lê, de um colega da mesma unidade, seis meses de nome, unidade, fidelização e
  produtos vendidos (`extrato-plano-carreira.phtml`). Não há faturamento no extrato.
- **A marca `visivel_dashboard` só existe no menu** (`layout.phtml:333`,
  `SessaoJson.php:131`): o controlador não a confere. Na cópia, 27 usuários ativos do grupo 1
  sem a marca têm o privilégio e abrem a tela pelo endereço.
- **18 colaboradores do grupo 1**, com a marca e o privilégio, abrem a própria tela e a acham
  vazia: a lista filtra a área 3 (Barbeiro), e o cadastro deles não tem área.

**Por que não foi consertado no registro:** a tela vai ser substituída pelo Profissional
Parceiro novo, e as perguntas dele estavam com o dono.

**Consertado em 30/09/2026, com a fatia F1 do Profissional Parceiro, nas duas partes que
eram acesso:**

- O extrato do plano de carreira passou a exigir também o privilégio do plano
  (`dashboardColaboradoresPlanoCarreira`), o mesmo que a tela já usava para mostrá-lo -- e
  que nenhum não administrador tem. **E o escopo**, depois da revisão da mesma data: sem
  escopo, 404, como a tela; no escopo "próprio", só o próprio extrato; o gerente, só a unidade
  dele. Só o privilégio não bastava: quem o tivesse e só pudesse ver a si mesmo lia o extrato
  de um colega.
- A marca `visivel_dashboard` passou a valer no servidor, na tela antiga e na API nova, pela
  mesma função de escopo (`RegraDoProfissionalParceiro::escopo`, ver o D124): quem não é
  administrador nem gerente e não tem a marca não abre.

**O que continua:** a tela antiga ainda filtra a área 3 (Barbeiro), e os colaboradores sem
área cadastrada seguem sem linha nela. A tela nova não tem esse filtro -- a lista é de quem
fez serviço no mês --, e a antiga sai quando o D306 decidir o Radar.

**A tela antiga saiu em 13/10/2026** (a resposta 137 do dono; o D306): o `DashboardColaboradoresController`, as duas views,
o extrato do plano de carreira e o item do menu antigo. O que restava -- o filtro da área 3, que escondia os 26 sem área e
os 13 com venda -- saiu com ela; o Profissional Parceiro lista quem fez serviço no mês, e a marca e o escopo continuam
valendo nele (`RegraDoProfissionalParceiro::escopo`). O endereço antigo é 404: o alias saiu do `Module.php`, e o
controller e as views não existem mais. Os casos da tela antiga no `ProfissionalParceiroTest` saíram com ela.

**A prova é estática, e não depende do banco** (a revisão de 13/10/2026 pediu que o título dissesse o que rodou): os
arquivos da tela não existem; `RegrasDoProfissionalParceiroTest::testOLayoutNaoLevaMaisAsTelasQueSairam` (o layout sem
comentários não leva a nenhuma das três, e os aliases do `ISRelatorio\Module` não as têm) não rodou no PHPUnit -- o
Docker travou --, mas a mesma conta, com a classe e o layout de verdade, rodou no PHP do host: verde com o código de hoje,
e vermelha (8 falhas) com o layout do HEAD. O mesmo arquivo de casos, sem banco, rodou inteiro no host por um arremedo do
PHPUnit (42 casos, verdes). O menu antigo que o privilégio dele abria tem caso agora
(`MenuDosRelatoriosSemAsTelasQueSairamTest`, o D306), escrito e não rodado no PHPUnit, com a mesma conta verde no host.


### D310 — A venda de pacote não tem como gerar cota-parte, e o dono quer um percentual configurável no pacote — REGISTRADO em 30/09/2026 — PARCIAL em 03/11/2026: as três colunas no esquema e nas cópias (a fatia 70); o cadastro do percentual e a gravação na sessão, na 74 — CORRIGIDO em 03/11/2026: o percentual no cadastro do pacote, quem vendeu e a cota em cada sessão finalizada, e o bloco de quem vendeu na Cota-parte (a fatia 74); na produção, pelo corte

**CORRIGIDO em 03/11/2026 (a fatia 74 da fila das telas que faltam, PDV-COTA-DO-PACOTE; a P5 da seção 4 de
`docs/f3-relatorios/spec-ordem.md` e a seção 3 do `spec-cota-parte.md`; a pergunta 16 (b) do dono).** O desenho inteiro entrou:

- **O percentual** (`produtos.cota_parte_pacote`, de 0 a 100 com até duas casas, zero por padrão) se grava só pelo cadastro do
  pacote, nas três portas -- a API (`Adm\PacotesController`, o 422 em `campos.cotaPartePacote`), o ERP antigo (o campo com a recusa
  ao lado, a vírgula da casa) e a tela nova de pacotes --, pela regra pura `ISConfiguracao\Regra\RegraDaCotaDoPacote`. O POST cru do
  cadastro de PRODUTO continua sem gravá-lo (o D679).
- **Quem vendeu e a cota**, em toda sessão de pacote finalizada (a comanda e a agenda passam pelo mesmo
  `VendaPagamentoService::atualizarValorComissaoPacote`): o colaborador do item `pac` da venda de origem do pacote do cliente,
  gravado também com 0%, e a cota = o valor da sessão vezes o percentual de hoje, em centavos inteiros. A comissão de quem executou
  não muda. Sem o item na venda de origem (2 dos 3.475 pacotes de cliente da cópia), a sessão fica sem vendedor e a finalização
  segue. O passado não se refaz.
- **A data**, a linha `cotaDoPacote` de `colunas_gravadas`, num passo próprio (`scripts/colunas-gravadas-cota-do-pacote.sql` e o
  irmão), nas duas cópias em 03/11/2026 e, na produção, no passo 7 da etapa 1 do corte (o dono).
- **A Cota-parte** credita a sessão duas vezes -- a quem executou, como antes, e a quem vendeu, no bloco `pacotesPorSessao` --, com
  as duas réguas e o aviso; o `cota.pacotes` deixa de ser nulo a partir da data; na lista, no extrato, no PDF e na planilha.

Na cópia, nenhum dos 144 pacotes ativos (nem dos 239 no total) tem percentual: nada muda até alguém configurar. Ficaram duas
escolhas do dono, que não seguram o conserto (o padrão do desenho não perde dado): o D683.

**PARCIAL em 03/11/2026 (a fatia 70 da fila das telas que faltam, PDV-CANCELAMENTO; a P0 de `docs/f3-relatorios/spec-ordem.md`).** A `scripts/migracao-cota-do-pacote.sql` criou `produtos.cota_parte_pacote` (0 por padrão), `vendas_produtos.vendedor_do_pacote` (com a chave para `usuarios`) e `vendas_produtos.cota_do_vendedor`, nas duas cópias locais (a resposta 83 (a)) e no laço do passo 0 do corte; as entidades `Produto` e `VendaProduto` as mapeiam. **Resta, para a fatia 74 (PDV-COTA-DO-PACOTE):** o campo no cadastro do pacote (as duas telas e a API), a finalização da sessão gravando quem vendeu e a cota (`VendaPagamentoService::atualizarValorComissaoPacote`), a linha `cotaDoPacote` de `colunas_gravadas` depois desse código, e a leitura na Cota-parte. Nenhuma linha tem valor: o padrão 0 não muda conta nenhuma.

**A decisão do dono, em 30/09/2026, respondendo sobre a produção do Profissional Parceiro:**
"a venda do pacote não deve gerar cota-parte automática, na configuração do pacote, que seja
definido qual % deve gerar de cota-parte. Ou seja, conta como volume de venda mas depende de
configuração adicional para gerar a cota-parte. A cota-parte deve ser gerada sempre na execução
do serviço."

**O que o sistema faz hoje, lido e medido na cópia:**

- `VendaProdutoService` (:184-188) só grava `comissao` para serviço (`ser`, `lavavip`) e
  produto (`probar`, `proemp`). A venda de pacote (`pac`) fica com a comissão que o item nasce
  (zero, :63). De janeiro a julho de 2026: 585 vendas de pacote, R$ 119.204,25, **comissão
  zero em todas**.
- A sessão de pacote gera a comissão na execução, sobre o valor da sessão
  (`$valorSessaoPacote`): 2.489 sessões no mesmo período, R$ 44.975,18 de comissão em 2.480.
- Então duas das três partes da decisão **já valem**: a venda não gera cota-parte automática, e
  a cota-parte nasce na execução. **O que falta é a terceira**: não existe onde configurar o
  percentual da venda do pacote -- nem `produtos` nem as tabelas de pacote (`pacotes_itens`,
  `clientes_pacotes`) têm coluna de comissão.

**O que o conserto pede, quando for feito:** o percentual no cadastro do pacote (tela nova de
pacotes, que já existe), gravado na comissão do item da venda do pacote pelo mesmo
`VendaProdutoService`, com zero como padrão para nada mudar sem configuração. E a Cota-parte
(relatório) precisa mostrar as duas partes separadas -- a da venda, quando configurada, e a da
execução. Fica para a vez da Cota-parte na ordem dos relatórios, ou antes, se o dono pedir.

*Correção de registro (11/10/2026, o item RESPOSTAS-SEM-RODADA):* o parágrafo acima descreve o
percentual **gravado na comissão do item da venda do pacote**, e não foi essa a regra decidida. Pela
decisão de 30/09 ("a cota-parte deve ser gerada sempre na execução do serviço") e pela decisão 16 dos
relatórios (01/10/2026, `docs/f3-relatorios.md`), a cota do pacote é **"de quem vendeu, por sessão
executada, e só pelo que foi usado"**: o percentual fica no cadastro do pacote, com zero como padrão, e
**em cada sessão executada** de um pacote com percentual o PDV grava no item da sessão quem vendeu (o
colaborador do item `pac` da venda que criou o `clientes_pacotes`) e a cota dele -- o valor da sessão vezes
o percentual, na hora. A venda do pacote continua sem cota-parte, e a cota de quem executou continua como
está. O desenho está em `docs/f3-relatorios/spec-cota-parte.md`, seção 3, e a Cota-parte ganha o bloco de
quem vendeu. **O que segura o conserto não é a regra, é o esquema:** as colunas novas, nas duas cópias
locais, esperam a pergunta 83 da página do lote (`esquema-em-bloco`), sem resposta em 11/10/2026; na
produção, o passo 0 do corte. (A nota "ainda não se configura" que a especificação dá à tela da
Cota-parte até lá não está no código em 11/10/2026: a Cota-parte da especificação não foi construída.)

*Atualizado em 12/10/2026 (o item ESQUEMA-NAS-COPIAS):* a 83 veio, a opção (a) -- tabela, coluna e índice
que não apagam linha entram nas duas cópias locais quando a resposta os pede, cada um com a sua migração no
passo 0. O esquema deixou de segurar este conserto: as colunas da cota de quem vendeu vão às cópias na rodada
da fatia do PDV (as decisões 2, 9, 15 e 16 dos relatórios, `docs/f3-relatorios/spec-ordem.md`, 4.4), que
ainda não foi escrita. Nada foi aplicado por ela.

*Conferido em 02/11/2026 (a fatia 68, REL-COTA-PARTE-ARQUIVO; a CP5 do `spec-cota-parte.md` e o item 6 da seção 2 do
`spec-ordem.md`):* a correção de 11/10/2026 acima já traz a resposta 16 do dono -- o percentual é de quem vendeu, por sessão
executada --, e o parágrafo "O que o conserto pede", que descrevia a opção (a), vale só como histórico. A Cota-parte nova (a API, a
tela e, desde a 68, o PDF e a planilha) mostra a venda do pacote com cota zero e a nota "ainda não gravado" (`AindaNaoGravado` da
`cotaDoPacote`, também nas notas do arquivo), até a fatia do PDV (a 74, PDV-COTA-DO-PACOTE) gravar a cota de quem vendeu.
REGISTRADO.

### D311 — Os clientes da rede no Profissional Parceiro levam cerca de 70 s no MySQL, e nada limita pedidos iguais em paralelo — MEDIDO em 01/10/2026 no PostgreSQL: 25 a 49 s fria e 7 a 8 s quente na rede; manter, frear ou gravar a estreia é decisão do dono — PARCIAL em 13/10/2026: um pedido da rede de cada vez por pessoa, e a tela espera o primeiro (a resposta 118); a trava do servidor não rodou em banco nenhum — CORRIGIDO em 13/10/2026: a trava rodou nos dois bancos, e os mutantes dela ficaram vermelhos

**Achado na revisão da F2 do Profissional Parceiro** (`docs/f3-relatorios.md`, seção 5), e medido
na cópia antes dela.

- **O custo.** `GET /adm/api/profissionais/clientes?unidade=todas` -- só o administrador alcança a
  rede -- levou cerca de 70 s e 122 MB de pico no PHP em julho de 2026, no MySQL com o buffer pool
  de fábrica (128 MB) e o cache frio: 64 s são a pergunta "teve serviço ou crédito antes?" dos
  14.489 candidatos a cliente novo (`VendaRepository::painelPrimeiroServicoOuCredito`, a mesma do
  painel), e a janela de 90 dias alarga a leitura das visitas para uns 120 dias. Cabe nos 300 s e
  nos 512 MB do Docker local; os limites de produção nunca foram lidos. O melhor da rede
  (`/destaques`) lê os 31.898 itens da rede num mês, cerca de 1,7 s, para qualquer escopo.
- **Nada limita pedidos iguais.** Todas as ações soltam a trava da sessão depois das guardas (de
  propósito: a lista não espera os clientes). Com isso, quatro trocas de janela seguidas na visão
  da rede são quatro pedidos de 70 s ao mesmo tempo. A tela descarta as respostas velhas, mas o
  pedido descartado continua rodando no servidor: o PHP só percebe a conexão fechada quando
  escreve, e o `pedir` da tela não cancela o `fetch`. O mesmo vale para `/destaques` com meses
  diferentes em paralelo, por quem tem a marca.

**Por que não foi consertado na F2:** o destino do corte é o PostgreSQL, onde a mesma pergunta
levava de 3 a 6 s na medição do desenho; e as saídas conhecidas mudam a regra ou a arquitetura --
guardar a estreia de cada cliente quando a venda é finalizada (a alternativa da seção 4 de
`docs/f3-relatorios.md`, que também serve ao painel), ou um freio por pessoa no servidor (um
pedido da rede por vez, e o segundo recusado com 429). **Medir no PostgreSQL antes do corte**, pelo
mesmo caminho, e decidir com o número.

#### MEDIDO no PostgreSQL em 01/10/2026

Na cópia convertida (`franquiabv_dev` do PostgreSQL, com o papel `bvip_app` e o RLS, como a
aplicação roda; `shared_buffers` de 128 MB, `work_mem` de 4 MB, `random_page_cost` 4 e JIT ligado --
os padrões de fábrica), pelo caminho do controller, julho de 2026, num roteiro de rascunho que
cronometra cada parte. A cópia tem as 20.767 vendas de julho e o índice do D308; **não tem o do
D189** (`agendas` ainda está em `(inquilino_id, colaborador)`, atrás do `postgres/schema.sql`), que
este caminho não lê.

- **Os números são os mesmos nos dois bancos, resposta inteira:** o sha1 do JSON bateu nos seis
  pedidos (a rede e a unidade 1 em 45 e 90 dias, o melhor da rede, o histórico do 200). Rede: 41,9%
  em 15.788 pares e 1.403 novos; unidade 1: 44,8% em 2.449 pares e 57 novos.
- **"Frio"** foi o `shared_buffers` QUASE esvaziado (a leitura de troca leu só as chaves de `agendas`
  e de `pontuacoes_historicos`, ~112 MB contra 128 MB -- o verificador conferiu pelo plano), o
  cache da VM solto para os arquivos do banco e um processo PHP novo; **"quente"**, a repetição em
  seguida. Na rede, o estado do `shared_buffers` quase não pesa: o frio leu 139.854 blocos de fora
  dele, e o quente 136.109.
- **A rede em 45 dias levou 25 a 49 s fria (duas rodadas) e 7 a 8 s quente**; em 90 dias, 33 s e
  8 s. A pergunta "teve serviço ou crédito antes?" é de novo quase tudo no frio: 20 a 38 s nos 15
  lotes de mil, e 1,7 a 2,4 s no quente. **Os "3 a 6 s" do desenho não se confirmam.** A unidade 1:
  0,9 s quente, 7 a 17 s fria.
- **O MySQL, na mesma hora, levou 47,5 s e 43 s** (os cerca de 70 s de 30/09 foram medidos em outra
  hora; a causa da diferença não foi medida). Com 128 MB e `O_DIRECT`, ele não tem quente: a segunda
  rodada leu 88 mil páginas do disco de novo.
- **Cada pedido da rede faz cerca de 1,1 GB de leituras** (leituras, e não páginas diferentes):
  duas varreduras inteiras de `vendas_produtos` (356 MB cada, uma nos itens e outra nas visitas) e as
  leituras salteadas da estreia. **Duas das três partes vêm de uma configuração:** com
  `random_page_cost` em 4 (o de disco giratório), o planejador varre `vendas_produtos`; com 1,1, a
  consulta dos itens anda pelo índice da venda -- 10 blocos e 195 ms contra 43.751 blocos e 1,8 s
  (conferido pelo verificador). E subir só o `shared_buffers` não guarda essa tabela na memória: a
  varredura de tabela maior que 1/4 dele usa um anel de 256 KB.
- **O JIT** custa 0,44 s no primeiro lote do `MIN` da estreia (que executa em 16 ms); o segundo lote
  fica abaixo do limite e não compila. Cerca de 0,7 s de cada pedido da rede é compilação (0,44 da
  estreia, 0,16 dos itens, 0,09 das visitas).
- **O `work_mem` não pesa:** nenhum arquivo temporário em rodada nenhuma.
- **Memória:** pico de 122 a 140 MB no PHP e de 157 a 179 MB no processo, igual nos dois bancos.
- **O melhor da rede (`/destaques`) é mais lento no PostgreSQL** -- 3,3 s quente e 5,4 s frio, contra
  1,8 s no MySQL --, pela mesma varredura de `vendas_produtos`. **E ele é o pedido da rede mais
  frequente:** é sempre a rede, para qualquer escopo, e a tela o pede a cada lista carregada -- todo
  gerente e todo colaborador com a marca o dispara.
- **O histórico/clientes do 200 na unidade 1:** 1,8 s quente e 7,4 s frio, contra 13,7 s no MySQL.

**Para o corte, fora deste débito:** a memória do PostgreSQL de produção (`shared_buffers` e
`effective_cache_size`), o `random_page_cost` para SSD e o JIT.

**O que fica com o dono** -- a pergunta foi a ele em 01/10/2026, com o custo de cada opção:
(1) manter até o corte; (2) um freio por pessoa no servidor (um pedido da rede de cada vez, o segundo
recusado com 429 -- a trava de arquivo vale para uma máquina só, D44); (3) uma fila na tela (só um
pedido de clientes da rede no ar por aba, o molde do histórico da F3 -- não protege de duas abas nem
da API chamada direto); (4) a estreia gravada na finalização da venda (vários dias; tira 20 a 38 s do
frio no PostgreSQL; o risco é o número gravado envelhecer, o D307). Em qualquer delas, dizer se vale
também para o `/destaques`. E ler (só ler) a memória do servidor de produção, que decide o frio
contra o quente.

#### O conserto (13/10/2026, a resposta 118 do dono)

**A escolha** (a 118, a opção (a)): *"Um pedido da rede de cada vez, por pessoa -- O segundo é recusado com 'já há uma
consulta da rede em andamento', também no melhor da rede; quem troca de período antes da resposta espera a primeira
terminar."*

- **No servidor:** os clientes da rede (`GET /adm/api/profissionais/clientes?unidade=todas`) e o melhor da rede
  (`/destaques`) tomam, depois das guardas e de soltar a sessão, uma trava da pessoa para aquela consulta
  (`ProfissionalParceiroController::travaDaRede`, a `TravaDeExecucao` do D44, com o id da sessão no nome). O segundo pedido
  da mesma pessoa com o primeiro ainda no servidor volta na hora com **`429 em_andamento`** e a frase *"Já há uma consulta
  da rede em andamento. Espere ela terminar e tente de novo."*; a trava solta no `finally`, e o processo que morre a solta
  sozinho. Os clientes de UMA unidade não travam (custam segundos), e as duas consultas não esperam uma pela outra. O 429
  é código novo da convenção (`RespostaApi::erroEmAndamento`; a regra 2 de `docs/convencao-api.md`).
- **Na tela:** `ProfissionalParceiroLigado` põe os clientes da rede e o melhor da rede, cada um, numa fila: com a fila
  livre o pedido sai na hora, na mesma ordem de antes; com um no ar, o seguinte espera ele terminar, e só sai se a tela
  ainda o quer -- trocar de mês duas vezes com o primeiro no ar manda um só, o último. O 429 que vier de outra aba aparece
  com a frase e o "tentar de novo".
- **O que continua, por escolha:** o tempo da consulta (no PostgreSQL, 25 a 49 s com o disco frio e 7 a 8 s quente; no
  MySQL, 43 a 70 s) -- o dono escolheu o freio, e não a estreia gravada. **E por limite:** a trava é de uma máquina só (o
  D44) -- com dois nós, a mesma pessoa pode ter um pedido em cada, até o `pg_advisory_lock` do corte; e a fila é da
  aba -- outra aba, ou a API chamada direto, leva o 429.

**A fila passou a ser da aba (a revisão de 13/10/2026).** Ela morava numa referência de cada instância da tela, e o
Console remonta a tela a cada escolha no menu: escolher "Profissional Parceiro" de novo, ou ir ao Painel e voltar, com o
melhor da rede no ar, criava uma fila vazia, o segundo saía na hora, e o servidor o recusava com o 429 -- o cartão
mostrava "Já há uma consulta da rede em andamento" em vez de esperar. A fila mora agora no módulo
(`frontend/src/telas/filaDaRede.ts`): a tela que sai deixa de querer o que ainda não saiu, e o pedido que já está no
servidor segura a fila até voltar. O movimento por dia da semana do painel (o D306) anda numa fila dela também.

**Provado na tela:** `ProfissionalParceiroLigado.teste.tsx` com 63 casos -- os clientes da rede de maio esperam os de
junho e os de uma unidade não; trocar de mês duas vezes com o melhor da rede no ar manda só o último; o 429 com a frase e
o "tentar de novo"; e, da revisão, sair e voltar com o melhor da rede no ar espera o primeiro --, e cinco mutantes da
fila, um por vez, vermelhos: os quatro do item (a fila do melhor da rede, a dos clientes, o "ainda quer", a unidade na
fila) e o da revisão (a fila de volta a cada instância, que só o caso de sair e voltar pega). **Na revisão, não provado
no servidor -- e era o que deixava este débito PARCIAL:** o caso da trava (`ProfissionalParceiroTest::
testOsPedidosDaRedeSaoUmDeCadaVezPorPessoa` -- o 429 da mesma pessoa, o 200 de outro administrador, as guardas antes da
trava, a trava solta ao terminar, os clientes da unidade sem trava) foi escrito e não rodou: o Docker da máquina travou
neste dia, e continuava sem subir na revisão (a entrada do roadmap de 13/10/2026, PARCEIRO-RADAR-E-FLUXO). A trava é
código só do servidor; ia a CORRIGIDO quando o caso e os mutantes do controller (o 429 arrancado, a trava sem o id da
pessoa, a trava também na unidade, o melhor da rede sem trava, a trava antes das guardas) rodassem nos dois bancos --
e rodaram, logo abaixo.

**Provado no servidor (13/10/2026, depois do reinício da máquina).** O PHPUnit de verdade (o 10.5, no PHP 8.1 do
contêiner), nos bancos só de fixtures que o CI monta (`scripts/rodar-caracterizacao-como-ci.sh` e
`scripts/rodar-caracterizacao-postgres.sh`), filtrado nos arquivos do item e nos vizinhos (26 classes, 651 casos):
**MySQL, 651 de 651 com 10.453 asserções; PostgreSQL, 651 de 651 com 10.452** -- nos dois, 5 pulados com o motivo
escrito, nenhum deste item. O `testOsPedidosDaRedeSaoUmDeCadaVezPorPessoa` verde nos dois, com o `ProfissionalParceiroTest`
inteiro (71) e o `RegrasDoProfissionalParceiroTest` no PHPUnit de verdade (42). A primeira rodada no MySQL teve 6
vermelhos, todos do `SegredosForaDoCodigoTest`, e nenhum da trava: o piso das rotinas do cron (26, e 22 sem as quatro
que cobram ou enviam) não acompanhou as duas rotinas que saíram com o D306 -- são 24 e 20, e o piso desceu, com a data
no comentário. **Os sete mutantes da trava, um por vez, no MySQL, com o arquivo devolvido e conferido por sha1 depois
de cada um, todos vermelhos no caso da trava:** o 429 arrancado nos clientes da rede e, à parte, no melhor da rede (200
no lugar do 429); a trava sem o id da pessoa (200 no lugar do 429); a trava também nos clientes de uma unidade (429 no
lugar do 200: "os clientes da unidade nao esperam a rede"); o melhor da rede sem trava (200 no lugar do 429); e a trava
antes das guardas, no melhor da rede e, à parte, nos clientes da rede (429 no lugar do 422: "o mes errado e recusado
antes da trava"). **Não rodou:** o 429 pelo HTTP, e a tela no navegador. E o limite segue o mesmo: a trava é de uma
máquina só (o D44).

### D312 — Os pontos da indicação vão para a coluna da unidade de quem indicou, e a linha do histórico para a unidade da venda — REGISTRADO em 01/10/2026 — PARCIAL em 15/10/2026: a 92 (b) no código; os de antes, no lote — PREPARADO em 07/11/2026: a 230 (a) no passo do corte, antes do recálculo

**Achado pelo verificador do D284.** `VendaPagamentoService::salvarPontuacao` (perto das linhas 609 a
616) soma os 10 pontos da indicação na COLUNA de pontuação da unidade de quem indicou, e grava a
LINHA de `pontuacoes_historicos` na unidade da venda. Quando as duas unidades diferem, nenhum
`atualizarPontuacao` fecha a conta: na unidade de quem indicou, a coluna perde os 10 que nenhuma
linha explica; na da venda, a linha existe sem a coluna.

**Medido na cópia PostgreSQL:** 19 das 79 linhas de indicação estão numa unidade diferente da de
quem indicou; desses 19, só 6 têm linha de saldo na unidade da venda.

**Por que não foi consertado:** de qual unidade são os pontos da indicação -- da de quem indicou ou
da venda -- é regra do programa de fidelidade, e é do dono. O D284 passou a recalcular quem indicou
no cancelamento, e isso torna o efeito visível mais cedo; qualquer outro `atualizarPontuacao` já o
fazia.

#### PARCIAL em 15/10/2026 -- a resposta 92 do dono, opção (b), no código (a fatia 5, RESPOSTAS-DO-PROGRAMA)

**A decisão** (a pergunta 92, "de qual unidade são"): *"Da unidade da venda — o saldo vai para lá, junto com o registro;
quem indicou pode ganhar pontos numa unidade onde nunca vai."*

**O conserto**, em `VendaPagamentoService::salvarPontuacao`: a linha de saldo de quem indicou é a da unidade da venda
(`$venda->getUnidade()`, a do caixa de quem finaliza — a mesma da comissão da primeira compra,
`gerenciarComissaoIndicacaoPrimeiraVenda`), e nasce lá quando não existe; a linha do histórico já ia para lá. A coluna e a
linha passam a ser da mesma unidade, e o recálculo (`ClienteService::atualizarPontuacao`, que soma as linhas `ativa` de
cada unidade na coluna dela) não tira mais nada. O cancelamento (`VendaService::cancelar`, o D284) tira a linha e
recalcula a mesma unidade.

**Os caminhos, derivados do código** (o `grep` de `salvarPontuacao`, de `new PontuacaoHistorico` e de `->finalizar(`): o
único lugar que dá os 10 pontos por compra é o `salvarPontuacao`, que só o `finalizar` chama, e o `finalizar` tem duas
portas — o PDV antigo (`VendasPagamentosController::finalizarAction`) e a comanda nova
(`Adm\ComandasController::finalizarAction`, `POST /adm/api/comandas/:id/finalizacao`). Nenhuma rotina dá esses pontos: a
da validade (`CronJobService::vencimentoPontuacaoProgramaFidelidade`) só expira e recalcula. Os 10 pontos da etapa 6
(`FidelidadeService::salvarPontuacaoCadastro`) são outro prêmio, o do envio, e ficam na unidade do cadastro (a resposta 63).

**Os de antes, que o código novo não refaz** (`franquiabv_dev`, só contagens, 15/10/2026; a cópia vai até 16/08/2026):
dos 79 lançamentos, 19 estão em unidade diferente da de quem indicou, e 13 deles sem linha de saldo na unidade da venda —
mas só **1** dos 19 ainda está `ativa` (no todo, 58 estão `inativa`, 19 `resgatada` e 1 `expirada`), de 31/07/2026, com
10 pontos. E é dele **a única coluna fora de conta** entre esses indicadores: na unidade do cadastro, a coluna marca os 10
que o código antigo somou ali, e as linhas `ativa` de lá somam 0. O primeiro `atualizarPontuacao` dessa pessoa — uma
compra dela, a escolha dela no PDV, um cancelamento, a rotina da validade, ou o recálculo de todos no corte (a resposta 123
(a)) — zera essa coluna; e a linha da unidade da venda só conta quando a pessoa ganhar a linha de saldo lá, numa compra do
mesmo amigo lá ou numa dela. Se isso nunca acontecer, os 10 somem: é o caso que a pergunta 92 descrevia, e a linha fica lá,
mas o saldo não. Na produção, o código antigo segue gravando assim até o código novo subir, e a cópia não mostra os de
depois de 16/08/2026 (pelo passado, cerca de um por ano desde 2021: 3 em 2018, 9 em 2019, 4 em 2020, 1 em 2021, 1 em 2025
e 1 em 2026).

**O que fica — e foi ao lote** (TELAS-RESTANTES [corte], 15/10/2026, a verificação da fatia 5): refazer o passado — criar a
linha de saldo, zerada, na unidade de cada lançamento `ativa` que não a tem, antes de recalcular — é escrita na produção,
e não está no texto da (b). As saídas: junto com o recálculo de todos da 123 (a), no corte, ou como está. Até a resposta,
nada se escreve; e o passo do recálculo da 123 (a), quando for escrito, lê a resposta antes.

**Provado** em `D312PontosDaIndicacaoTest` (9 casos, nos dois bancos): quem indicou sem saldo na unidade da venda ganha a
linha lá, com os 10, e a dele fica nos 50 — e o recálculo não muda nada; com saldo nas duas, os 10 somam na da venda; na
mesma unidade, como antes; pelo PDV antigo e pela comanda nova, a mesma conta; e o cancelamento tira os 10 da unidade da
venda. Os mutantes: a linha de saldo procurada na unidade de quem indicou deixa seis casos vermelhos; o histórico na
unidade de quem indicou, cinco.

#### PREPARADO em 07/11/2026 -- a resposta 230 (a) no passo do corte, antes do recálculo da 123 (a)

**A decisão** (a pergunta 230, 15/10/2026): *"Acertar no corte, junto com o recálculo da 123"* -- "antes de recalcular todos, o
passo cria o saldo zerado onde falta, e o recálculo põe os pontos lá; ninguém perde ponto, e eles ficam onde a sua 92 (b) diz. Só
acrescenta linhas, no mesmo passo que você já pediu, contadas antes e depois." O passo é o mesmo do **D284**, e a 230 (a) é a
primeira parte dele.

**O roteiro** (a fatia 106, RECALCULO-DA-PONTUACAO-NO-CORTE): `scripts/recalcular-pontuacao-no-corte.sql` e o irmão
`postgres/recalcular-pontuacao-no-corte.sql`. Na ordem das duas respostas: **primeiro a 230 (a)** -- a linha de saldo zerada
(cliente, unidade; no PostgreSQL também o inquilino) de cada par com pontos `ativa` e sem linha de saldo, como o código novo a cria
(`valor`, `valor_pf` e `pontuacao` zero, `pontuacao_validade` no dia), para todo tipo de ponto (a letra: "cada registro de pontos
ainda válido que não tem saldo lá") --; **depois a 123 (a)** -- a soma das linhas `ativa` do mesmo (cliente, unidade), ou zero, em
toda linha de saldo que difere dela (a pontuação nula também), a conta de `ClienteService::atualizarPontuacao`, também nas duas
linhas do par gêmeo (o D72). **Conta antes** (as linhas de saldo; os pares sem saldo e os pontos deles; das linhas, as que diferem,
acima, abaixo, nulas, e o líquido), **lista** os pares sem saldo, as linhas criadas (o id) e cada linha que muda com o número de
antes (o caminho de volta, guardado pelo `tee`), e **confere** no fim com a mesma contagem de antes (as linhas de saldo, as de
antes mais as criadas; todo o resto zero). Só a coluna `pontuacao` e as linhas novas: `valor` e
`valor_pf` não são tocados -- o crédito dos pontos de venda cancelada que já viraram dinheiro fica com o cliente (a 123 (a), pela
lógica da 68) --, e o histórico só é lido. Idempotente. O do PostgreSQL começa por `ANALYZE` das duas tabelas: num clone recém-
carregado, sem as estatísticas, a contagem levou 61 s (o planejador estimou 2.460 linhas `ativa`, e são 225.209); com elas, o
roteiro inteiro leva cerca de 1,3 s.

**Onde roda:** no passo 7 da etapa 1 (`docs/procedimento-de-corte-etapa-1.md`), depois da troca do código e logo depois do consumo
dos clientes, pelo caminho da árvore no lugar e com o `PIPESTATUS` do `mysql` (o mesmo molde do `refazer-consumo-dos-clientes.sql`,
da 295 (a), e do `encerrar-pacotes-de-venda-cancelada.sql`, da 287 (a)); e o irmão no 7d de `docs/procedimento-de-corte.md`, que
com a etapa 1 feita só acerta o que o intervalo deixou. Nunca no laço do passo 0 (muda dado). O item 18 de "O que falta fazer antes
da janela" e as linhas 123 e 230 da tabela das decisões, na etapa 1.

**Medido na cópia, só SELECT** (07/11/2026): 1 par sem saldo, de 10 pontos -- o lançamento de indicação deste débito --; das 256.987
linhas de saldo, 28 diferem no PostgreSQL da cópia (22 acima, 6 abaixo, +5.092) e 7.793 no MySQL de desenvolvimento (7.789 acima, 4
abaixo, +790.297: quase todas linhas expiradas no próprio ambiente, como este débito já dizia); 3 pares gêmeos; nenhuma pontuação
nula. **Num clone descartável das duas tabelas da cópia em cada banco** (`descartavel_f106`, apagado no fim), o roteiro de verdade
pelo cliente de cada banco, duas vezes: no MySQL, cerca de 3 s, 1 linha criada (o indicador, na unidade da compra) e 7.794
recalculadas, a conferência 0 e 0; no PostgreSQL, 1 criada e 29 recalculadas; a segunda rodada, nos dois, contou zero e não mudou
nada. O indicador real: a coluna da unidade do cadastro foi de 10 a 0, e a linha nova da unidade da compra, de 0 a 10.

**Provado** em `RecalculoDaPontuacaoNoCorteTest` (5 casos, nos dois bancos; 6 depois da verificação), o roteiro de cada banco comando por comando dentro da
transação do caso: o indicador de outra unidade (ganha o saldo na unidade da compra, com os 10; o total dele fica 10), o cancelamento
antigo (1.210 a 400), o abaixo, os pontos resgatados (300 a 200, e o crédito de R$ 90,00 e o `valor_pf` ficam), o par gêmeo (as duas
linhas a 60, e nenhuma terceira), a pontuação nula, o que já bate e o que só tem linha expirada (nada nasce); a contagem de antes, as
listas, a conferência; o `atualizarPontuacao` que, depois do roteiro, não muda nenhuma linha; a segunda rodada com zero; os dois
roteiros só com o `INSERT` zerado e o `UPDATE` da pontuação; e o lugar no passo 7 e no 7d, fora do passo 0. O
`PermissoesComONomeDaTelaTest` passou a exigi-lo entre os roteiros do passo 7. Mutantes, um por vez, devolvidos pelo sha1: sem o
saldo zerado (S1 no MySQL, P1 no PostgreSQL) e o saldo criado também no par que já tem linha (S2): vermelhos; a soma com todo status
(R1, P2), a pontuação nula deixada (R2), o `valor` zerado junto (R3) e sem o recálculo (R4): vermelhos; a linha do 7d e a do passo 7
tiradas (L1, L2): vermelhas. O par sem o inquilino no `NOT EXISTS` do PostgreSQL (P3) ficou verde: equivalente com um inquilino só,
que é o que a suíte e o corte têm.

**A verificação** (07/11/2026, três lentes) confirmou quatro achados, consertados: **a conferência do fim** trazia só os pares sem
saldo e os divergentes, e o bloco pede a contagem antes e depois -- agora ela repete a contagem de antes, coluna por coluna, com
`_depois` no nome (as linhas de saldo, os pares sem saldo e os pontos deles, os divergentes, acima, abaixo, nulos e o líquido): se
der diferente de zero (PARE), quem opera a janela lê ali quantas ficaram acima e abaixo e o líquido. **O `GROUP BY` do `INSERT`
não tinha caso:** sem ele, um par com duas linhas `ativa` e sem saldo ganhava duas linhas de saldo (o par gêmeo do D72 criado pelo
próprio roteiro), e os 5 casos ficavam verdes porque o indicador do cenário tinha uma linha só; agora ele indicou dois amigos que
compraram na mesma outra unidade (20 na coluna do cadastro, duas linhas de 10 na unidade da compra) e ganha uma linha de saldo só,
com os 20. **A chave do inquilino no PostgreSQL** (o `NOT EXISTS`, o `GROUP BY` e o `JOIN` da soma) não tinha caso: o
`testNoPostgresOParLevaOInquilinoEmTodaConsulta` lê o roteiro sem os comentários e exige o inquilino em cada um, tantos quantos o
irmão do MySQL tem sem ele. O cenário do achado (um segundo inquilino com os mesmos ids de cliente e de unidade) não existe no
esquema: `clientes.id` e `unidades.id` são chave primária sozinhos, e com o dado coerente a chave não muda o resultado, com um
inquilino ou mais; ela guarda a linha incoerente (o histórico com um inquilino e o cliente com outro), e o cabeçalho do roteiro
diz isso. **O roteiro inteiro pelo cliente do banco, no banco do contrato:** num banco descartável montado pela receita do
contrato em cada banco (`descartavel_f106_contrato` pelo `scripts/montar-banco-contrato.sh`; `descartavel_f106_pgcontrato` pela do
`rodar-contrato-postgres.sh`, o dono superusuário, como no 7d), com o indicador de outra unidade (duas linhas) e os pontos
resgatados da 68 por cima das fixtures, o arquivo como está, com a transação, o `SET` e o `ON_ERROR_STOP` dele, duas vezes: nos
dois, `mysql` e `psql` saíram 0; a primeira contou 5 linhas de saldo, 1 par sem saldo de 20 pontos, 5 divergentes (+1.460), criou
1 linha e recalculou 6, e a conferência deu 6 linhas e o resto zero; a segunda contou zero, criou nada, recalculou 0. O indicador
ficou com 20 na unidade da compra e 0 na do cadastro; o resgatou, 200 pontos e os R$ 90,00 e R$ 12,50 de crédito. Os dois bancos
apagados no fim; o `franquiabv_contrato` e o `franquiabv_pgcontrato` não foram tocados. O ensaio fica em
`estado-sessao-d5c2211c/scratchpad/ensaio-106/` (fora do repositório; o CI não o roda). Mutantes novos, um por vez, devolvidos
pelo sha1: sem o `GROUP BY` do `INSERT` (G1 no MySQL, G2 no PostgreSQL), a conferência de antes, só com pares e divergentes (C1,
C2), e o inquilino tirado do `NOT EXISTS` do passo 3 (I1, o P3 de antes), do `JOIN` do recálculo (I2) e do da contagem (I3):
vermelhos.

**Não provado:** o roteiro na produção (nada roda lá antes da janela) e na janela (é parte do ensaio da etapa 1, o item 11); a
linha incoerente entre inquilinos por comportamento (a suíte entra como quem obedece ao RLS e só vê um inquilino; a chave é
guardada pela leitura do roteiro).

**O que fica:** rodá-lo na janela da etapa 1 (do dono). Os lançamentos que o código antigo gravar na produção até a troca entram
na mesma conta, porque o passo roda depois dela.

### D313 — O PDV antigo tira item e pagamento de venda já concluída, dentro da mesma unidade — CORRIGIDO em 01/10/2026

**Achado pela frente do D237, em 01/10/2026.** `VendasProdutosController::removerAction` e
`VendasPagamentosController::removerAction` passaram a conferir a unidade (o D237), mas não a
situação da venda: um POST escrito à mão tira o item ou o pagamento de uma venda concluída (status 1)
da própria unidade. A tela só oferece a remoção na venda em andamento (status nulo) e na comanda
(status 3). É o D163, que fechou isso só na API nova.

**O conserto é o do D163**, sem decisão nova: recusar a remoção quando a venda estiver concluída ou
cancelada, com um caso que prove a recusa. Fica para a próxima rodada de débitos.

#### CORRIGIDO em 01/10/2026

As duas ações passam a ler a situação da venda depois da unidade, e só tiram item ou pagamento da
venda em andamento (`status` nulo, a do `/vendas/novo`) e da comanda aberta (3, a do
`/vendas/comanda`). São os dois lugares onde a tela desenha o botão, e os únicos de onde o JavaScript
do PDV (`pdvCarrinhoRemoverProduto`, `pdvRemoverPagamento`) chama as rotas; nenhum estorno, reabertura
ou correção de venda fechada passa por elas (o cancelamento é outra ação). A recusa tem a forma que o
JavaScript já entende -- `sucesso: false` e a frase, mostrada como aviso: *"Só é possível remover
itens de uma venda em andamento ou de uma comanda aberta."*, e a do pagamento. A venda de outra
franquia continua com a recusa do D237, que não conta em que estado ela está.

**A regra mora num lugar só**, `ISVenda\Regra\RegraDaVendaNoPdv::editavel`: é a do D163 (`status !==
3`, escrita em linha nas sete escritas da comanda da API nova, sem ajudante para reusar), acrescida do
nulo, que só o PDV antigo tem. O agente a pôs como método estático do `VendasProdutosController`,
chamado pelo controller do pagamento; a integração a moveu para `ISVenda\Regra`, porque as irmãs do
D316 vão chamar a mesma.

**Medido na cópia, só com SELECT:** 2.206.728 vendas concluídas, 62.509 canceladas, 16 em andamento
(de 2019 a 2026) e 1 comanda aberta. Nenhum outro valor de `status`: um estado novo nasce recusado.

**Provado** em `D313RemocaoEmVendaConcluidaTest` (11 casos, nos dois bancos): a concluída e a
cancelada recusam com o banco intacto; a em andamento e a comanda continuam removendo; a concluída de
outra franquia recebe a recusa de "não encontrado"; e a regra, estado por estado, sem banco. Arrancar
cada guarda deixa vermelhos os dois casos dela; a regra devolvendo `true`, cinco (as quatro recusas e
a tabela); a regra sem o nulo, os dois controles da venda em andamento. A regra e as duas chamadas
entraram em `conferir-recorte-por-unidade.php` -- o portão que lista as guardas dessas ações --,
vermelho com cada guarda arrancada e com a regra devolvendo `true`. Um verificador independente
confirmou, e mediu dois mutantes que o portão textual não vê (a guarda depois do `remover()`, a
guarda sem o `return`): o teste pega os dois.

**O que ficou:** as outras escritas do PDV antigo também não leem a situação -- o D316, corrigido no
mesmo dia.

### D314 — O cancelamento de pacote do ERP antigo aceita quem não é gerente, e por GET — CORRIGIDO em 01/10/2026

**Achado pela frente do D237, em 01/10/2026.** `ClientesController::pacoteRemoverAction` passou a
conferir a unidade do pacote (o D237), mas continua aceitando qualquer um com o cadastro de clientes
-- o X da tela só aparece ao gerente, e o D281 pôs a guarda do gerente só na API nova. E remove por
GET (o D199). O conserto é o da API nova (D281) e o do D199, sem decisão nova.

#### CORRIGIDO em 01/10/2026

A regra do D281 passou a morar num lugar só, `RegraDaFidelidade::cancelaPacote($gerente,
$administrador)` -- gerente ou administrador, pelo papel da sessão --, e as três perguntas a chamam: a
ação da API nova (`cancelarPacoteAction`, que tinha a condição escrita à mão, e cuja forma nova é
equivalente à antiga), a do ERP antigo (`ClientesController::pacoteRemoverAction`) e o X da aba
(`pacotesAction` passa `cancelaPacote` à `clientes/pacotes.phtml`, que lia `usuarioGerente`). A ação do
ERP começa recusando, com o 404 das outras recusas e antes de ler o pacote, o GET e quem não é
gerente nem administrador; a conferência da unidade do D237 continua depois. A tela manda o formulário
de `remover-por-post.js`, como as três de dinheiro e estoque do D199, e a pergunta "Deseja realmente
remover?" continua.

**A tela e a API discordavam só no administrador, e o dado não as separa:** medido nas duas cópias, o
único grupo com `administrador = 1` é o 4, que também tem `gerente = 1`; nenhum grupo tem
administrador sem gerente, e nenhum código da aplicação grava essas colunas (`GruposController::novo`
e `editar` são 404; só dois ensaios criam um grupo administrador sem gerente, e só no banco de
ensaio). O X aparece aos mesmos grupos 3, 4 e 7; quem perdeu foi quem digitava a URL sem ser gerente.

**Provado:** `D314PacoteSoDoGerentePorPostTest` (9 casos, nos dois bancos) -- o não gerente por POST e
o gerente por GET recebem 404 com o pacote ativo; o gerente e o administrador por POST desativam (o
controle); a API recusa o não gerente com 403 e cancela para o gerente; a página do gerente tem
`modalRemoverPorPost` para `pacoteRemover` e o `<form method="post">`, sem o `modal_remover` de antes; a
de quem não é gerente não tem o X. Sete mutantes, todos mortos: a ação sem a regra, aceitando GET, a
de antes, a regra `return true` (quatro vermelhos), a API sem a guarda, o X por link e o "Sim" por
link. O `D237DinheiroTest` passou a desativar por POST, como gerente: por GET, os casos da outra
franquia ficariam verdes pela recusa do GET, e não pela unidade. **No navegador**, contra o banco de
contrato e como o gerente de fixture: a aba mostra o X, o X abre a pergunta, o "Sim" vai por POST a
`/mod-cadastros/clientes/pacoteRemover/90001000`, a resposta é 302, o pacote fica desativado no banco e
some da aba.

**Os portões:** `conferir-remocao-por-post.php` -- a trava desceu de 14 para 13; falsificado com a ação
aceitando GET, o sinal trocado, o X por link e o "Sim" sem formulário, os quatro vermelhos.
`conferir-recorte-por-unidade.php` vigia a regra e as três chamadas; arrancar cada uma deixa vermelho.
Um verificador independente confirmou, e refez as falsificações numa cópia fora do repositório.

**O que ficou:** a regra tem uma cópia no front novo (`Console.tsx`, `papeis.gerente ||
papeis.administrador`), em outra língua; e a aba imprime cru o nome do pacote e o do serviço -- é o
D203, onde ficou registrado.

### D315 — O administrador que assume outra pessoa e clica "Sair" derruba o aparelho dela — CORRIGIDO em 01/10/2026 — PARCIAL, lido em 05/10/2026 — PARCIAL em 06/10/2026 — PARCIAL em 08/10/2026 — CORRIGIDO em 12/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: a escolha do dono entre a barreira de origem e o token nos botões do ERP; e o caminho do D127, até ele ser decidido.
>
> **06/10/2026:** decidido pelo dono (lote de perguntas, pergunta 3): a barreira de origem nos cinco botões, sem token. Falta: a implementação, que está na fila, e o caminho do D127.
>
> **06/10/2026, no item ERP-BOTOES-E-DATAS:** a barreira está nos cinco (ver "A barreira de origem nos
> quatro botões", no fim deste registro). Falta só o caminho do D127, que é decisão à parte e continua com
> o dono; por isso o débito segue PARCIAL. As outras escritas por POST do ERP antigo, que a pergunta 3 não
> contava, estão no "O que fica" do fim e foram ao lote como pergunta nova.
>
> **08/10/2026:** a pergunta 38 da página do lote voltou (07/10/2026): a barreira num lugar só, na entrada dos controllers do ERP; e, no fato, "Não, que eu saiba" -- nenhuma página de fora manda formulário para ele, que o dono saiba (é o que ele sabe, e não uma medida). ~~A implementação está na fila -- ver o fim deste registro.~~
>
> **08/10/2026, no item ERP-BARREIRA-NA-ENTRADA:** feita -- ver "A barreira de origem na entrada do ERP", no fim deste registro. Falta só o caminho do D127, que continua com o dono; por isso o débito segue PARCIAL.

**Achado pela frente do D223, lido no código e não medido por HTTP** (não há administrador com senha
no banco de contrato). O `autenticarComoAction` põe na `SessaoAcl` a pessoa assumida (X), com o token
de X, e deixa a identidade do administrador. Os efeitos:

- "Sair" nessa sessão apaga o token de X no banco, e o aparelho de X sai com "Atualizamos o sistema".
  Desde o D222 isso acontecia em até 48 s; com a guarda do D223, acontece no clique seguinte.
- A regra confere X, e não o administrador: se X entra em outro lugar, o administrador sai com "usada
  em outro local"; se alguém entra como o administrador em outro lugar, a sessão de personificação
  não cai.
- O `token_web` do administrador não é invalidado no "Sair" dessa sessão.
- O retorno da personificação (o ramo com `usuarioSessao`) monta a `SessaoAcl` com o token ATUAL do
  banco da pessoa do id da rota: se alguém entrou como o administrador em outro aparelho durante a
  personificação, a sessão que volta adota o token novo, e as duas ficam válidas -- a regra do D223 é
  anulada nesse caso, e não só atrasada.

**O conserto:** guardar o token do administrador na personificação e usá-lo na regra, no "Sair" e no
retorno. Mora no `UsuariosController` (`autenticarComoAction`), da mesma família do D127.

#### CORRIGIDO em 01/10/2026

A sessão de personificação guarda o token do login de quem entrou, e não o da pessoa assumida. A
regra, o "Sair" e a volta usam o mesmo par: a marca `usuarioSessao` (fora da personificação, o
`usuario.id`) e esse token -- `SessaoAcl::loginDaSessao`.

- **A entrada** (`autenticarComoAction`) lê a marca e o token ANTES do `setAcl`, que reescreve o `acl`
  inteiro, e regrava o token do login depois dele (`SessaoAcl::manterTokenDoLogin`). O token de X não
  fica na sessão. Nada vira cookie (a ação não chama `setCookie()`), e a API continua sem publicá-lo
  (`SessaoJson` escolhe os campos à mão).
- **A regra** (`UmDispositivoPorVez`) e **o "Sair"** (`invalidarTokenDestaSessao`) conferem e apagam o
  token de quem entrou. A porta da ação e a guarda de despacho não mudaram.
- **A volta para quem entrou** guarda o token da sessão, e não o atual do banco. Sem token guardado
  -- a sessão de quem entrou já estava sem token, o que só acontece depois do caminho do D127 --,
  adota o do banco, como antes: senão a sessão seguiria sem conferência nenhuma.
- **A personificação aberta antes desta correção** (acrescentado na revisão de 01/10/2026) sai uma
  vez, no primeiro pedido depois do deploy, com "Atualizamos o sistema" (info). O token dela é o da
  pessoa assumida, e o par não é de login nenhum: conferido, ele a tirava com "usada em outro local"
  (danger), acusando um login que ninguém fez, ou, com o token de X nulo, nem a conferia, e ela
  seguia até expirar com um "Sair" que não apagava nada. A entrada de hoje grava `tokenDeQuemEntrou`
  na sessão (`manterTokenDoLogin`); a marca sem ele é a sessão de antes (`loginDaSessao`, a chave
  `tokenDeOutraPessoa`), e a regra a encerra sem conferir token. Nenhum token do banco muda, e o
  cookie de A, se ainda vale, o devolve a si mesmo.

O que muda para quem usa, com A administrador e X assumido:
- o "Sair" apaga o token de A, e o de X fica: o aparelho de X segue;
- o login de X noutro lugar não tira mais A; o de A noutro lugar tira a personificação no pedido
  seguinte (o ERP vai ao `entrar` com "usada em outro local"; a API responde 401), e a volta já não
  deixa as duas sessões válidas;
- desativar X não tira mais A -- antes A saía com "Atualizamos o sistema", pelo token apagado de X. A
  entrada já aceitava assumir um inativo;
- o campo `usuario_sessao_token` do layout passa a valer `1` durante a personificação, e a página
  pergunta ao `validarHash` a cada 48 s -- que confere A.

**O caminho do D127 ficou como estava.** A volta para um id diferente de quem entrou monta a sessão da
pessoa da rota (W) com o token atual dela e sem marca; daí em diante vale o par dela -- o login dela
derruba a sessão, o de A não; o "Sair" apaga o token dela, e não o de A; sem token, ela não é
conferida. Esses efeitos do D315 continuam nesse caminho até o D127 ser decidido. Quatro coisas
sobre ele, achadas na revisão de 01/10/2026 e lidas no código:
- **Ele não exige URL editada.** Personificando um administrador X (o D127 contou 10 pessoas ativas
  no grupo "Gerente geral"), o `usuarioAdmin` que o `CrudController` lê da sessão é o de X, e a lista de
  usuários (`usuarios/index.phtml:133-139`) mostra o "Autenticar como" em cada linha: um clique num W
  qualquer cai no ramo da volta, com W diferente da marca.
- **"Sem token" é estado comum**, e não só de quem nunca entrou: todo "Sair" normal
  (`invalidarTokenDestaSessao`) e toda desativação (`UsuarioService::mudarEstado`) gravam o
  `token_web` nulo. Uma sessão do caminho do D127 com W nessas condições só cai ao expirar, no "Sair"
  ou com a desativação de A (a identidade).
- **Encadeia.** Se W é administrador, entrar dali como outra pessoa grava a marca W: `loginDaSessao`
  passa a dizer que quem entrou é W, embora a identidade do Laminas seja A, e a regra, o "Sair" e a
  faixa do `SessaoJson` seguem W.
- **Três GETs forjados escolhem quem sai.** Com A logado, um site de fora abre um popup e o navega
  três vezes -- navegações de topo, e o cookie de sessão é `SameSite=Lax`
  (`config/autoload/sessao.global.php`); nenhum controller do ERP confere a origem (o `OrigemDoPedido`
  só serve a `/adm/api`). `autenticarComo/W` entra (marca A, token de A); o mesmo GET de novo cai na
  volta com W diferente da marca (sessão de W, sem marca, com o token de W); e `GET /autenticacao/sair`
  -- rota `Literal`, e o `sairAction` não confere o método -- apaga o token de W. O aparelho de W cai no
  clique seguinte com "Atualizamos o sistema"; A sai também, e o token de A segue valendo. Antes do
  D315 bastavam dois passos (W e o "Sair"); o conserto não abriu o caminho, e não o fechou. As peças
  são do D127 e da família do D199 e do D260 (GET que escreve); o "Sair" por GET não estava em
  nenhum débito. **Desde 03/10/2026 os dois primeiros passos não passam** -- o `autenticarComo` só
  aceita POST (ver "A personificação só por POST", no fim deste registro); ~~o "Sair" por GET
  continua.~~ (o terceiro também não passa desde 03/10/2026: o "Sair" só aceita POST da mesma origem --
  ver "O 'Sair' só por POST, a trava de si mesmo e a senha trocada", no fim deste registro).

O "Sair" nesse caminho, pelos dois jeitos de chegar a ele (o ramo que o clique alcança, chamado direto
pelo teste, e os GETs repetidos), está
caracterizado no `D315PersonificacaoTest` (g2): apaga o token de W, deixa o de A e o de X, e o
aparelho de W cai no pedido seguinte. Caracteriza, não decide: quando o D127 for decidido, o caso
muda junto.

**Provado:**
- `D315PersonificacaoTest`: 15 casos e 20 execuções, verdes nos dois bancos. Despachados em processo,
  com o construtor real do controller e o gerente de fixture num grupo de administrador criado pelo
  teste. A revisão de 01/10/2026 acrescentou dois casos: a personificação aberta antes do D315 (três
  execuções: X com token no ERP, X sem token no ERP, e na API) e o "Sair" no caminho do D127 (g2, duas
  execuções). E tirou do controle (g) três asserções de token que não podiam falhar: nem a ação nem a
  guarda gravam `token_web`.
- Com os quatro arquivos de código de volta ao HEAD (`git show`), 15 das 20 ficam vermelhas. As cinco
  verdes são os controles: a personificação sem nada mudando, a volta do D127, as duas do "Sair"
  nesse caminho e a desativação de A.
- Mutantes, um de cada vez, todos vermelhos. Os sete primeiros foram medidos antes da revisão, com 15
  execuções; os quatro últimos, depois dela, no filtro inteiro de 77 testes:

  | Mutante | Execuções vermelhas |
  |---|---|
  | A entrada sem guardar o token, ou lendo-o depois do `setAcl` | 10 |
  | A volta sem guardar o token, ou lendo a marca depois do `setAcl` | 2: a volta depois de outro login de A, e a volta com o token apagado |
  | A volta guardando o token também para outra pessoa | 1: o controle do D127 |
  | A volta sem o `!empty` | 1: a sessão sem token |
  | A regra pela identidade do Laminas, no lugar da marca | 1: o controle do D127 |
  | A regra ainda pelo `usuario.id` | 5 |
  | O "Sair" ainda pelo `usuario.id` | 1 |
  | O "Sair" pela identidade do Laminas, no lugar do par da sessão | 2: as do (g2). Antes da revisão, nenhum caso o pegava |
  | A entrada sem gravar `tokenDeQuemEntrou` | 7: toda personificação de hoje vira "a de antes" e sai |
  | A sessão de antes reconhecida sem olhar a marca | 13: 8 do D223, 4 do D315 e 1 do `UsuarioAtivacaoTest` |
  | A regra sem o ramo da sessão de antes | 3: as três da personificação aberta antes do D315 |
- O D315 e os vizinhos ficam verdes nos dois bancos, 77 testes, sem mudar o código dos vizinhos (do
  D223 mudaram só dois docblocks): D223, `UsuarioAtivacaoTest`, `SessaoApiTest`,
  `AutenticacaoDoClienteTest` e D198.
- `conferir-recorte-por-unidade.php` continua vigiando a porta (149/0/0); arrancar
  `$this->usuarioAdmin ||` deixa EXIT 1.

**O que ficou:**
- o D127: a porta e a volta aceitam qualquer id, a autoria é de X, e nenhum rastro é gravado;
- nada foi medido por HTTP nem no navegador: não há administrador com senha no banco de contrato, e
  personificação não se ensaia por HTTP. A prova é em processo;
- a personificação aberta antes do deploy cai uma vez, no primeiro pedido, com "Atualizamos o
  sistema" (acima). Não conferi se a página inicial mostra a mensagem quando o cookie refaz o login
  de A logo em seguida -- isso depende de a view de `/` ler as mensagens. Fazer o deploy fora do
  horário da loja;
- ~~o registro do D127 diz que `SessaoAcl::setCookie()` não é chamado por ninguém. Ele é chamado no
  `entrarAction` e no `entrarCookies` do `AutenticacaoController` desde a importação (`eaf1fca`). Não
  foi corrigido lá, para não mexer no D127;~~ CORRIGIDO em 07/10/2026 no registro do D127 (a "Correção
  de registro" logo depois do parágrafo da personificação), sem mexer no débito: só a frase;
- lidos no código, fora do D315 e ainda não registrados:
  - ~~A, personificando um administrador X, consegue desativar a si mesmo: as travas comparam com o
    `usuarioId`, que é X;~~ CORRIGIDO em 03/10/2026 ("O 'Sair' só por POST, a trava de si mesmo e
    a senha trocada", no fim deste registro);
  - ~~o `autenticarComo` é um GET, e o cookie é `SameSite=Lax`: uma navegação vinda de outro site
    começa uma personificação ou, durante uma, volta para qualquer id (a família do D199 e do D260);~~
    CORRIGIDO em 03/10/2026, abaixo;
  - ~~o "Sair" também é GET (`/autenticacao/sair`, rota `Literal`, sem conferir o método): uma
    navegação vinda de outro site desloga quem estiver logado e apaga o token dele -- e, com o
    caminho do D127, escolhe quem sai (acima);~~ CORRIGIDO em 03/10/2026, no fim deste registro;
  - ~~trocar a senha (`updateMeusDadosSenha`, `senhaConfirmar`) não gira o `token_web`, e o cookie velho
    continua entrando.~~ Registrado como D347; as duas portas da própria pessoa CORRIGIDAS em
    03/10/2026, no fim deste registro, e a terceira (a própria ficha, no `editarSenha`) em 04/10/2026,
    no D347; a senha que o gerente define para outra pessoa espera a P7.

#### A personificação só por POST — CORRIGIDO em 03/10/2026

O cadeado da barra do ERP, o "Autenticar como" da lista de usuários e o "voltar a ser eu" da tela
nova eram GET: uma navegação vinda de outro site -- o cookie é `SameSite=Lax`, que vai na navegação
de primeiro nível -- começava uma personificação, ou, durante uma, voltava para qualquer id.

**O conserto, na forma das irmãs do D199 e do D260:** `autenticarComoAction` começa recusando o GET
(`if (!isPost()) { return notFoundAction() }`, antes da porta). O cadeado do `layout.phtml` virou um
formulário POST escondido, que o clique no cadeado envia (o cadeado continua filho do `li`, que é o
que o tema estiliza); o botão de cada linha de `usuarios/index.phtml` virou um formulário POST, como
o "Ativar" dos convênios; e o `voltarASerEu` da tela nova manda `POST` pelo `fetch` -- e o último
recurso dele, quando a volta confirmada falha, deixou de ser `location.assign` (que daria o 404) e
passou a enviar um formulário POST ao mesmo endereço. A ação entrou em `ACOES_SO_POST` do
`conferir-remocao-por-post.php`, uma linha por tela: o portão confere que a guarda é o primeiro
comando, que cada tela tem o `<form method="post">` e que nenhuma tem link para a ação.

**Sem token de formulário; com a barreira de origem (a revisão de 03/10/2026).** O item pedia
"formulário/POST com o token". A primeira versão deste conserto deixou o token de fora dizendo que
o ERP não tem token para formulário de botão -- o que vale para os botões, e não para o ERP: os
formulários do Laminas usam `Csrf` (`new Csrf('security')` em cerca de 50 arquivos de `module/`, o
`CreditoDevolucao` entre eles); os botões irmãos (D260, D263, D294, D298) é que não têm. O POST de
outro site chega sem o cookie (`SameSite=Lax`, explícito em `sessao.global.php`); o que o token
acrescentaria é a defesa contra outro subdomínio do MESMO site, que o `SameSite` não separa. Na
revisão, essa defesa entrou pela barreira que `/adm/api` já usa (`OrigemDoPedido`, o D290), e não
por token: `autenticarComoAction`, depois de recusar o GET e antes da porta, responde 403 ao POST
cujo `Sec-Fetch-Site` não é `same-origin` (outro site e outro subdomínio) e, sem ele, ao `Origin` de
outro host; o pedido sem os dois (um cliente que não é navegador) passa, como lá. A leitura crua dos
cabeçalhos saiu do `ApiAdmController::onDispatch` para `OrigemDoPedido::recusaDoPedido`, uma só
para as duas portas. Por que não o `Csrf`: no formulário escondido do cadeado, renderizado em toda
página da personificação, o token do Laminas vence em 300 s por padrão (o clique depois disso seria
recusado), e o "voltar a ser eu" da tela nova, que é `fetch`, precisaria de um endereço que o
entregasse. A escolha entre a barreira e o token nos botões do ERP foi ao lote de perguntas do dono,
com a recomendação de ficar na barreira.

**O `entrar` por GET encerrava a personificação (a revisão de 03/10/2026).** Na personificação, o
`token_web` do navegador continua sendo o do administrador (a ação só chama `setAcl` e
`manterTokenDoLogin`), e a guarda de login deixa a sessão logada seguir até a ação. Uma navegação de
primeiro nível vinda de outro site para `/autenticacao/entrar` levava o cookie (`SameSite=Lax`), o
`entrarCookies` autenticava por ele e montava `setAcl(null, ...)` como o administrador: a marca
sumia e a personificação acabava. O dano era pequeno -- voltava só para quem entrou de verdade, sem
escolher pessoa --, mas era uma porta por GET que nem este registro nem o conserto acima citavam.
Agora o `entrarAction`, no GET, com a sessão logada e personificando, leva à página inicial sem
refazer o login; a sessão anônima, a do desativado e a do dispositivo expulso (D223) chegam lá sem
identidade, e o cookie entra como antes. ~~O "Sair" por GET continua sendo outro item.~~ (CORRIGIDO
em 03/10/2026, abaixo)

**A forma do pedido não decide o D127:** a porta, a volta para outro id, a marca e o rastro ficaram
como estavam, e os casos do caminho do D127 (g, g2, g3) continuam verdes, só que por POST.

**Provado:** `D315PersonificacaoTest`, 32 execuções verdes nos dois bancos (o arnês passou a
despachar por POST; os casos novos: o GET não começa a personificação; personificando, o GET para
quem entrou e para outro id é 404 e a sessão não muda; o POST entra e volta; e, da revisão, o POST de
outro site, de outro subdomínio e com o `Origin` de outro host é 403 na entrada e na volta, com a
sessão intacta (6 execuções), o POST da mesma origem entra e volta, e o GET do `entrar` com o cookie
do administrador -- que entraria de verdade: a senha dele é a que o login pelo cookie confere -- leva
à página inicial e deixa a personificação como estava). Com a guarda arrancada, os dois casos do GET
ficam vermelhos nos dois bancos. Da revisão, cada mutante vermelho nos dois bancos: sem a barreira
de origem na ação, os 6 casos de outra origem; `recusaDoPedido` sempre deixando passar, os 6 e o
`testAEscritaDeOutraOrigemEBarradaAntesDaAcao` do `PrivilegiosPelosControllersTest` (a barreira de
`/adm/api`, que passou a ler por ela); `recusaDoPedido` sem ler o `Origin`, os 2 do `Origin` e o
mesmo da API; e o `entrar` sem a guarda da personificação, o caso dele. A barreira de `/adm/api`, que
passou a ler os cabeçalhos pelo mesmo lugar, ficou como era: o `PrivilegiosPelosControllersTest`, a
suíte inteira (1.660 execuções) e os ensaios de usuários (19 de 19), de permissão (61 de 61), de
recusas e da sessão do ERP (15 de 15, que passa pelo `entrar`), verdes nos dois bancos. O portão: arrancar a guarda, pô-la depois da
porta, voltar o cadeado a link, tirar o `method="post"` do formulário do cadeado e voltar o botão da
lista a link -- cada um deixa EXIT 1. No vitest (`ConsoleLigado.teste.tsx`, 25 casos): a volta sai
por POST, e a ida ao ERP sem dublê envia um formulário POST ao endereço da volta; tirar o
`method: 'POST'` e voltar o último recurso a `location.assign` deixam cada um o seu caso vermelho.
**Não visto no navegador** -- nem o cadeado nem o botão da lista: não há administrador com senha no
banco de contrato. O que é em processo é a AÇÃO (o `D315PersonificacaoTest`); as duas telas
(`layout.phtml` e `usuarios/index.phtml`) não são renderizadas por teste nenhum, e quem as lê é só o
portão textual (`conferir-remocao-por-post.php`: o `<form method="post">` e a falta de `href` para a
ação). O `onclick` que envia o formulário escondido do cadeado não é exercido por nada.

**O que fica:** ~~o "Sair" por GET (outro item);~~ (CORRIGIDO em 03/10/2026, abaixo) o D127; ~~o token
nos botões do ERP, que é pergunta ao dono (acima).~~ (decidido pelo dono em 06/10/2026: a barreira de
origem, sem token; feito no mesmo dia, em "A barreira de origem nos quatro botões", abaixo)

#### O "Sair" só por POST, a trava de si mesmo e a senha trocada — CORRIGIDO em 03/10/2026

Três dos quatro "lidos no código" acima, no item SESSAO-SENHA-EMAIL.

**O "Sair" só por POST, e da mesma origem.** `sairAction` começa recusando o GET (404) e, antes de
mexer em qualquer coisa, o POST de outra origem (403, `OrigemDoPedido::recusaDoPedido`) -- o molde do
"Autenticar como". O "Sair" do menu do usuário (`layout.phtml`) virou um formulário escondido que o
clique envia, como o cadeado. A tela nova não tem "Sair" (procurado em `frontend/src`: nenhum endereço
de saída). A ação entrou em `ACOES_SO_POST` do `conferir-remocao-por-post.php`; como o layout chama a
rota pelo NOME (`url('isconfiguracao-autenticacao-sair')`), sem `'action' => 'sair'`, a linha ganhou
uma quinta coluna que diz como a tela nomeia a ação. Os ensaios que saíam por GET passaram a POST (os
casos 3 e 6 de `ensaiar-sessao-do-erp.php` e o `sair()` de `ensaiar-busca-de-cliente-da-agenda.php`).
Com isto, nenhum dos "três GETs forjados" do caminho do D127 passa mais.

**A trava de si mesmo confere quem entrou.** `CrudController::ehASiMesmo`: o alvo é a própria pessoa
quando é o `usuarioId` (quem a sessão mostra) OU o `loginDaSessao()['id']` (quem entrou). As três portas
do D294 -- o botão da lista do ERP, a edição e a API -- perguntam a ela, e o `proprio` que a tela nova
lê também. Personificando um administrador X, A já não desativa a si mesmo.

**A senha trocada pela própria pessoa** derruba as outras sessões e o cookie: é o D347, PARCIAL (a
senha que o gerente define para outra pessoa espera a P7).

**Provado (em processo, nos dois bancos):** `D315PersonificacaoTest` ganhou seis execuções: cinco do
"Sair" -- o GET é 404 e não mexe no token, na identidade nem na sessão; o POST de outro site, de outro
subdomínio e com o `Origin` de outro host é 403, com tudo intacto; o POST da mesma origem sai (o
controle: o token apagado, a identidade fora, o 302 ao `entrar`) -- e uma da personificação de
verdade: com X administrador, o botão do ERP e a API recusam desativar A, e o `proprio` vale para A e
não para outra pessoa. O `UsuarioAtivacaoTest` ganhou a porta da edição, com a sessão dublê dizendo que
quem entrou é outro. Por HTTP, nos dois bancos de contrato, o caso 16 do ensaio da sessão: o GET é 404
e a API segue 200; o POST com `Sec-Fetch-Site: same-site` é 403 e a API segue 200; o da mesma origem é
302 e a API passa a 401. Falsificado por HTTP: com a guarda de POST arrancada, o GET responde 302 e
desloga (a API cai a 401), e só o caso 16 fica vermelho.
Mutantes, um de cada vez, vermelhos: o "Sair" sem a guarda de POST (o caso do GET); sem a barreira de
origem (os três de outra origem); `ehASiMesmo` só pelo `usuarioId` (o da personificação e o da
edição); o botão, a edição, a API e o `proprio`, cada um de volta ao `usuarioId` (o seu caso). O
portão: arrancar a guarda do `sairAction`, pô-la depois da barreira de origem, voltar o "Sair" a link
e tirar o `method="post"` do formulário -- cada um deixa EXIT 1.

**Não visto no navegador:** o clique no "Sair" que envia o formulário escondido não é exercido por
nada; a tela só é lida pelo portão textual, e o POST da mesma origem é o do ensaio HTTP.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 3): a opção (a), "Barreira de origem
nos cinco" -- sem token de formulário: o "Autenticar como", que já a tem, e os outros quatro botões do
ERP que escrevem por POST (o D260, o D263, o D294 e o D298) recusam o POST de outra origem com 403, pela
`OrigemDoPedido` da API nova. ~~A implementação está na fila.~~ (feita em 06/10/2026, logo abaixo)
Continua também o caminho do D127.

#### A barreira de origem nos quatro botões — CORRIGIDO em 06/10/2026

No item ERP-BOTOES-E-DATAS, pela resposta 3 do dono.

**O conserto, no molde do "Autenticar como" e do `PrivilegiosController::removerAction`:** os quatro botões
do ERP que escreviam por POST só com o `SameSite=Lax` recusam com 403 e `OrigemDoPedido::FRASE` o POST cujo
`Sec-Fetch-Site` não é `same-origin` (outro site, outro subdomínio do mesmo site) e, sem ele, o do `Origin`
de outro host; o pedido sem os dois (um cliente que não é navegador) passa, como na `/adm/api`. A lista saiu
dos corpos do D260, do D263, do D294 e do D298 e do código, e não da memória:

- `ConveniosController::ativarRemoverAction` (D260) e `CuponsController::ativarRemoverAction` (D263):
  depois da guarda de POST e do privilégio, antes de ler o alvo;
- `UsuariosController::ativarRemoverAction` (D294): dentro do `if` de POST e privilégio, antes de ler a
  pessoa;
- `WhatsAppController::desconectarAction` (D298): depois da guarda de POST -- que o
  `conferir-remocao-por-post.php` quer como o primeiro comando -- e antes de falar com a instância.

**Quem os chama é só a tela do ERP, da mesma origem:** os formulários das listas de convênios e de cupons, a
caixa de `remover-por-post.js` (convênios, cupons e usuários) e o formulário da tela da instância, todos com
o `action` relativo do `$this->url(...)`. Procurado em `module/`, `public/`, `frontend/src` e `scripts/`:
nenhum JavaScript, nenhuma tela nova e nenhum roteiro de produção os chama; o único outro chamador é o
ensaio dos cupons (casos 40 e 53), que manda só o cookie e passa. A tela nova de usuários desativa pela API
(`/adm/api/usuarios/:id/desativacao`), que já tinha a barreira.

**Provado:**
- `BotoesDoErpSoDaMesmaOrigemTest` (20 execuções, verdes nos dois bancos), despachado em processo: em cada
  botão, o POST de outro site, de outro subdomínio e com o `Origin` de outro host é 403 com a frase, e o
  convênio, a campanha e a pessoa seguem ativos e a instância não ouve o desconectar (12); o POST da mesma
  origem -- mesmo com um `Origin` que não é o host -- passa e muda (4); e o pedido sem cabeçalho de origem
  também (4).
- Mutantes, um de cada vez, no MySQL, restaurados e conferidos pelo sha256: cada barreira arrancada deixa
  vermelhos os 3 casos de outra origem do seu botão (4 mutantes, 12 vermelhos); a barreira do convênio que
  só recusa `cross-site`, os 2 do subdomínio e do `Origin`; e a barreira do desativar usuário movida para
  depois do `mudarEstado` -- o 403 sai e a pessoa já foi desativada --, os 3 do usuário, pelo banco.
- Os vizinhos verdes nos dois bancos (922 execuções em 66 arquivos, 10 pulados por pedirem o histórico da
  produção): este registro (`D315PersonificacaoTest`), `UsuarioAtivacaoTest`, `EstadoPorPostNoMarketingTest`,
  `TelasDosConveniosDoErpTest`, `TelasDosCuponsDoErpTest`, `OrigemDoPedidoTest`, `PrivilegiosDoErpTest`,
  `PrivilegiosPelosControllersTest` e os outros que tocam os controllers mudados. Por HTTP, contra o banco
  de contrato, o ensaio dos cupons 59 de 59 -- o 40 (GET, 404) e o 53 (POST só com o cookie, 302). E os
  portões `conferir-remocao-por-post.php`, `conferir-recorte-por-unidade.php`,
  `conferir-autorizacao-privilegiada.php` e `conferir-guarda-do-erp.php`, EXIT 0.

**Não visto no navegador:** nenhum dos quatro cliques -- as telas não mudaram, e o POST da mesma origem é o
do controle em processo e o do ensaio. O 403 por HTTP com o `Sec-Fetch-Site` de outro subdomínio não foi
medido nestes quatro: é a mesma leitura (`recusaDoPedido`) que o caso 16 do `ensaiar-sessao-do-erp.php` mede
por HTTP no "Sair".

**O que fica:** o caminho do D127. E as outras escritas por POST do ERP antigo sem token nem barreira, que
não eram da pergunta 3 (ela contava cinco botões). **A primeira redação daqui as contava pela caixa** -- 18
das 22 ações de `remover-por-post.js`, o `saidas/excluirSaida` e as escritas da agenda antiga -- e a
pergunta do lote só levava as 19 da caixa e da despesa (a revisão do item, 06/10/2026). A classe é maior:
lido o código dos 57 controllers do ERP antigo (fora o aplicativo e a `/adm/api`), **135 ações aceitam POST
sem a barreira e sem o `isValid()` do formulário**; pelo nome, 43 só leem, o remarcar tem o token (D216) e o
cancelar venda pede a senha, e **perto de 90 escrevem** só com o `SameSite` -- por heurística (o roteiro
lê o corpo de cada ação sem comentários; uma ou outra, como o `atualizar` da agenda, só redesenha). Entre
elas, além das da caixa:

- as da agenda antiga do fim do D216: marcar (`agendar`), desmarcar, a chegada (`checkin`), fechar e abrir;
- fora da caixa, dinheiro e cadastro: `usuarios/comissaoExtraSalvar` e `comissaoExtraRemover` (a
  cota-parte, até 100%), `unidades/socioSalvar` e `socioRemover`, `saidas/parcelaQuitar`,
  `quitarSemPagamento`, `pagamentoEstornar`, `parcelaEditar` e `excluirParcelas`, `caixas/retirada`, os
  `vendasPagamentos` e `vendasProdutos`, o `autorizarVendaFiado`, o `devedores/pagarDebito` e o resgate de
  pontos da fidelidade.

Um portão que derive a lista da caixa não vê estas. A pergunta do lote foi reescrita com a classe inteira e
três opções: a barreira num lugar só (a entrada do `CrudController`, de onde os 57 descendem, como o
`ApiAdmController` faz na `/adm/api`), com o módulo do aplicativo de fora -- ele descende do mesmo
`CrudController` e responde `Access-Control-Allow-Origin: *`, chamado de outra origem de propósito --; uma
por uma, nas 19 e nas cinco da agenda; ou deixar como está. E uma pergunta de fato: se alguma página fora
do ERP manda formulário direto para ele.

**Decidido pelo dono em 07/10/2026** (pergunta 38 da página do lote), as duas partes. O **rumo**: a
opção (a), "A barreira num lugar só, na entrada dos controllers do ERP" -- "Todo POST de outro site ou
subdomínio é 403; o da tela passa. O aplicativo fica de fora, porque é chamado de outra origem de
propósito. Um portão confere que nenhum controller escapa." O **fato**: a opção "não", "Não, que eu
saiba" -- nenhuma página fora do ERP (o site, uma landing, outro subdomínio) manda formulário direto para
ele, **que o dono saiba**. A lista de exceções nasce vazia por isso, e a resposta é o que ele sabe, e não
uma medida: uma página que ele não conheça e que mande formulário ao ERP passa a receber 403 com a
barreira. Daí a recomendação a quem a implementar: a lista num lugar só, onde se acrescenta uma origem
sem mexer na barreira. A
implementação está na fila (outro item); nada mudou no código neste. O débito segue PARCIAL: a barreira em
toda a classe, e o caminho do D127. (A barreira em toda a classe: feita em 08/10/2026, logo abaixo.)

#### A barreira de origem na entrada do ERP — CORRIGIDO em 08/10/2026

No item ERP-BARREIRA-NA-ENTRADA, pela resposta 38 do dono: o rumo (a), "A barreira num lugar só, na entrada dos
controllers do ERP", e o fato "Não, que eu saiba".

**O conserto.** Um ouvinte do despacho, `ISConfiguracao\Permissions\BarreiraDeOrigem`, pendurado pelo
`onBootstrap` do `ISConfiguracao\Module` na prioridade 1000 -- acima da guarda de sessão (100) --, recusa com 403
e `OrigemDoPedido::FRASE` todo POST, PUT, PATCH ou DELETE que chega a uma rota do ERP vindo de outro site, de
outro subdomínio do mesmo site ou, sem `Sec-Fetch-Site`, com o `Origin` de outro host. A regra é a de
`OrigemDoPedido::recusaDoPedido`, sem cópia; o pedido sem os dois cabeçalhos passa (o `curl`, o cron, a
integração). Antes da guarda de sessão de propósito: o pedido de fora não abre sessão, e o anônimo não cai no
`entrar` com o corpo forjado -- o login forjado, o de quem monta a página na sessão de quem a abre, também
passava só com o `SameSite`.

- **De fora, e só isto:** as rotas do módulo `ISApi` -- o aplicativo, a API v4, as rotinas do cron, as
  exportações das unidades; 65 --, lidas do `module.config.php` dele pelo `ModuleManager`, para o pedido que NÃO
  traz o cookie da sessão do ERP, e as rotas cujo controller é um `ApiAdmController` -- a `/adm/api`; 125 --, que
  têm a barreira no `onDispatch` (o D290), com a resposta da convenção dela. As duas só onde a guarda de sessão
  deixa passar sem sessão (`RotaPublica::exigeSessao`): as oito `/api/docs*` são do `ISApi` e exigem sessão, e
  ficam sob a barreira (a revisão, abaixo). O curinga do ERP que alcança um controller do `ISApi` pelo apelido
  (`/mod-cadastros/aplicativo/...`, `/mod-configuracoes/cronJob/...`) fica sob a barreira: a rota é do ERP.
- **A lista de exceções** mora em `module/ISConfiguracao/config/rotas-de-outra-origem.php`: o nome completo da
  rota, as origens que podem escrever nela e o motivo. Nasce vazia, pela resposta do dono (que é o que ele sabe,
  e não uma medida); acrescentar uma linha não toca a barreira, e o portão confere cada uma (a rota existe, não é
  curinga, está sob a barreira; a origem é esquema, host e porta; o motivo diz qual página manda).
- **A resposta** é a dos botões: 403 com a frase, em `text/plain`; em JSON (`{sucesso: false, mensagem}`, a
  forma das ações JSON do ERP, a da janela de privilégios) quando a AÇÃO responde JSON -- lido no código dela, só
  na recusa (a revisão, abaixo) --, e também para o `$.ajax` do jQuery, que manda `X-Requested-With`, e para quem
  pede `application/json`.
- **As nove chamadas por ação saíram**, em seis controllers: o "Sair", o "Autenticar como", o desativar usuário,
  a janela de privilégios (salvar e remover, e o `recusaDeOrigem` delas), o remover molde de privilégio, o
  convênio, o cupom e o WhatsApp. Com o ouvinte antes delas, nenhuma recusaria mais nada fora dos testes em
  processo -- e eram eles que as provavam. Um lugar só, como o dono pediu; o leitor cru dos cabeçalhos ficou
  público (`OrigemDoPedido::cabecalho`) para a forma da recusa. Os testes delas continuam, pela barreira: os três
  arneses que despacham o controller direto (`ControllerEmProcesso`, o do D315 e o dos privilégios) chamam a
  mesma barreira do contêiner antes do `dispatch()` (`PelaEntradaDoErp`), com a rota que o roteador daria ao
  controller e à ação (`rotaPelaQualSeChega`; a revisão, abaixo).
- **Quem é chamado de fora, lido no código:** o cron chama `/api/cronjobs/*` (do `ISApi`) por `curl`, sem
  `Sec-Fetch-Site` nem `Origin` (`docs/crontab-a-aplicar.md`); as exportações `/api/unidade/vendas*` são do
  `ISApi`; não há rota de webhook de provedor (Asaas, WAHA, Evolution e OneSignal são chamadas que saem); a
  adesão pública (`/fidelidade/salvarDados`), a pesquisa NPS, a pesquisa de satisfação, o CEP, o login, o
  `validarHash` e o "Sair" são pedidos de páginas do próprio ERP (`$.ajax` com endereço relativo, formulário da
  página), da mesma origem; nenhuma rota do ERP responde CORS (só os controllers do `ISApi`).
- **A tela nova pelo proxy do Vite** (`/autenticacao` e `/mod-configuracoes/usuarios/autenticarComo`,
  `changeOrigin: false`): o navegador manda `Sec-Fetch-Site: same-origin`, e o `Origin` e o `Host` que chegam ao
  PHP são os do `localhost:5173`. Medido pelo proxy de verdade, no "Provado" abaixo.

**Provado:**
- `BarreiraDeOrigemNaEntradaTest` (novo, 202 execuções, sem banco), pelo `Application::run()` com a configuração
  de verdade -- o roteador escolhe a rota pela URL, e uma sentinela na prioridade 101 responde no lugar do
  controller: o POST de outro site, de outro subdomínio e com o `Origin` de outro host a 24 escritas de todos os
  módulos do ERP (os cinco botões, a janela de privilégios, a cota-parte, o sócio, a agenda antiga, o fiado, a
  retirada, o pagamento da venda, a parcela, um relatório, a raiz, o login, o "Sair", o `validarHash`, a adesão,
  a pesquisa NPS, o CEP e o curinga que leva ao aplicativo) é 403 com a frase e não chega à sentinela (72); PUT,
  PATCH e DELETE também; o da mesma origem, o `none`, o `Origin` do próprio host, o sem cabeçalho e o GET de
  outro site chegam (120); seis rotas do `ISApi` e da `/adm/api` chegam, de outro site (6); o `$.ajax` e o
  `Accept: application/json` recebem JSON; as rotas do aplicativo são as do `module.config.php` do `ISApi`; e a
  exceção de mentira deixa passar só a rota e só a origem dela.
- Os testes das cinco ações, sem mudar o que conferem, pela barreira: `BotoesDoErpSoDaMesmaOrigemTest`,
  `D315PersonificacaoTest`, `PrivilegiosDoErpTest` e `PrivilegiosPelosControllersTest` (a janela confere o corpo
  JSON da resposta; na primeira versão, com o `X-Requested-With` acrescentado -- a revisão o tirou, e o contrato
  é o antigo: JSON a qualquer pedido).
- Os dois bancos, em série, o filtro dos vizinhos (`BarreiraDeOrigemNaEntrada`, os quatro acima,
  `OrigemDoPedidoTest`, `GuardaDeLoginTest`, `UsuarioAtivacaoTest`, `EstadoPorPostNoMarketingTest`,
  `TelasDosConveniosDoErpTest`, `TelasDosCuponsDoErpTest`, `D199AtivarRemoverPorPostTest`, os do D223, o
  `SessaoApiTest` e os do D198): 440 execuções verdes no MySQL e no PostgreSQL, 3 puladas por pedirem o
  histórico da produção.
- Por HTTP, o caso 19 do `ensaiar-sessao-do-erp.php`, contra o banco de contrato dos dois: o desativar cliente da lista do ERP -- uma escrita que nunca teve barreira na ação -- é 403 com a
  frase de outro site, de outro subdomínio e com o `Origin` de outro host, e o cliente segue ativo; o da mesma
  origem desativa (302) e o sem cabeçalho reativa (302). 19 de 19 nos dois. Os outros onze ensaios do ciclo,
  que mandam POST por `curl` sem esses cabeçalhos, verdes nos dois bancos (no MySQL, o de recusas saiu 2 na
  primeira rodada pelo caixa que o de fluxo, rodado antes dele, deixou aberto -- o resíduo de sempre --, e 202 de
  202 com o banco remontado; no PostgreSQL, o de permissão e o de recusas saíram 2 com "SEM RESPOSTA" do host
  carregado, e passaram repetidos: 61 de 61 e 202 de 202).
- Pelo proxy do Vite de verdade (`npx vite`, e o `curl` em `localhost:5173`, sem sessão): o POST ao
  `autenticarComo` e ao `entrar` com `Sec-Fetch-Site: same-origin` e com só o `Origin: http://localhost:5173`
  passam (200, a resposta do `entrar`) -- o `Host` que o PHP recebe é o do proxy --; com `cross-site`,
  `same-site` ou o `Origin` de outro host, 403 com a frase.
- `scripts/conferir-barreira-de-origem.php` (novo, no `conferir-tudo.sh` e no `ci.yml`, no job dos portões ao
  lado do `conferir-rotas-sem-login.php`): 233 rotas, 35 sob a barreira, 73 do aplicativo, 125 da `/adm/api`, 0
  exceções; 163 POSTs de outro site pela aplicação montada (os 35 do ERP voltam 403, e o da mesma origem a cada
  um chega à sentinela), EXIT 0. A lacuna do CI, em dia nas duas direções.
- Mutantes, um de cada vez, restaurados e conferidos pelo sha256 (e o `git diff` igual ao de antes):

  | Mutante | Vermelho |
  |---|---|
  | O ouvinte arrancado do `Module` | o teste do MVC (74 execuções); o portão, EXIT 1. Os arneses em processo ficam verdes -- chamam a barreira do contêiner, e é o teste do MVC que prova a ligação. Por HTTP, contra o banco de contrato do MySQL: o caso 19 e o 16 (o "Sair" de outro subdomínio, que agora só a barreira da entrada recusa) ficam vermelhos -- o POST de fora desativa o cliente e desloga --, 17 de 19, EXIT 1 |
  | A barreira deixando passar tudo | 102 execuções: o teste do MVC (75), os botões (12), o D315 (9), o molde de privilégio (3) e a janela (3); o portão, EXIT 1 |
  | A barreira depois da guarda de sessão (prioridade 50) | o teste do MVC (74: a sentinela responde antes); o portão, EXIT 1 |
  | A `/adm/api` sob a barreira | o teste do MVC (1) e o `testAEscritaDeOutraOrigemEBarradaAntesDaAcao` (a recusa da API perde a forma dela); o portão, EXIT 1 (125 rotas) |
  | O aplicativo sob a barreira | o teste do MVC (6); o portão, EXIT 1 (73 rotas) |
  | Uma rota do ERP na lista de fora (o curinga dos cadastros como do aplicativo) | o portão, EXIT 1 (a família é do `ISCadastro` e leva a controller do ERP); o teste do MVC (14) |
  | Uma exceção para o curinga `isconfiguracao-admin/default` | o portão, EXIT 1 |
  | Uma rota no `module.config.php` do `ISApi` que leva ao `UsuariosController` | o portão, EXIT 1 |
  | A recusa sempre em texto | o teste do MVC (1) e a janela de privilégios (3) |
  | A exceção sem conferir a origem | o teste do MVC (1) |

  ONDE ELE RODA (corrigido na revisão: aqui se lia "no ciclo, no corredor e no CI"): no ciclo dos ensaios, à mão
  (`medir-ensaios-escolhidos.sh` e `medir-ensaios-pg.sh`), e no job `caracterizacao-postgres` do `ci.yml` -- que
  está parado por cobrança desde 13/09/2026. O corredor (`conferir-tudo.sh`) não roda ensaio HTTP nenhum: a
  lacuna declara este como `PRECISA_PG`, e o `--so-declaracao` do corredor só confere a declaração.

**A revisão de 08/10/2026 -- seis achados de um verificador, conferidos no código e consertados.**

1. **Quatro escritas do `ISApi` que só a sessão do ERP alcança ficavam de fora:** o desmarcar
   (`/api/agendas/cancelar`, que apaga o bloco do atendimento), o agendar v2 e v3 e o cadastrar indicação
   autorizam pela sessão do OPERADOR (`AplicativoController::usuarioDoSistemaAutenticado`, o D91), e o aplicativo
   instalado toma 401 nelas (o D237). A página de outro subdomínio aberta com a sessão viva as disparava -- o
   navegador manda `Sec-Fetch-Site: same-site` e o cookie `SameSite=Lax` junto --, e a frase "não achei ação do
   ERP que a barreira não cubra" estava errada para elas. Medido por HTTP com o conserto arrancado: o POST de
   outro subdomínio, com a sessão, chegava à ação e era atendido. **Conserto:** a rota do `ISApi` só fica de fora
   para o pedido que não traz o cookie da sessão do ERP (`BarreiraDeOrigem::trazASessaoDoErp`, pelo
   `session_name()` do servidor, e não por um nome escrito). A pergunta é pelo cookie, e não por uma lista de
   ações, de propósito: vale sozinha para a ação do `ISApi` que passar a ler a sessão, e sai sozinha se o D91 for
   revertido para o aplicativo antigo, que não traz o cookie. O aplicativo é de outro site e nunca manda o cookie
   `Lax` num POST: segue com o 401 de sempre nessas quatro (medido, caso 20).
2. **O login forjado continuava pelas oito `/api/docs*`:** são do `ISApi`, ficavam de fora da barreira, e exigem
   sessão (não estão em `rotas-publicas.php`) -- a guarda de sessão, logo abaixo, desviava o POST anônimo de outro
   site para o `AutenticacaoController::entrar`, que faz login com o `login` e a `senha` do corpo. A sentinela do
   teste e do portão ficava acima da guarda e não via a troca. Medido por HTTP com o conserto arrancado: o
   formulário de outro site com as credenciais do contrato a `/api/docs` respondeu 200 e ENTROU. **Conserto:** só
   fica de fora a rota que a guarda deixa passar sem sessão (`RotaPublica::exigeSessao`, a pergunta da própria
   guarda, e não uma cópia); a que exige sessão é do ERP para a barreira, de qualquer módulo -- a rota nova de fora
   que nascer sem linha em `rotas-publicas.php` também. A `/adm/api` inteira é pública na guarda, e não mudou. A
   guarda é o único `setParam('controller')` fora do `vendor/`.
3. **O arnês em processo dava a todo controller o curinga do módulo** -- o `isapi-admin/default`, que nem existe,
   para o `AplicativoController`: latente (nenhum teste despachava o `ISApi` em processo), mas o primeiro que o
   fizesse com os cabeçalhos do aplicativo tomaria 403 onde a aplicação deixa passar. **Conserto:**
   `PelaEntradaDoErp::rotaPelaQualSeChega` deriva da configuração a rota própria do controller e da ação (a do
   `ISApi`, a da `/adm/api`, o "Sair"), e só sem ela o curinga do módulo -- que tem de existir no roteador, ou o
   arnês diz que não sabe, em vez de inventar uma rota. Medido nas 496 ações públicas dos controllers dos oito
   módulos: nenhuma sem rota. A ação também vai na rota, como o roteador a poria.
4. **A recusa da janela de privilégios deixou de ser sempre JSON:** a primeira versão só dava JSON a quem mandava
   `X-Requested-With` ou pedia JSON, e o teste da janela passou a mandar o cabeçalho -- o contrato antigo (JSON a
   qualquer pedido, o `JsonModel` do `recusaDeOrigem`) deixou de ser exercido. Nenhum chamador legítimo quebrava
   (o `$.ajax` da janela manda o cabeçalho), mas a forma era decidida pelo pedido, e o pedido do item dizia "as
   ações JSON do ERP recebem JSON". **Conserto:** JSON também quando a ação responde JSON, lido no código dela (o
   método que nomeia `JsonModel` fora de comentário), só na recusa; o controller vem pela classe da rota ou pelo
   apelido do curinga, resolvido pelos apelidos que os módulos declaram (o `getControllerConfig()` de cada um --
   pelo método, como o `ServiceListener` os lê: só o `ISConfiguracao` declara a interface), sem instanciar nada. O
   teste da janela voltou ao contrato antigo, sem o cabeçalho. Os cinco botões de página seguem em texto
   (`text/plain`; antes da barreira da entrada era o `text/html` padrão, com a mesma frase).
5. **O registro dizia que o caso 19 roda "no corredor"**, e o corredor não roda ensaio HTTP -- corrigido acima.
6. **O cliente do caso 19 é também o `CLIENTE_IRMAO_A` do `ensaiar-rotas-publicas-do-erp.php`**, que troca o
   telefone e o `status` e devolve o que leu; o comentário dizia que nenhum outro ensaio o lia, e o caso não o
   devolvia se saísse no meio: a repetição da saída 2 (a regra da casa) ficava vermelha com "cliente inativo
   antes" até remontar o banco. **Conserto:** o caso começa pondo o cliente ativo (pelo POST sem cabeçalho, o do
   `curl`) e, se sair no meio, o devolve ativo no encerramento do PHP (`devolverOClienteAtivo`, por
   `register_shutdown_function`: o `exit` dos ajudantes não passa por `finally`); o comentário foi corrigido.

**Provado (a revisão):**
- `BarreiraDeOrigemNaEntradaTest`, agora 233 execuções (eram 202), sem banco: as cinco escritas do `ISApi` com o
  cookie da sessão, de outro subdomínio, de outro site e com o `Origin` de outro host, são 403 em JSON, e da
  mesma origem passam (15); as oito `/api/docs*`, derivadas do `module.config.php` do `ISApi` e da pergunta da
  guarda, são 403; numa segunda aplicação, com a sentinela logo ABAIXO da guarda de sessão (99), o POST anônimo
  de outro site que a barreira deixa passar chega ao despacho com o controller da rota, e não com o
  `AutenticacaoController` -- e o controle do arnês, a rota do ERP da mesma origem, cai no `entrar`; em todas as
  rotas da configuração, nada de fora exige sessão, e com o cookie o `ISApi` inteiro fica sob a barreira; a ação
  JSON recebe JSON sem o cabeçalho, e o `$.ajax` recebe JSON numa ação de página.
- `PelaEntradaDoErpTest` (novo, 8 execuções): a rota do arnês é a que o roteador casa para o aplicativo, o cron,
  a `/adm/api`, o "Sair", a janela de privilégios e o desativar cliente; e o aplicativo de outro site passa pelo
  arnês como pela aplicação.
- Os dois bancos, em série: todos os testes que despacham pelo arnês (os 52 do `ControllerEmProcesso`, o D315 e
  os privilégios), o teste do MVC, o novo do arnês e os vizinhos de antes -- 1192 execuções verdes no MySQL e no
  PostgreSQL, 3 puladas por pedirem o histórico da produção.
- Por HTTP, contra o banco de contrato, os casos novos do `ensaiar-sessao-do-erp.php`, 21 de 21 no MySQL e no
  PostgreSQL. **Caso 20:** o desmarcar do aplicativo, com o cookie da sessão, de outro subdomínio e só com o
  `Origin` de outro host, 403 com a frase; o controle, da mesma origem, chega à ação autorizado ("ID inválido",
  o id é 0 e nada se apaga); como o aplicativo, de outro site sem o cookie, o 401 de sempre. **Caso 21:** o
  formulário de outro site com `login` e `senha` a `/api/docs`, 403 e login nenhum; o controle, da mesma origem,
  cai no `entrar` e entra -- o desvio é de verdade. **E o caso 19 que sai no meio:** com um `exit(2)` logo depois
  de a mesma origem desativar o cliente, o encerramento o devolveu ativo (`status` 1 no banco); com o
  encerramento arrancado ficou inativo (0), e a rodada seguinte sem o "pôr ativo" do começo ficou vermelha com
  "cliente inativo antes" -- e, de novo com ele deixado inativo, a rodada sem mutante passou (21 de 21) e o
  devolveu ativo.
- O portão: 233 rotas, 43 sob a barreira (as 35 do ERP e as 8 `/api/docs*`), 65 do aplicativo (sem o cookie),
  125 da `/adm/api`, 0 exceções; 163 POSTs de outro site pela aplicação montada, e mais 59 de outro subdomínio com
  o cookie da sessão às rotas do aplicativo, todos 403; EXIT 0.
- Mutantes da revisão, um de cada vez, restaurados e conferidos pelo sha256:

  | Mutante | Vermelho |
  |---|---|
  | O `ISApi` de fora mesmo com o cookie da sessão do ERP (o achado 1) | o teste do MVC (17: as 15 escritas com a sessão, a regra do módulo e a regra em todas as rotas); o do arnês (1); o portão, EXIT 1 (as rotas do `ISApi`, com o cookie); por HTTP, o caso 20 -- de outro subdomínio, 200 com a sessão --, 20 de 21 |
  | De fora mesmo exigindo sessão, sem a pergunta da guarda (o achado 2) | o teste do MVC (4: as `/api/docs*`, a guarda de verdade, a regra em todas as rotas, a regra do módulo); o portão, EXIT 1 (as oito, "fica de fora e exige sessão"); por HTTP, o caso 21 -- de outro site, 200 e ENTROU --, 20 de 21 |
  | O arnês de volta ao curinga do módulo para todo controller (o achado 3) | o do arnês (6: cinco rotas e o aplicativo de outro site) e o `testAEscritaDeOutraOrigemEBarradaAntesDaAcao` (a `/adm/api` pelo curinga cai na barreira do ERP) |
  | A forma da recusa só pelo pedido (o achado 4) | o teste do MVC (25), a janela de privilégios (3) e o do arnês (1) |
  | O caso 19 sem o "pôr ativo" do começo, com o cliente deixado inativo (o achado 6) | o caso 19, "cliente inativo antes", 20 de 21 |
  | O caso 19 saindo no meio sem o encerramento (o achado 6) | o cliente fica inativo no banco de contrato (`status` 0); com o encerramento, 1 |

**Não visto no navegador.** O que se mediu foi o `curl` com os cabeçalhos que o navegador manda, por HTTP e pelo
proxy; nenhum clique foi dado, e o login do contrato não foi feito pela tela nova.

**O que fica:**
- o caminho do D127 -- o débito segue PARCIAL só por ele;
- a barreira olha os verbos que escrevem; o GET que escreve é outra família (a do D199 e do D260), e não muda
  aqui;
- o navegador sem `Sec-Fetch-Site` (o Safari antes do 16.4, o Android muito velho) cai na comparação do
  `Origin` com o `Host`. Atrás de um proxy que reescreva o `Host`, a gravação dele no ERP inteiro seria 403 --
  antes, só nos cinco botões e na `/adm/api`. Os modelos de homologação e de teste passam `Host $host`
  (`docker/nginx/*-host.conf.*`); o servidor da produção não está no repositório, e não foi medido. Conferir no
  primeiro deploy: um POST do ERP por um navegador desses, ou o `Host` que chega ao PHP;
- a lista de exceções nasce vazia pelo que o dono sabe: uma página de fora que ninguém conheça e que mande
  formulário ao ERP passa a receber 403 com a frase -- o remédio é uma linha em `rotas-de-outra-origem.php`;
- (a revisão) um cliente web do aplicativo ou da API v4 servido de OUTRO SUBDOMÍNIO do mesmo site que mande
  `credentials: 'include'` com a sessão do ERP aberta no mesmo navegador passaria a tomar 403 -- nenhum é
  conhecido: o aplicativo é o `capacitor://`, a tela nova é da mesma origem, e o `fetch` não manda cookie a outra
  origem sem o `include`;
- (a revisão) o recorte por unidade das três rotas antigas do aplicativo continua com o dono, na pergunta do
  D237: a barreira fecha o disparo de outra origem, e não quem, da mesma origem, desmarca em outra franquia.

#### 12/10/2026 — O CAMINHO DO D127 FECHOU

O que deixava este débito PARCIAL era só o caminho do D127, e a resposta 105 do dono o decidiu: a volta é só para quem
entrou. Os efeitos deste registro nesse caminho -- o "Sair" que apagava o token da pessoa da rota, a regra pelo par dela,
a sessão sem token que não era conferida -- não acontecem mais: a sessão nunca troca de pessoa sem passar pela volta.
O conserto, a prova e os mutantes estão no D127. O "sem token guardado, a volta adota o do banco" ficou como defesa: só o
caminho do D127 produzia a sessão de quem entrou sem token.


### D316 — As outras escritas do PDV antigo também não leem a situação da venda: acrescentar item, lançar pagamento, finalizar de novo, somar débitos e autorizar o fiado numa venda concluída — CORRIGIDO em 01/10/2026 — CORRIGIDO em 06/10/2026

**Achado pela frente do D313, em 01/10/2026, e confirmado pelo verificador dela; lido no código, não
medido por HTTP.** O D313 fechou o remover do item e o do pagamento. As irmãs, nos mesmos dois
controllers, conferem a unidade e não a situação -- um POST escrito à mão com o id de uma venda
concluída ou cancelada da própria unidade passa:

- `VendasProdutosController::adicionarAction`, com o `venda_id`, acrescenta o item -- e
  `VendaProdutoService::insert` apaga em seguida **todos os pagamentos** da venda.
- `VendasPagamentosController::adicionarAction` lança um pagamento (`validarVenda` só confere a
  unidade).
- `finalizarAction` finaliza de novo: o serviço só recusa o caixa de outro dia, e a segunda chamada
  debita outra vez o crédito do cliente e o do programa de fidelidade usados nos pagamentos. Se um
  duplo clique chega a isso pela tela, não foi medido.
- `pagarDebitosAction` soma os débitos do cliente ao total de uma venda já fechada.
- `autorizarVendaFiadoAction` grava `autorizado_por` e `autorizado_em` por cima do de quem autorizou de
  verdade (o D86).

**O conserto é o do D313, sem decisão nova:** recusar, depois da unidade, a venda que
`RegraDaVendaNoPdv::editavel` não aceita. Antes de fechar: derivar a lista das escritas do
`module.config.php` e do `mod-vendas.js`, e não de memória (o D201 ficou com rotas abertas assim), e
medir se algum fluxo legítimo chama uma delas com a venda já concluída.

#### CORRIGIDO em 01/10/2026

As cinco escritas passam a ler a situação da venda depois da unidade e antes de gravar qualquer
coisa, pela regra do D313 (`RegraDaVendaNoPdv::editavel`): só a venda em andamento (`status` nulo) e
a comanda aberta (3). **A lista foi derivada, e não lembrada** -- um agente que só lia mapeou as 19
ações das rotas curinga de `ISVenda` (`module.config.php` e `Module.php`), os serviços, o
`mod-vendas.js` inteiro e as telas que o chamam, e os setters de `Venda` nos outros módulos; um
verificador refez a lista do zero e chegou às mesmas cinco. Das escritas que recebem o id de uma
venda, só estas não liam o estado; o cancelamento recebe a venda fechada de propósito (é o fluxo da
tela), e as outras escolhem a venda por consulta que só acha nulo ou 3.

- **Acrescentar item** (`VendasProdutosController::adicionarAction`): a guarda vale para a venda do
  `venda_id`, e vem antes do `atualizarCliente`, que já grava. Sem `venda_id` a venda é nova, e passa.
- **Lançar pagamento** (`validarDados`) e **somar débitos** (`pagarDebitosAction`): `sucesso: false` e
  a frase, como o JavaScript já mostra.
- **Finalizar** (`validarFinalizarVenda`) e **autorizar o fiado** (`autorizarVendaFiadoAction`): recusam
  antes de tudo o mais -- antes do serviço que debita o crédito e da mensagem de sucesso -- com a
  mesma frase e o código: *"Esta venda já foi finalizada ou cancelada, código: N."* No fiado, o
  reclique entra pela autorização, e não pelo finalizar (o "Confimar" chama a autorização primeiro e
  só encadeia o finalizar no sucesso); a primeira versão dava ali uma frase sem o código, e o
  verificador dos caminhos apontou. A autorização sem `venda_id` (o cache do D86) continua.

A venda de outra franquia recebe a mesma resposta de antes, que não conta em que estado ela está.

**O finalizar repetido não era só POST escrito à mão: a tela fazia isso** -- é o D321, com as
perguntas. Esse clique agora recebe a frase com o código, e o banco fica como está.

**Provado:**
- `D316EscritasEmVendaFechadaTest` (28 casos, nos dois bancos): em cada ação, a concluída e a
  cancelada recusam com o retrato do banco intacto -- a venda inteira (com o cliente), os itens, os
  pagamentos, o crédito do cliente, o caixa, os débitos e os pontos; a venda em andamento e a comanda
  passam; a concluída de outra franquia recebe a resposta de antes. O controle do finalizar não
  finaliza -- a finalização enfileira mensagens que o agendador manda --: prova a passagem pela guarda
  chegando à recusa do troco, que mora depois dela.
- Mutantes, todos vermelhos: cada guarda arrancada (dois casos cada, no retrato do banco; a do
  finalizar mostra o defeito inteiro -- o crédito do cliente de 100,00 a 0,00, o caixa somado, uma
  linha de pontos e a data da venda regravada); a regra devolvendo `true` (15 casos, com os do D313);
  a guarda do item lida antes da unidade; e a guarda do item movida para depois do `atualizarCliente`
  -- que o primeiro teste deixava verde, e o verificador das provas achou: entrou o caso da venda
  concluída sem cliente.
- `conferir-recorte-por-unidade.php` vigia as cinco chamadas e as duas dos ajudantes de validação (149
  entradas); falsificado numa cópia fora do repositório, oito vermelhos.
- **No navegador**, contra o banco de contrato e como o gerente de fixture, pela tela do PDV: dois
  itens (o primeiro abre a venda, o segundo passa pela guarda com o `venda_id`), o pagamento e a
  finalização -- a venda fechou com o caixa somado uma vez. O mesmo `finalizar` mandado de novo, como
  uma aba antiga, voltou com a frase e o código, e o caixa, os pontos e a data da venda ficaram como
  estavam.
- Três verificadores independentes (os caminhos, o fluxo legítimo, as provas): o do fluxo seguiu o
  caixa passo a passo -- a venda nova, os pagamentos, os débitos, o fiado, a primeira visita, o
  troco, a comanda e o cancelamento da concluída -- e nenhum passo de quem usa direito é recusado.

**O que ficou:**
- o finalizar repetido pela tela, a corrida e o dado já estragado: o D321;
- o cancelamento que recancela: o D317; a autorização de fiado da API nova: o D318; o `pagarDebitos`
  que soma o valor do pedido: o D319; o finalizar de venda inexistente: o D320;
- ~~o inventário marca `vendas/novo` e `vendas/comanda` como "não muta", e as duas apagam (a venda
  temporária, e os pagamentos da comanda num GET) -- o gerador não vê escrita dentro do repositório;~~
  **CORRIGIDO em 06/10/2026:** o gerador segue a chamada até quem escreve (o D199, "PARCIAL em
  06/10/2026"), e as duas saem `muta = sim`, por `VendaRepository::apagarVendaTemporaria` (a DQL de
  `DELETE`) e por `VendaPagamentoService::apagarPagamentos`. Com o terceiro sinal arrancado do gerador,
  voltam a `nao` -- numa cópia fora do repositório;
- duas abas do mesmo usuário podem deixar duas vendas temporárias (a segunda apaga a primeira, e a
  primeira manda o id apagado, que cria outra) -- lido, não observado: na cópia, as 16 temporárias são
  de 16 usuários. **Passou ao D429 em 06/10/2026** (a revisão do item apontou que nenhum débito o
  tinha assumido, e o "CORRIGIDO" do título ficava desmentido pelo corpo).

### D317 — O cancelamento do ERP aceita a venda já cancelada e a em andamento: recancelar devolve estoque, sessões de pacote e crédito outra vez — CORRIGIDO em 03/10/2026

**Achado pelo mapa do D316, lido no código.** `VendasController::cancelarAction` confere a unidade e a
credencial do gerente, e não a situação. Recancelar uma venda já cancelada (0) devolve o estoque e as
sessões de pacote de novo (`VendaService::cancelar`), e, numa venda de crédito, tira o crédito do
cliente outra vez; cancelar uma venda em andamento (nulo) devolve estoque que nunca saiu. A tela só
oferece o cancelamento na venda concluída (1) e na comanda aberta (3). A API nova já recusa a
cancelada com 409, "Esta venda já está cancelada." (o D213).

**O conserto é o da API, sem decisão nova:** o ERP aceitar só 1 e 3. Não é a regra do D313, que
recusaria justamente a concluída.

#### O conserto (03/10/2026)

`VendasController::cancelarAction` lê a situação depois da unidade e antes do crédito e do serviço,
e só cancela a venda concluída (1) e a comanda aberta (3) -- a regra da API. A cancelada (0) recebe a
frase da API, *"Esta venda já está cancelada."*; a em andamento (nulo), *"Só é possível cancelar uma
venda concluída ou uma comanda aberta."* (a API responde "já está cancelada" também ao nulo, porque o
`(int)` dele é 0, e na venda em andamento isso não é verdade). O `2` que a listagem ainda pinta de
"Ativa" não existe: na cópia há 16 vendas em andamento, 62.509 canceladas, 2.206.728 concluídas, 1
comanda aberta e nenhuma em outro estado (SELECT de 03/10/2026). A de outra franquia recebe a recusa
de antes.

**Provado** em `D317A320RestosDoD316Test`, nos dois bancos: recancelar a cancelada deixa o retrato
inteiro -- a venda e o motivo, o estoque da pomada, as sessões do pacote, o crédito, o caixa, os
débitos e os pontos --; a venda de crédito cancelada não tira o crédito outra vez; a em andamento
continua em andamento. Os controles mostram que o retrato mudaria: a concluída se cancela, com a
pomada de volta ao estoque, a sessão ao pacote e o recebido fora do caixa; a de crédito concluída tira
os R$ 50,00; a comanda aberta se cancela. Mutantes: a guarda arrancada (três vermelhos -- na cancelada,
a pomada de volta ao estoque, as sessões de 3 a 4 e o motivo regravado; o crédito; a em andamento) e a regra
aceitando o nulo (um). O `conferir-recorte-por-unidade.php` ganhou a entrada, e fica vermelho nos
dois.

### D318 — A autorização do fiado da API nova não lê a situação: é a oitava escrita do D163 — CORRIGIDO em 03/10/2026

**Achado pelo mapa do D316, e confirmado pelo verificador dos caminhos.**
`Adm\ComandasController::autorizarFiadoAction` confere só a unidade da comanda e se ela é fiado; numa
comanda já concluída, grava `autorizado_por` e `autorizado_em` por cima de quem autorizou de verdade (o
D86). O D163 fechou as sete escritas da comanda com `status !== 3`; esta ficou de fora.

**O conserto é o do D163:** a mesma recusa, com 409.

#### O conserto (03/10/2026)

`Adm\ComandasController::autorizarFiadoAction` responde **409**, *"Só é possível autorizar o fiado de
uma comanda aberta."*, quando a comanda não está em 3 -- logo depois da unidade e antes do fiado, no
mesmo ponto das sete irmãs. **Provado** em `D317A320RestosDoD316Test`, nos dois bancos: a concluída, a
cancelada e a venda em andamento do PDV antigo recebem o 409 com `autorizado_por` e `autorizado_em` de
quem autorizou; a comanda aberta continua autorizada (200, o gerente gravado); a de outra franquia
continua 404. Mutante: a guarda arrancada, três vermelhos, com `autorizado_por` trocado do colaborador
para o gerente. O portão do recorte não vigia a API nova (as irmãs do D163 também não estão nele): quem
vigia é o teste. Nenhum ensaio chama a autorização numa comanda fechada.

### D319 — O "Quitar" do PDV antigo soma ao total o valor que vem no pedido, sem ler os débitos do cliente — CORRIGIDO em 03/10/2026

**Achado pelo mapa do D316, lido no código.** `VendasPagamentosController::pagarDebitosAction` faz
`valor_total = valor do POST + total atual`. O valor não é lido de `clientes_debitos`: mesmo numa
venda em andamento, um POST escrito à mão infla o total com qualquer número, e o finalizar depois
baixa os débitos do cliente como se tivessem sido somados. A tela manda o valor que ela mesma mostrou.

**O conserto:** o servidor somar os débitos abertos do cliente na unidade, e ignorar o valor do
pedido. Antes, conferir como o finalizar baixa os débitos quando vem `pagarDebitos` (os dois têm de
concordar).

#### O conserto (03/10/2026)

**Conferido antes, como o registro mandava: os dois concordam, e o comportamento certo é um só.** O
finalizar, com `pagarDebitos`, baixa `selecionarDebitosCliente` do cliente da venda na unidade da
sessão -- todos os débitos abertos dele ali. A tela mostra no campo `modfin_debitos`, **desabilitado**,
a soma das mesmas linhas (`selecionarValorDebitoCliente` no `/vendas/novo`,
`selecionarValorDebitosCliente` na comanda, com o mesmo filtro), e manda esse número. Não há quitação
parcial pela tela, nem outro uso do `valor`.

`pagarDebitosAction` soma ao total `selecionarValorDebitosCliente` do cliente da venda na unidade da
sessão, e ignora o `valor` do pedido -- a regra de `Adm\ComandasController::adicionarDebitosAction`.
Sem débito aberto na unidade, a recusa da API, *"Este cliente não tem débitos nesta unidade."*: somar
zero ligava a bandeira do finalizar sem nada a cobrar. A resposta continua com `sucesso` e `valor`,
que o JavaScript usa para redesenhar o total.

**Provado** em `D317A320RestosDoD316Test`, nos dois bancos, com dois débitos abertos (30,00 e 20,00),
um pago e um de outra franquia: o pedido com 0,01 e o com 99.999,00 somam os mesmos 50,00, na venda
em andamento e na comanda; o "Quitar" não baixa nada; sem débito aberto na unidade, a recusa, com o
total intacto; e **a concordância pelo lado do finalizar**: depois do "Quitar", o finalizar de verdade,
com a bandeira que a tela liga (`pagarDebitos = 'true'`), baixa exatamente as duas linhas somadas, a
soma delas é o que o total cresceu, e o débito da outra franquia continua aberto. Mutantes: o total
pelo `valor` do pedido (quatro vermelhos), a soma sem o recorte da unidade (cinco), a recusa do zero
arrancada (um) e o finalizar baixando sem o recorte da unidade (um, o caso que finaliza). O portão do
recorte vigia a soma e o recorte do "Quitar"; o do finalizar, só o teste.

*Revisão de 03/10/2026.* Na primeira versão, o caso da concordância lia as linhas pelo repositório,
com a unidade escrita no teste, e não finalizava -- pelo motivo do D316 (a finalização enfileira
mensagens) --: o finalizar baixando sem a unidade, que fecharia também o débito de R$ 500,00 da outra
franquia, deixava os 19 casos verdes. Agora o caso finaliza: as mensagens enfileiradas são linhas da
transação do teste, que o rollback desfaz, como no `FechamentoVendaTest` e no
`PagamentoTrocoDebitoTest`. E as duas recusas do "Quitar" no `D316EscritasEmVendaFechadaTest` (a
concluída e a cancelada) também passaram a criar o débito de R$ 30,00, como os dois controles: sem
ele, a guarda do D316 arrancada chegava ao D319, que recusava por falta de débito com o total igual, e
o retrato passava vazio -- só a frase ficava vermelha. Com ele, a guarda arrancada leva o total de
100,00 a 130,00, e é o retrato que acusa nas duas.

**O que este conserto não fecha:** o finalizar do ERP continua aceitando `pagarDebitos` sem o
"Quitar" -- a metade do D159 que ficou de fora de propósito --, e o "Quitar" não deixa marca na
venda. É o D380, com a pergunta ao dono -- respondida em 06/10/2026 (pergunta 14: a marca pelo
proxy do D159 e a tela, e o finalizar do ERP passa a recusar sem a soma), implementada em 06/10/2026 -- o
D380, corrigido.

### D320 — Finalizar uma venda que não existe dá erro fatal — CORRIGIDO em 03/10/2026

**Achado pelo mapa do D316, lido no código.** `VendasPagamentosController::validarFinalizarVenda`
acrescenta "Não foi possível localizar a venda." e segue: logo abaixo, `count($venda->getProdutos())`
roda com `$venda` nulo. Um id que não existe derruba o pedido em vez de responder a frase. A guarda do
D316 está no `elseif` da unidade e não lê o nulo.

**O conserto:** responder a frase e parar ali, sem chegar à contagem.

#### O conserto (03/10/2026)

`validarFinalizarVenda` responde *"Não foi possível localizar a venda."* e para ali, antes da
contagem. Vale também para a de outra franquia, que recebia junto as outras perguntas (a do troco
dela). **Provado** em `D317A320RestosDoD316Test`, nos dois bancos: o id que não existe recebe a frase,
sem mensagem de sucesso; a de outra franquia com troco recebe só a frase, com o retrato intacto.
Mutante: a frase de volta à lista -- o erro fatal reaparece (*"Call to a member function
getProdutos() on null"*), e a de fora recebe a frase com a do troco. O portão do recorte vigia o
`return`.

### D321 — A tela do PDV antigo finalizava a mesma venda de novo: 1.154 vendas na cópia, 52 com o débito do fiado em dobro — PERGUNTAS ao dono em 01/10/2026 — PARCIAL em 15/10/2026 — PARCIAL em 07/11/2026 (a fatia 101: o crédito volta no erro do meio; a consulta e a baixa da 86 (a) escritas e provadas -- falta o dono rodá-las na produção, e os já pagos em dobro e os pontos vêm depois, com o número; os primeiros abertos com a cópia já paga foram ao lote)

**Achado pelo mapa do D316, e medido na cópia, só com SELECT.** A guarda do D316 recusa a segunda
finalização; o que ela não desfaz é o que já aconteceu, nem a corrida.

**O que foi medido.**
- **1.154 vendas concluídas, de 857 clientes, têm de 2 a 15 linhas "Somou mais N ponto(s)" do
  comprador** -- frase que só a finalização grava presa à venda: 1.681 finalizações a mais.
- O pico vai de novembro/2025 a abril/2026 (113, 190, 188, 124, 71 e 178 por mês) e cai em maio (17,
  13, 6 e 2 até agosto).
- **Em dinheiro:** 52 vendas com débito duplicado -- 56 débitos a mais, R$ 3.435,80 --, dos quais **20
  continuam abertos (R$ 1.109,50): o cliente deve duas vezes a mesma venda.**
- Pontos: 154.901 a mais, 2.263 linhas ainda ativas. Em 178 vendas, um cupom reemitido por
  finalização, cada um com mensagem enfileirada.
- O intervalo entre as finalizações: até 2 s em 75 vendas, de 3 a 60 s em 792, de 1 min a 1 h em 282.
- A cópia não tem os pagamentos de 2025 em diante, nem log de erro: **o que fazia a finalização
  devolver erro e o operador clicar de novo não foi achado.** A hipótese é uma resposta inválida
  depois do commit; a tela devolve o botão no erro e no `sucesso: false`.

**Por onde entra o reclique.** Na venda paga, pelo "Finalizar"; no fiado, pelo "Confimar" do modal da
autorização, que chama a autorização e só depois o finalizar (37 a 40 das 1.154 eram fiado). Os dois
recebem agora a frase com o código.

**A corrida não é fechada pela guarda.** Dois pedidos quase simultâneos (as 75 de até 2 s) leem a
mesma situação antes do `flush`. No fiado a janela é maior: o "Confimar" volta a ficar clicável antes
de o finalizar responder.

**As perguntas:**
1. **O que a tela faz com a recusa "já finalizada".** (a) deixar como está -- o aviso, e o modal
   aberto; o operador sai pelo menu ou pelo "Limpar"; (b) o JavaScript tratar a recusa como sucesso e
   levar à venda nova, no finalizar e na autorização do fiado; (c) achar o erro que leva ao reclique,
   o que pede o log do servidor de produção (só ler). **Recomendo (b) e (c).**
2. **A corrida.** (a) uma trava na linha da venda dentro do `finalizar` (o molde de
   `DevedorService::quitarSobTrava`) -- o serviço é o mesmo da API nova; (b) só no JavaScript: o
   "Confimar" do fiado volta a ficar clicável apenas no retorno do finalizar; (c) os dois. **Recomendo
   (c).**
3. **O dado já estragado.** Os pontos, os cupons e os débitos em dobro. Medir na produção, só lendo,
   e começar pelos 20 débitos abertos em dobro (R$ 1.109,50 na cópia), que cobram o cliente duas
   vezes. A correção em massa é escrita na produção, e só com a sua ordem.

#### A resposta 85 (a) — 15/10/2026, a fatia 5c (RESPOSTAS-DO-FINALIZAR-E-DA-DEVOLUCAO)

O dono respondeu as perguntas 1 e 2 deste débito pela página do lote (a 85, `finalizar-duas-vezes`), com a opção (a),
*"O servidor fecha a corrida, e a tela antiga sai do modal"*: *"A finalização marca a venda como fechada antes de tudo, numa conta no banco que só um dos dois
pedidos vence (como a baixa das sessões da sua 60), e soma o caixa e o crédito do cliente também por conta no banco, como
o estoque da sua 20: nada sai duas vezes, e nada feito no meio se desfaz. No PDV antigo, a recusa 'já finalizada' leva à
venda nova, como o sucesso, e o confirmar do fiado só volta quando o finalizar responde."*

**A corrida, no banco.** A primeira escrita de `VendaPagamentoService::finalizar` passou a ser
`VendaRepository::fecharParaOFinalizar`: `UPDATE vendas SET status = 1 WHERE id = ? AND status = <o que o pedido
conferiu>` -- o nulo da venda em andamento do PDV, ou o 3 da comanda. Só um de dois pedidos vence: o segundo espera a
linha que o primeiro segura e confere a condição de novo (o MySQL relê a versão confirmada, o PostgreSQL reavalia o
`WHERE` na versão nova), acha zero linhas, e volta sem escrever nada. Vem antes da baixa das sessões do pacote (a 60), que
virou a segunda escrita: dois finalizar da mesma venda, com saldo no pacote para os dois, baixariam duas vezes. O estado de
antes é exato -- a venda em andamento que virou comanda no meio também recusa --, e é ele que volta quando o finalizar
desiste: a recusa das sessões e o erro no meio reabrem a venda (`reabrirDepoisDoFinalizar`), pela mesma pergunta de antes,
agora feita ao próprio finalizar (`gravarComAVendaFechada`: a venda fica fechada se ESTE finalizar a gravou fechada; até
aqui era o `status` do banco, que agora é 1 desde o começo). A conta da fidelização do meio do finalizar
(`selecionarTotalVendasPorClientePeriodo`) passou a excluir a venda que está fechando: ela nunca a contou.

**O caixa e o crédito, no banco** -- o resto do D228 e o D383: o crédito usado, o do programa, o troco que vira crédito e a
comissão de indicação somam por `ClienteCreditoRepository::somarAoSaldo` (`valor = valor + ?`, pela linha que
`selecionarCreditoCliente` escolheu), e o caixa por `CaixaRepository::somarAoSaldo` (`valor_final = COALESCE(valor_final, 0)
+ ?`), na mesma transação da primeira gravação da venda fechada, com o cliente e o caixa travados (o porquê, achado na
verificação da fatia, está no D228). A devolução sob a trava do cliente e a retirada sob a trava do caixa que terminam no
meio ficam.

**As portas.** Quando o serviço recusa, o ERP (`VendasPagamentosController::finalizarAction`) e a API
(`Adm\ComandasController::finalizarAction`) perguntam ao banco se a venda fechou (`VendaRepository::fechadaNoBanco`)
ANTES da pergunta das sessões -- o outro caixa gastou a sessão com esta mesma venda, e a frase das sessões seria falsa. O
ERP responde a frase do D316 com `ja_finalizada` e a guarda como aviso da página seguinte; a API, o 409 *"Esta comanda não
está aberta."* de sempre (a tela nova o mostra como mostra todo 409).

**A tela antiga sai do modal** (`mod-vendas.js`, servido com `?cache=167`): a recusa "já finalizada" -- a da guarda do
D316, o reclique, e a da corrida -- leva à venda nova (ou à lista das comandas, na página da comanda), como o sucesso, no
finalizar e na autorização do fiado; o "Confimar" do fiado só volta quando o finalizar responde.

**Provado** em `D321FinalizarUmaVezSoTest` (26 casos), verde nos dois bancos: a primeira escrita é o `UPDATE` condicional
da venda -- condicionado ao 3 na comanda e ao nulo na venda em andamento -- e a segunda a baixa das sessões; quem chega
depois de o outro fechar não grava nada, na comanda e na venda em andamento; a comanda fecha uma vez, e o vencedor grava
o ponto, o cupom e o cupom do sorteio que o cenário oferece (um sorteio ativo e o cupom do checkout aberto: sem eles, "quem
chega depois não grava sorteio nem cupom" comparava zero com zero); a recusa das sessões e o erro no meio devolvem a venda
em andamento ao nulo, e o erro DEPOIS de a venda ser gravada fechada não a reabre (o fiado quitado e o caixa somado não
saem de novo); as duas portas na corrida dizem a frase do D316 (o ERP com `ja_finalizada`), e não a das sessões; o caixa,
o crédito usado, o troco, a comissão e a compra pelo ERP somam por cima do que outra sessão gravou no meio; a soma do caixa
vai com a entrada da venda no caixa, e a falha dela desfaz as duas; a fidelização não conta a venda que fecha. Seis casos
rodam FORA da transação do teste, com OUTRO PROCESSO (`tests/Caracterizacao/outro-processo-segura-a-linha.php`, que abre a
própria conexão e recebe a senha pela entrada, e não pela linha de comando): o finalizar daqui espera mais de 0,7 s e perde
quando o outro confirma o fechamento, na comanda e na venda em andamento; o fechamento daqui espera e vence quando o outro
desiste; a soma do caixa e a do crédito esperam a retirada e a devolução do outro e somam por cima; e o recálculo do caixa
feito pelo outro no meio do finalizar conta a venda uma vez -- nos dois bancos. No front, `finalizarDaTelaAntiga.teste.ts`
(9 casos, as funções do arquivo servido). Mutantes na tabela da fatia, no roadmap. **O ramo do nulo no MySQL:** sem a
condição do nulo, o `UPDATE` de quem chega depois daria 0 linhas no MySQL (que conta as que mudaram) e 1 no PostgreSQL
(que conta as que casaram); no MySQL a recusa viria por acaso, e quem vigia é o caso que lê o SQL.

**O que fica.** O dado de antes -- os débitos, os pontos e os cupons em dobro das 1.154 vendas -- é a pergunta 86 da
página, respondida (a) (a consulta e a baixa escritas aqui e rodadas pelo dono na produção), fora desta fila. O erro no
meio do finalizar continua deixando o que já gravou antes de a venda ser gravada fechada (o crédito debitado no começo):
sem transação, como o D28 descreve; a venda volta aberta, e o finalizar seguinte debita de novo -- como antes. E a tela
antiga não foi aberta num navegador.

#### O resíduo da suíte — 16/10/2026, a fatia 5c-r (RESIDUO-DO-D321)

O ciclo da fatia 5c saiu vermelho só na caracterização, nos dois bancos, e longe deste débito: o
`RelatorioClientesInjecaoTest` contava 5 clientes na unidade A, onde há 4. **Não era o serviço.** O banco de teste
copiado depois do `D321FinalizarUmaVezSoTest` inteiro e comparado, linha a linha, com o banco recém-montado tinha uma
linha diferente só -- nenhum dos 20 casos dentro da transação do teste deixou nada (o amigo indicado do caso da comissão
não fica) --, e nenhuma porta chama `finalizar` com transação aberta: o `commit` de `gravarComAVendaFechada` é o da
transação que ele mesmo abre. **Era a limpeza do teste.** Dos seis casos fora da transação, o
`testORecalculoDoCaixaNoMeioDoFinalizarNaoContaAVendaDuasVezes` (da verificação da fatia) é o único em que o finalizar
daqui VENCE, e o que ele grava fica confirmado. `foraDaTransacao` apagava o que é preso à venda e devolvia o caixa e o
crédito, mas não o cliente 90001004: a última visita, a unidade dela (90001000, de nulo), o barbeiro e o `hash`, que
`gravarAteAVendaFechada` e `salvarPontuacao` gravam. O relatório de clientes filtra pela unidade da última visita, e o
cliente entrava nele. Medido, nos dois bancos: esse caso sozinho, seguido do relatório, dá os dois vermelhos; os outros 25
casos do arquivo seguidos do relatório, 29 verdes (no MySQL).

**O conserto, no teste** (`D321FinalizarUmaVezSoTest::foraDaTransacao`): as linhas que o finalizar que vence grava fora
da venda -- o cliente, o crédito, o caixa e o pacote (`LINHAS_QUE_O_FINALIZAR_GRAVA`) -- são lidas inteiras antes do caso
e devolvidas no `finally`, também quando o caso falha, coluna a coluna, só as que mudaram, cada valor com o seu tipo (a
booleana do PostgreSQL vem `bool`). E, depois do `finally`, o caso confere que as quatro linhas são as de antes: o
vazamento aparece agora no caso que o deixa, e não num arquivo que roda depois.

**Os irmãos, achados pela mesma régua.** O banco do MySQL depois da suíte inteira, comparado com o recém-montado, tinha
mais uma linha diferente: a `data_alteracao` do caixa 90001000, deixada por dois casos de fora da transação que só
devolviam o saldo -- o do `DevolucaoDeCreditoSobTravaTest` (da 5c: a devolução sai do caixa como uma retirada, e o
recálculo grava as duas colunas) e o do `CaixaSobTravaTest` (de antes, a retirada). Nenhum teste lia a coluna, e por isso
nada ficava vermelho. As duas limpezas devolvem a `data_alteracao` também, e cada caso confere, depois do `finally`, que o
caixa voltou inteiro.

**Provado:** o par `RelatorioClientesInjecaoTest|D321FinalizarUmaVezSoTest` verde nos dois bancos (30 casos), e o banco
do MySQL depois dele igual, linha a linha, ao recém-montado; a caracterização inteira, em série, 3.711 casos no MySQL
(23.250 asserções) e 3.711 no PostgreSQL (23.246), com os 11 pulados de sempre e saída 0 -- e, depois dela, os dois
bancos iguais, linha a linha, aos recém-montados (no PostgreSQL, menos a hora de criação do inquilino, que é a da
montagem). Os
mutantes, um por vez: sem devolver o cliente, o caso do recálculo fica vermelho na conferência (as quatro colunas) e o
relatório nos dois casos, nos dois bancos; sem devolver linha nenhuma, cinco dos seis casos de fora ficam vermelhos no
MySQL (o sexto começa do que o anterior deixou, e não muda nada); e, nos dois irmãos, a limpeza de antes fica vermelha
na conferência do caixa (a `data_alteracao`). **Não provado:** o tipo booleano da devolução -- nenhum caso de
hoje muda uma coluna booleana dessas quatro linhas.

**A verificação da 5c-r achou um caminho em que a limpeza ainda deixava resto.** O caso de fora que caísse antes de
esperar o outro processo (o do crédito, se a soma lançasse antes do `UPDATE` dela) chegava ao `finally` com o outro
segurando a linha e a escrita dele sem COMMIT: a devolução lia sem trava a linha confirmada, igual à de antes, não
gravava nada, e o outro confirmava depois -- o crédito ficava em 70 pelo resto da rodada, e a conferência do fim não
rodava, porque o erro do caso já tinha subido. **O conserto:** o `finally` de `foraDaTransacao` desfaz a transação
daqui (o outro pode estar esperando uma trava dela, como no recálculo), espera os outros processos que o caso não
esperou (`esperarOsQueSobraram`, até 15 s, e depois o processo é terminado) e só então lê e devolve as linhas. O caso
novo `testOCasoDeForaQueFalhaCedoEsperaOOutroProcessoAntesDeDevolverAsLinhas` põe o outro tirando 30 do crédito e cai
antes de esperá-lo; a leitura do fim é com trava, e espera o COMMIT do outro se ele ainda segura a linha: 100 com o
conserto, e 70 sem a espera -- vermelho nos dois bancos (o mutante, restaurado e conferido pelo md5). **Provado de
novo, em série:** os quatro arquivos juntos (`D321FinalizarUmaVezSoTest`, `DevolucaoDeCreditoSobTravaTest`,
`CaixaSobTravaTest` e `RelatorioClientesInjecaoTest`) -- a rodada de antes, de 40 casos, era anterior ao conserto do
`CaixaSobTravaTest` e não o tinha -- 65 casos nos dois bancos (235 asserções no MySQL, 234 no PostgreSQL), saída 0;
o par `RelatorioClientesInjecaoTest|D321FinalizarUmaVezSoTest`, 31 casos nos dois bancos; e a caracterização
inteira, 3.712 casos no MySQL (23.254 asserções) e 3.712 no PostgreSQL (23.250), os 11 pulados de sempre, saída 0 --
e, depois dela, os dois bancos iguais, linha a linha, aos recém-montados (no PostgreSQL, menos a hora de criação do
inquilino). **Não provado:** o limite de 15 s, que nenhum outro processo de hoje alcança.

#### O crédito volta no erro do meio, e a consulta e a baixa dos débitos em dobro — 07/11/2026, a fatia 101 (FINALIZAR-E-CANCELAR-SEM-DINHEIRO-EM-DOBRO)

**O erro no meio do finalizar.** `VendaPagamentoService::desfazerOFechamento` devolve, por conta no banco e com o sinal
trocado, o que o finalizar já tinha gravado antes de gravar a venda fechada: o crédito do cliente e o do programa usados no
pagamento, o troco que virou crédito e o estoque baixado (a lista `desfazerSeMorrer`, cada devolução sozinha: a que falha não
impede as outras). Até aqui ficavam, e o finalizar seguinte da venda reaberta debitava de novo -- contra o "nada sai duas
vezes" da 85 (a). **Provado** em `D321FinalizarUmaVezSoTest`, nos dois bancos, com a trava do cliente que desiste (a falha
forçada no `SELECT ... FOR UPDATE`, como um tempo de espera de trava): a venda volta a nulo, e o crédito (30 debitados e 20
de troco somados) volta a 100, o programa a 50, o estoque da pomada e o caixa ficam como estavam. Mutantes: sem devolver
nada (nos dois bancos), sem o troco, sem o estoque. **A lista é do finalizar que está rodando** (a verificação da fatia):
`finalizar` a esvazia no começo, e o caso `testOErroDoMeioNaoDevolveOQueOFinalizarAnteriorDoMesmoServicoGravou` usa o MESMO
serviço para finalizar a comanda paga com 30 de crédito (vence) e, depois, a venda em andamento que morre na trava do
cliente: o crédito fica em 70. Mutante, nos dois bancos: sem esvaziar a lista, o erro da segunda devolve os 30 da primeira.
**Não provado:** o erro do meio que vem de um `flush` que cai e fecha o gerenciador -- a falha forçada é só a da trava do
cliente que desiste, e nenhum caso mostra as devoluções com o gerenciador fechado.

**A consulta e a baixa da 86 (a)** -- *"Eu escrevo a consulta e a baixa; você roda as duas na produção agora"*:
`scripts/medir-debitos-em-dobro.sql` (só lê: o resumo, a lista venda por venda com cada débito e as quitações que apontam
para ele, e as cópias abertas) e `scripts/baixar-debitos-em-dobro.sql` (fecha só a cópia ainda aberta: `status` falso e a
`data_alteracao`, um instante só; sem venda, caixa, crédito ou pontos; a contagem antes e a lista com a data de antes, o
caminho de volta; idempotente), e os irmãos em `postgres/`. **A cópia** é o débito de uma venda que não é o primeiro dela (o
menor id) e repete o primeiro no cliente, na unidade e no valor; o repetido que difere fica, e é contado. **Provado** em
`DebitosEmDobroTest`, nos dois bancos, com débitos em dobro semeados (sete vendas: as duas abertas, a paga e a aberta, o
primeiro aberto com a cópia paga, três abertas, o débito único, o repetido de outro valor e a venda cancelada): a consulta
conta e lista e não escreve; a baixa fecha as cinco cópias abertas, só elas, com o mesmo instante, sem tocar as vendas; a
segunda vez não muda nada; os dois roteiros só leem ou só marcam `clientes_debitos`. Mutantes: a baixa sem a guarda do
cliente, da unidade e do valor (MySQL), e sem a do primeiro (PostgreSQL). **Ressalva:** o `DebitosEmDobroTest` roda os
roteiros no banco da suíte, comando por comando, pela conexão do PHP (e, no PostgreSQL, sem o `BEGIN`, o `COMMIT` e o `SET`
do inquilino) -- e não no banco de contrato pela linha de comando, como as notas da fatia pediam. **Por isso, na verificação
(07/11/2026), os quatro arquivos rodaram também como o dono os roda**, pelo `mysql` e pelo `psql` (`-v ON_ERROR_STOP=1`),
inteiros, em bancos descartáveis de nome próprio, com os mesmos sete débitos em dobro semeados: no MySQL, `f101_dobro_my`,
montado pelo `scripts/montar-banco-contrato.sh` (o banco de contrato, com outro nome); no PostgreSQL, `f101_dobro_pg`, uma
cópia do banco da suíte (com o `search_path` do banco, `bvip, public`, que a cópia não herda). Nos dois: a consulta deu o
resumo do cabeçalho (6 vendas, 6 cópias, 5 abertas R$ 145,00, 1 fechada, 1 repetido que não é cópia, 1 primeiro aberto com a
cópia fechada) e a lista venda por venda; a baixa contou 5, listou as cinco com a data de antes, fechou as cinco no mesmo
instante e conferiu 0 abertas; a segunda baixa contou 0 e não mudou nada; as vendas ficaram iguais, coluna a coluna. Os dois
bancos foram apagados no fim.

**No franquiabv_dev, só a consulta** (07/11/2026): 52 vendas com débito repetido, 56 cópias; 20 cópias abertas, R$ 1.109,50
(os números de 01/10); 36 fechadas, R$ 2.326,30; nenhum repetido que não seja cópia; e 3 primeiros ainda abertos com a cópia
já fechada (R$ 149,50): a cópia foi paga, e o primeiro segue cobrando. Lido na verificação (só contagem): as 3 vendas estão
finalizadas, e as cópias fechadas delas são 5, todas quitadas por uma venda de quitação (`vendas.debito`) -- isto é, o cliente
já pagou a venda uma vez ou mais, e o primeiro ainda cobra de novo. A baixa não fecha esses 3 -- não são a cópia. **A 86 (a)
não fala deles** (ela fala da cópia aberta, que a baixa fecha, e da cópia já paga, que fica para depois); a primeira leitura
da fatia escreveu que a 86 (a) os deixava para depois, e era uma escolha de dinheiro do cliente feita no lugar do dono. A
pergunta foi ao lote: TELAS-RESTANTES [vendas] (07/11/2026), *"Os débitos em dobro em que o primeiro segue aberto com a
cópia já paga"*. Até a resposta, ficam como estão. A contagem deles é a de ANTES da baixa: depois dela, a cópia que a baixa
fechou parece paga (o cabeçalho manda guardar a saída da primeira consulta).

**Para o dono rodar, agora** (a árvore da produção é a antiga e não tem os arquivos: copiá-los para o servidor):
`mysql franquia_producao < medir-debitos-em-dobro.sql | tee /root/debitos-em-dobro-medir-<data>.txt`, ler o resumo, e então
`mysql franquia_producao < baixar-debitos-em-dobro.sql | tee /root/debitos-em-dobro-baixar-<data>.txt` (a saída guarda a
lista, com a data de antes de cada débito). Os irmãos em `postgres/` são para depois do corte, só se a baixa não tiver rodado
antes no MySQL.

**O que fica:** rodar os dois na produção (do dono); os já pagos em dobro (as 36 cópias fechadas na cópia) e os pontos em
dobro -- *"vêm depois, com o número na mão"* (a 86 (a)); e os primeiros abertos com a cópia paga, pela resposta do lote.

### D322 — As telas da Fase 3 vistas pelas prévias do Claude Design: os defeitos que ficaram — REGISTRADO em 01/10/2026 — PARCIAL em 11/10/2026: 42 dos 44 consertados; ficam os dois do FecharAgenda e os cinco de desenho, do dono — PARCIAL em 02/11/2026: a Cota-parte em dobro do menu saiu na raiz (a CP2 fundiu os dois itens); o resto segue

**Achado pela sincronização com o Claude Design de 01/10/2026**, que escreveu prévia para as 25
telas novas, as 10 peças novas da agenda e as 8 da comanda, com dados realistas, e as fotografou
uma a uma (`frontend/.design-sync/NOTES.md`, a seção daquela data). Quatro defeitos foram
consertados na mesma rodada, com a aprovação do dono: os campos de busca e de filtro crus do
navegador em oito telas, a caixa e o rádio no azul do navegador, e os dois cortes de texto da
agenda (a linha do tempo e o bloco de 15 minutos). Outros cinco saíram na nota final (abaixo).
Ficou o que segue, lido nas capturas e no código.

**Sem decisão nova -- consertos de tela:**

- **O "09:00" da grade da agenda** fica com a metade de cima sob o canto preso: a hora é centrada
  na própria linha, e a primeira hora cheia cai na borda. A linha do tempo já tem o conserto
  (`Agenda.tsx`, `h - inicio < 20 ? { transform: 'none' }`); a grade não.
- **O texto dos campos das janelas da agenda sai em 0,72rem** -- `.agenda-fechar input`,
  `.agenda-remarcar select` e `.agenda-marcar input/textarea` herdam o tamanho do rótulo em caixa
  alta (App.css, perto das linhas 1028-1048 e 1098). **Mexe no `FecharAgenda`, que o
  `templates/agenda/` do dono usa** -- o conserto pede que ele veja o template.
- **"buscar" grudado no campo** na lista de comandas: o `gap` da `.tela-busca` está no `<form>`, que
  tem um filho só, o `<label>` (`App.css` ~1644, `Comanda.tsx` ~310-322).
- **O catálogo da comanda com os produtos em falha** mostra também "Nada com “” em todo o
  catálogo." (`LancarItem.tsx` ~701-705), e **carregando** mostra a pílula "Tudo 0" (~686), que se
  lê como zero itens.
- **A falha de rede diz "não foi possível" duas vezes** ("não foi possível carregar produtos: Não
  foi possível falar com o servidor." -- `Erro.tsx` ~163 mais a frase de `erroDe`).
- **O desconto do lançamento segue "· opcional"** com o desconto preenchido e o motivo marcado
  obrigatório (`LancarItem.tsx` ~515).
- **A frase do cancelamento de comanda fala sempre dos pagamentos**, mesmo sem nenhum ("Nenhum item e
  os pagamentos lançados ficam registrados como cancelados" -- `DetalheDaComanda.tsx` ~753).
- **A tela não migrada fora do menu diz "quando ERP for migrado"**: o grupo de reserva entra na
  frase (`Console.tsx` ~138 e ~217-219).
- **O tema do Console é efeito no `<html>`**: sem `temaInicial` ele APAGA o `data-tema` da raiz
  (`Console.tsx` ~587-592).
- **O "tentar de novo" aparece no 403 do caixa**, onde repetir não resolve.
- **Na busca de cupons, o seletor "Emite" corta "em qualquer momento"** a 900px: a grade
  `repeat(auto-fit, minmax(9rem, 1fr))` da `.catalogo-busca` não deixa trilha vazia.
- **O campo novo (a regra de 01/10/2026) não tem estado de desligado nem de recusado**: a data
  desligada parece ligada, e o campo recusado da credencial do gerente não ganha a borda
  `--perigo` (só `.clientes-campos [aria-invalid='true']` a tem).
- **"O que mudar aqui vale a partir do próximo login"** aparece também para quem só consulta os
  privilégios, a quem nada se deixa mudar.
- **O anel do foco do título** (`outline-offset: -2px`) passa por cima da última letra em títulos
  longos (pacotes, vendas).
- **O erro genérico** põe o código (`nao_encontrado`) em negrito antes da frase; o de rede põe a
  frase primeiro.
- **O catálogo de origens do ERP grafa "WhastApp"** (`Arrays::clientesOrigem`).

**Pedem decisão do dono -- desenho:**

1. **Dois botões em ouro na mesma tela** -- "editar" e "acrescentar" nos pacotes, "cadastrar ...
   novo" e "buscar" nos convênios e cupons. A regra da casa é um por tela.
2. **"Dias trabalhados" em queda sai em vermelho** no painel, e a queda é só do calendário. Os
   descontos e as cortesias já são neutros; os dias talvez devessem ser.
3. **O ticket de quem não faz serviço** (a caixa, que lança o café: R$ 3,66 de ticket) -- se o
   painel o mostra.
4. ~~**A fidelização "62,7% · 131 de 209"** quase encosta no rótulo vizinho~~ -- na nota final
   ela já INVADIA o rótulo a 1280px ("131 de 209Faturamento por dia"), e isso não é decisão:
   o valor do cartão passou a quebrar (`.painel-colaborador-numeros dd`). Fica para o dono só
   se o cartão deve mostrar a conta inteira ou só a porcentagem.
5. **Os estados que abrem por clique** (receber o fiado, comprar/devolver/resgatar, cancelar
   pacote, a oferta de dependente, os resultados da nova comanda) não têm prop que os force, e por
   isso não têm cartão no Claude Design. Uma prop opcional de estado inicial os deixaria desenhar.

**A nota final achou mais (01/10/2026).** Oito agentes releram as 59 folhas depois do fork do
conversor. Cinco defeitos sem decisão foram consertados na hora, porque reprovavam célula ou se
viam em várias: o cabeçalho da `ColunaDoBarbeiro` sozinha colava o nome nos números ("Anderson5 ·
71%"); o telefone do `ResumoDoAgendamento` saía na borda direita (o `.num`); a fidelização do
Profissional Parceiro invadia o rótulo vizinho; "Aberta às" era o único cabeçalho à esquerda numa
coluna de valores; e a movimentação cancelada do estoque saía em `--tinta-2`, a cor do próprio
`body` (agora `--tinta-3`, como no caixa). O gerente dos dados de exemplo levava o nome do dono, e
passou a fictício.

**Sem decisão nova -- os que a nota final achou e ficaram:**

- **O botão "abrir caixa" desce** para a linha da frase de recusa e se desalinha do campo
  (`.lancar { align-items: flex-end }`, App.css ~292) -- `AbrirCaixa`, e a `TelaDoCaixa` quando a
  abertura é recusada.
- **"nenhuma forma de pagamento cadastrado"**: a lista vazia monta `{vazio} cadastrado nesta
  unidade`, e o gênero do `vazio` não entra (`Comandas.tsx` ~171).
- **"Esta comanda tem 2 pagamentos de R$ 50,00"** se lê como R$ 50,00 cada; o número é o total
  (`Comandas.tsx` ~53-55). "2 pagamentos, somando R$ 50,00".
- **A lista da busca do lançamento esconde o oitavo item** sem sinal de que rola, e o contador do
  grupo não bate com o que se vê (`.combo-lista { max-height: 20rem }`, App.css ~2044).
- **O "89,90" do Receber é `placeholder` fixo** e parece um valor -- aparece até em comanda paga
  (`Comandas.tsx` ~183). O catálogo usa "ex.: 89,90".
- **"precisa autorizar aqui"** aparece também para quem não pode autorizar e não tem o botão
  (`DetalheDaComanda.tsx` ~565).
- **Os cartões "Crédito e pontos" e "Pacotes" da fidelidade, carregando**, ficam sem espaço
  embaixo e o texto encosta na borda (`.fidelidade-estado p` e `.cartao-titulo`, padding-bottom 0).
- **A mesma unidade com dois nomes na mesma tela**: o cadastro diz "VIP Centro", a fidelidade
  diz "Joinville – Centro" (`nomeDaUnidade`, `FidelidadeDoCliente.tsx` ~97-102).
- **"Clientes historico"**, sem acento, na lista de privilégios -- vem do nome do catálogo.
- **A `MolduraDoErp` e a `Home` não recebem nem repassam `guardas`** ao `MenuPrincipal`, ao
  contrário do Console: para quem tem privilégios, o Profissional Parceiro e a Cota-parte somem e
  o pé do menu diz "3 destinos ficaram de fora". As duas só existem para o design.
- **O "livres agora" da `Home` conta os livres do dia inteiro**, inclusive os que já passaram.
- **Sem `privilegios`, o menu mostra duas "Cota-parte"** em Relatórios, com o mesmo rótulo.
- **Na legenda da agenda, "agendado" e "atendido" têm o mesmo fundo** e só diferem no tom da
  borda (App.css ~828 e ~845).
- **A sombra "não cabe" da grade é opaca** e esconde o bloco que causa o conflito.
- **A linha do agora passa por cima do texto dos blocos** (`.agenda-agora`, `z-index: 3`): às
  10:40 ela corta o selo "EM ATENDIMENTO".

**A segunda nota final achou ainda (01/10/2026)**, já com a descrição de cada célula chegando ao
agente de design. A quebra da fidelização foi refeita: quebrar em qualquer espaço separava o "R$"
do número e fazia o painel de agosto passar do teto da captura, e agora o valor só quebra no "·";
e o "o seu: R$ 27.832,40" do melhor da rede, que partia entre o "R$" e o número, vai inteiro.
Sem decisão nova:

- **O valor encostado na borda direita** no cartão de cadastro do produto ("Estoque 12") e no do
  pacote ("Valor R$ 290,00"): `<dd className="num dado">` em `dl.identificacao` (`Catalogo.tsx`
  ~1385, ~1393, ~1426; `Pacotes.tsx` ~943) -- o mesmo do telefone do resumo, consertado.
- **O placeholder "89,90" também no valor do gerente** do `LancarPelaBusca` (`LancarItem.tsx` ~403),
  além do Receber.
- **O texto do campo do cliente da nova comanda sai em 0,8rem**: `.nova-comanda-busca input` com
  `font: inherit` herda o tamanho do rótulo (App.css ~2270 e ~2298) -- a família do defeito das
  janelas da agenda.
- **"Ao finalizar, o sistema:" termina em ";"** quando não há fiado: a pontuação foi escrita para a
  quarta linha, que é condicional (`Comandas.tsx` ~425-426).
- **O "tentar de novo" no 403 também na lista de clientes** (`Clientes.tsx` ~283-296).
- **O mesmo telefone com duas formas**: "(47) 99230-5517" no fiado, "+55 (47) 99230-5517" na lista de
  clientes.
- **No painel do caixa**, as linhas de "Recebido por forma" e o cabeçalho das retiradas começam
  5px à esquerda do título do cartão; e "Retiradas" mostra "− R$ 0,00", o sinal com o zero.
- **`.usuario-gravacao { min-height: 1.9rem }`** reserva uma faixa vazia de ~30px entre as pílulas e a
  tabela também onde nada se grava (quem só consulta, os próprios privilégios).
- **A `Home` passa da tela**: a grade tem `max-height: max(18rem, calc(100svh - 10rem))`, e o
  cumprimento e os quatro números da Home ficam fora da conta -- a página rola junto com a grade
  (~220px a mais no dia de férias, em qualquer altura).
- **No `FecharAgenda`, a força dos avisos está invertida**: a recusa que trava o botão (sem motivo)
  sai em `.aviso`, âmbar; o alerta que não trava nada ("Há 37 agendamentos neste período") sai em
  `.aviso-forte`, vermelho (`Agenda.tsx` ~2385). **Mexe no componente que o `templates/agenda/` do
  dono usa.**
- **Os selos do `BlocoNaGrade` quebram linha em coluna estreita**: com o bloco em ~187px, o "C" do
  convênio desce sozinho e empurra o nome.
- **O campo de data da navegação da agenda** tem borda `--linha` e canto de 3px; os outros campos,
  `--linha-forte` e `--r`.
- **Na `FormaAgora`, o cartão "livre" tocado perde a borda verde**, e o rótulo "LIVRE" continua verde.

**O conserto (11/10/2026, o item D322-CONSERTOS da fila).** Dos 44 defeitos das três listas "sem decisão nova", 42
foram consertados; os dois do `FecharAgenda` ficam (abaixo), e os cinco de desenho continuam com o dono. Lido cada um no
código antes: todos seguiam de pé, menos o do telefone, que era só do dado das prévias.

Os que mudaram texto ou estrutura, cada um com caso no vitest:

- **A primeira hora da grade** desce para baixo da linha quando falta meia altura dela acima (`(h - inicio) * px < 10`,
  `GradeDaAgenda`), como a linha do tempo já fazia.
- **"buscar"** ganhou o espaço: a `.tela-busca` passou do `<form>` para o `<label>` (`Comanda.tsx`), como no Console.
- **O catálogo da comanda** não diz mais "Nada com “”" com a lista em falha (nem com filtro digitado), e sem filtro diz
  "Nenhum item ativo no catálogo." ou nada, quando a unidade não tem produto (a `FalhaDeLista` já diz); a pílula "Tudo"
  fica sem número carregando e em falha (`LancarItem.tsx`).
- **A falha de rede** diz "sem resposta do servidor ao carregar produtos — confira a conexão.", e não "não foi possível"
  duas vezes; **o gênero** do vazio vem de quem chama ("nenhuma forma de pagamento cadastrada"), como o plural já vinha;
  **o erro genérico** põe a frase primeiro e o código pequeno depois, como o de rede (`Erro.tsx`).
- **O desconto preenchido** deixa de ser "· opcional" (`CampoDeDesconto`).
- **O cancelamento da comanda** diz o que fica registrado: itens e pagamentos só quando há, e "A comanda não tem item
  nem pagamento" na vazia (`CancelarComanda`).
- **A tela fora do menu** diz "quando ela for migrada", e não "quando ERP for migrado" (`TelaNaoMigrada`).
- **O tema do Console** só apaga da raiz o `data-tema` que ele mesmo gravou: ao montar sem escolha, ele apagava o de
  quem montou a página (o canvas). No app nada muda.
- **O "tentar de novo" no 403** some do caixa e da lista de clientes -- e, derivado do grep, e não da memória, dos dez
  irmãos com a mesma peça no erro da tela inteira: agenda, catálogo, estoque, convênios, cupons, devedores, pacotes,
  usuários, vendas realizadas e painel (o padrão do Profissional Parceiro, que já não o oferecia). Os "tentar de novo"
  de dentro dos cartões (os catálogos dos formulários do catálogo e do cadastro, as unidades e os produtos do cupom, a fidelidade, os
  privilégios, os clientes do painel, os da agenda depois da grade) não estavam nas listas, e ficaram: o D472.
- **"O que mudar aqui vale a partir do próximo login"** e a faixa da gravação (o `min-height` da `.usuario-gravacao`)
  saem para quem só consulta e nos próprios privilégios (`Usuarios.tsx`).
- **"WhastApp"**: o rótulo da lista do ERP passou a "WhatsApp", com a chave de sempre (`Arrays::clientesOrigem`; 585
  cadastros a têm na cópia); a tela nova recebe as chaves e troca o rótulo do mesmo jeito (`rotuloDaOrigem`), na lista e
  no cadastro aberto, e o `PUT` devolve a chave.
- **"abrir caixa"** fica na linha do campo, e a mensagem embaixo dos dois (`AbrirCaixa`); a recusa da ação ganha linha
  própria; **"Retiradas"** sem retirada diz "R$ 0,00", sem o sinal.
- **"2 pagamentos, somando R$ 50,00"** (`AvisoApagaPagamentos`); **o "ex.: 89,90"** no Receber e no valor do gerente;
  **"Ao finalizar, o sistema:"** termina em ponto quando não há fiado; **"precisa autorizar aqui"** só para quem tem o
  formulário.
- **A unidade na fidelidade** passou ao nome do cadastro ("VIP Centro"), como no cartão do cliente, nos pacotes e nos
  usuários; a cidade e o bairro ficam para a unidade sem nome. Medido na cópia: as 64 unidades têm nome, e nenhum par de
  ativas o repete.
- **"Clientes historico"** e os outros dois nomes gravados sem acento ("Marketing notificacoes", "Configurações app
  configuraçoes") se leem com ele na tela dos privilégios, na linha, no nome da caixa e na frase da gravação
  (`nomeDaFuncionalidade`). O nome gravado é chave no servidor (`SessaoAcl`, `RegraDoPrivilegio`), e não mudou.
- **A `MolduraDoErp` e a `Home`** recebem e repassam as `guardas`; **sem `privilegios`, uma Cota-parte só** (a tela, a
  forma de quem não é caixa); **os livres da Home** contam, com o `agora` do servidor, só os que não acabaram, e sem ele
  o rótulo diz "livres no dia" -- a Home não sabe a hora.
- **A amostra da legenda** leva letra ("Aa"): o que separa "agendado" de "atendido" na grade é o texto, e a amostra vazia
  só mostrava o tom da borda.
- **O "C" do convênio** foi para o lado do nome do cliente, no bloco: na linha dos selos ele descia sozinho e empurrava o
  nome. A lista do Agora segue com ele entre os selos.
- **O telefone com duas formas** era do dado das prévias, e não do código: a lista e o fiado mostram a mesma coluna
  crua (`cliente.telefone`), e na cópia 242.586 dos 242.597 cadastros a têm na forma "(47) ..."; a prévia da lista tinha
  o "+55" que só o cadastro aberto traz (`telefone.completo`). A prévia foi corrigida.

Os que são só CSS (o vitest não carrega a folha de estilo; vistos no navegador, abaixo): o campo de data da navegação
com a borda e o canto dos outros; o cartão "livre" tocado da `FormaAgora` com a borda verde; a sombra "não cabe" sem
fundo, com o período no dele; a linha do agora por baixo dos blocos (z 1, os blocos z 2, a sombra z 3); o seletor
"Emite" na trilha de 11,5rem (`.cupons-busca`); o campo da regra de 01/10 desligado e recusado (`:disabled` e
`[aria-invalid]`); a folga do anel do título; as tabelas do caixa a partir do título; a sombra de rolagem da lista da
busca; o respiro de baixo dos cartões da fidelidade carregando; o valor à esquerda em `dl.identificacao`; o campo da
nova comanda em 0,92rem; e a altura da grade da Home, com o cabeçalho dela na conta.

**Ficam, e são do dono:** os dois do `FecharAgenda` -- o texto dos campos das janelas da agenda em 0,72rem (o `FecharAgenda`
divide a regra com o `RemarcarAgendamento`, e o Marcar ficou junto, para as três janelas não saírem diferentes) e a
força dos avisos invertida --, porque o `templates/agenda/` do dono usa o componente; e os cinco de desenho, numerados acima. As classes `.agenda-*` que mudaram de valor (a régua,
a legenda, a sombra, a linha do agora, o selo, o campo de data, o cartão) são as que o `templates/agenda/` também lê;
nenhuma saiu nem mudou de nome.

**Provado.** No vitest, casos novos ou acrescidos em 16 arquivos (a comanda, o caixa, o console e o menu, a agenda e a
grade, os clientes, o catálogo e o estoque, os convênios, os cupons, os devedores, os pacotes, os usuários, a
fidelidade, as vendas realizadas, o painel e o `privilegioDaTela`); três casos antigos conferiam o defeito e mudaram
com ele (o cancelamento da fiada dizia "O item e os pagamentos", e a fidelidade, "Joinville – Centro" duas vezes). A
suíte inteira, `--maxWorkers=4`: 1.653 de 1.653, em 44 arquivos; o build e o lint verdes, no piso de antes (dois avisos
em `src`, quatro em `.design-sync`). No PHP, o caso novo do `CadastroDeClienteTest` e os vizinhos que leem os arquivos
mudados (`RegraDoPrivilegioTest`, que lê o `privilegioDaTela.ts`; e os que leem o `layout.phtml`): 160 de 160 no MySQL
das fixtures e 160 de 160 no PostgreSQL, em série.

**Os mutantes, cada um sozinho, restaurado e conferido byte a byte:** 47 no front, um por conserto que o vitest alcança
(a frase do erro, o ramo da rede, o "cadastrado", o "somando", os dois "ex.:", o ponto final, o "opcional", a pílula,
os dois "Nada com", o "aqui", a frase do cancelamento, a `.tela-busca`, a tela fora do menu, o tema, a Cota-parte, as
guardas da moldura e da Home, os livres, os doze "tentar de novo", o sinal das retiradas, a linha do "abrir caixa", os
três da origem, os seis dos privilégios, a unidade da fidelidade, a hora da régua, a letra da amostra e o "C"): os 47
deixaram ao menos um caso vermelho. E dois no PHP (o rótulo de volta a "WhastApp"; a chave trocada junto): os dois
vermelhos.

**No navegador** (as prévias do `/design-sync`, servidas por um Vite temporário e apagado depois, nos dois temas, a
1280, 900, 800 e 375px), medido e não só olhado: a primeira hora da régua começa na borda do canto preso (59px, os dois);
a linha do agora passa por baixo dos blocos -- e **o navegador achou um defeito do conserto**: o "em atendimento" tem
fundo translúcido (`--ok-fraco`), e a linha por baixo aparecia através dele, riscando o período; ele ganhou a superfície
por baixo do verde, e a linha sumiu dali; a sombra "não cabe" deixa ver o Gustavo, e o rótulo dela ganhou recuo próprio
(sem ele, o começo do texto de baixo aparecia ao lado: "1 não cabe"); o "Emite" tem 201px para os 144 da opção mais
longa, contra 149 e 158 antes; o "abrir caixa" alinha com o campo (180px, os dois), e a mensagem vem embaixo; as tabelas
do caixa começam onde o título começa (46px, os dois); a lista da busca mostra a sombra embaixo enquanto há item e a
perde no fim; os cartões da fidelidade carregando têm 11px embaixo; o valor do cadastro do produto alinha à esquerda; o
campo da nova comanda tem 13,8px (o rótulo, 12); o "buscar" tem 6px antes do campo; o campo desligado das vendas
realizadas sai em `--superficie-2`, e **o navegador achou o segundo**: a borda `--perigo` da credencial recusada não
pegava -- o seletor `[aria-invalid]` pesava menos que o da regra do campo --, e passou a repetir o seletor dela; o anel do
título fica 1,7px fora da última letra; e a Home cabe na tela (698 de 700px a 900x700, 798 de 800 a 1280x800; antes,
39px a mais só com a moldura). A 375px, nenhuma das treze células rola de lado, menos a Home: a `MolduraDoErp` não tem a
regra do estreito que o Console tem (o menu de 15rem fica), e isso é de antes e não estava nas listas.

**Não provado:** as telas no app de verdade, com a sessão e os dados da cópia -- vi as prévias, que montam os mesmos
componentes com o mesmo CSS; a agenda antiga num navegador (o D454 e o D226); e o `templates/agenda/` do dono, que lê as
classes `.agenda-*` que mudaram de valor. Com o amplificador a 50 ms, os arquivos mudados dão 29 vermelhos, todos nos
seis do D471 e nenhum caso novo; sozinho, o `ComandasLigada` dá 2 e 2, em casos diferentes, como o D471 mediu.

**A Cota-parte em dobro saiu na raiz (02/11/2026, a fatia 67, REL-COTA-PARTE-TELA; a CP2 do `spec-cota-parte.md`).** O
conserto de 11/10/2026 escondia, no menu sem lista de privilégios, a segunda das duas "Cota-parte" (o filtro `eAFormaQueSobra`
do `Moldura.tsx`). Com a CP2 o item é um só -- a tela nova, guardada pela `relatorioCotaParte`, também para o caixa (no dia de
hoje) -- e o PDF em aba nova do caixa saiu do menu da tela nova com a guarda `vendasExportadas`; o filtro ficou sem par e saiu. O
`menu-do-erp.teste.ts` passou a recusar rótulo repetido em qualquer grupo, e o mutante P67-P7 (os dois itens de volta) fica
vermelho por ele. Os dois do `FecharAgenda` e os cinco de desenho seguem como estavam. PARCIAL.

### D323 — O passo 0 do corte não cobre todas as mudanças de esquema feitas desde o dump de produção — CORRIGIDO em 03/10/2026 — PREPARADO, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está pronto no código, à espera só do passo em produção na janela da migração final. Falta: aplicar as seis migrações do laço do passo 0 na produção, antes do passo 1; quando aplicar a do Líder é decisão do dono (D124).

**Achado pelo levantamento da F4 (o molde e como uma tabela nova entra), e conferido ao escrever a
F4.1.** O passo 1 de `docs/procedimento-de-corte.md` refaz o `mysql.sql` a partir da estrutura de
produção, e é dele que `scripts/gerar-ddl-postgres.php` parte. Toda mudança de esquema feita aqui
depois do dump de 16/08 tem de estar em produção ANTES desse passo -- é o "passo 0" -- ou some do
`schema.sql` e o código que depende dela quebra no primeiro dia.

**O passo 0 lista três migrações**: `migracao-pontuacao-validade-unidade.sql` (D54),
`migracao-venda-autorizacao-fiado.sql` (D86) e, desde 02/10/2026, `migracao-usuarios-horarios.sql`
(a vigência do horário, F4.1). **Faltam, comparando as colunas do `mysql.sql` do dump (3b1f05c) com
o de hoje:**

- a tabela **`clientes_sessoes`** (9d92774, 03/09/2026: o login do aplicativo por código);
- **`pontuacoes_historicos.autorizado_por`** (o resgate autorizado pelo gerente);
- as três colunas **`unidades.hash_anterior*`**.

E `unidades.iframe_bi` saiu do mapeamento (D9) e continua em produção -- essa não quebra nada.

**Não existe a "lista" que o texto de 09/09 cita** ("mais um da lista que espera o
descongelamento"): cada migração ficou registrada no próprio débito, e nenhum portão compara o
`mysql.sql` de hoje com o do dump.

**O conserto:** escrever as migrações que faltam (a tabela e as quatro colunas), pô-las no passo 0, e
um portão que compare as colunas do `mysql.sql` com as do dump de produção versionado e exija uma
linha do passo 0 para cada diferença. **Depende de produção** (congelada desde 23/08): só o dono
aplica, na janela do corte.

#### O conserto, em 03/10/2026 — no repositório; aplicar continua sendo seu, no passo 0

**A lista saiu da comparação, e não da memória.** `git diff 3b1f05c HEAD -- mysql.sql`, sem os CR
do dump, dá 14 diferenças em cinco tabelas: as duas tabelas novas, as sete colunas, os dois índices e
as duas chaves estrangeiras, e a `unidades.iframe_bi` que saiu. As que não tinham migração eram as que
este registro dizia, e passaram a ter:

| Migração nova | O que faz |
|---|---|
| `scripts/migracao-clientes-sessoes.sql` | `CREATE TABLE IF NOT EXISTS clientes_sessoes`, com o DDL do `mysql.sql` |
| `scripts/migracao-pontuacao-resgate-autorizado.sql` | `pontuacoes_historicos.autorizado_por`, o índice e a chave estrangeira (D88) |
| `scripts/migracao-unidade-hash-anterior.sql` | as três `unidades.hash_anterior*`, depois de `hash` (D77) |

**O passo 0 de `docs/procedimento-de-corte.md` passou de três linhas a nove:** as três novas, a do
Líder (`migracao-grupo-lider-gerente.sql`, D124, que é dado e nunca tinha aparecido no
procedimento; **quando** aplicá-la espera o dono, pela revisão abaixo), uma "não aplicar" para a
`sql/sessoes.mysql.sql` (abaixo), e uma "nada a aplicar" para a `iframe_bi` (D9), com o laço que
aplica as seis de estrutura na ordem. (A primeira versão punha a `sessoes` e o Líder no laço, oito
ao todo; a revisão tirou os dois.) As frases das linhas novas dizem o tamanho de cada
falta, e ele era maior do que este registro dizia: a `Unidade` e a `PontuacaoHistorico` mapeiam as
colunas, então sem elas **toda** leitura de unidade e do histórico de pontos falha — não só a rota
que as usa.

**E o portão.** `scripts/conferir-passo-zero-do-corte.php`, no `conferir-tudo.sh`, compara o
`mysql.sql` de hoje com o do dump (lido do git, `3b1f05c`) em tabelas, colunas, índices, restrições e
opções, e exige, para cada diferença, uma migração citada numa linha `| 0 |` que a faça — a coluna
por `ADD COLUMN` com o mesmo tipo e a mesma nulabilidade, dentro do `ALTER TABLE` da tabela dela; a
tabela nova com o mesmo DDL; a removida pelo nome escrito no passo 0. E exige que todo
`scripts/migracao-*.sql` e todo `sql/*.sql` estejam no passo 0. Lê as migrações sem os comentários.
Não roda no CI, que clona raso e não acha o commit (sai 2, "não consegui medir").

**Achado no caminho: o passo 5 do corte reprovaria.** `sql/sessoes.mysql.sql` (a sessão do ERP no
banco, F1.5, de 19/08) não está no `mysql.sql`, e o gerador cria `sessoes` no PostgreSQL de qualquer
jeito. Sem ela no MySQL de produção, a sexta prova de `conferir-migracao.php` acusa as cinco colunas
"só no PostgreSQL" — medido, abaixo. A primeira versão deste conserto a pôs no passo 0, **e isso
parava a janela no passo 4** (a revisão, abaixo). O que ficou: a prova aceita a `sessoes` ausente no
MySQL, como exceção nomeada; o gerador não emite a `sessoes` do dump; e a linha do passo 0 diz "não
aplicar". O portão exige os `sql/*.sql` citados também, que o `deploy-teste.sh` já tratava como
esquema.

**Achado no caminho, também: o roteiro de carga estava velho** — o D379.

**O que foi provado, em bancos descartáveis com nome próprio, apagados no fim:**

- **A estrutura.** `franquiabv_ensaio_corte_d323`, montado do `mysql.sql` de `3b1f05c`, recebeu as
  sete `scripts/migracao-*.sql` **duas vezes**: a segunda rodada não mudou nada no
  `information_schema` (as que conferem dizem "nada a fazer"), e a primeira mudou 70 linhas do
  retrato. Depois delas, o retrato — cada coluna na ordem, com tipo, nulabilidade, padrão, `EXTRA`,
  comentário e collation; cada índice; cada chave estrangeira com as regras; motor e collation de cada
  tabela, 1.390 linhas — é **idêntico** ao de `franquiabv_ensaio_corte_d323_hoje`, montado do
  `mysql.sql` de hoje, menos a `iframe_bi`, que o retrato deixa de fora. Antes delas, os dois
  diferiam nessas mesmas 70 linhas, e só nas das outras 13 diferenças acima.
- **O Líder.** Com uma linha `8, 'Líder'` no grupo, a migração deu `gerente = 1` (o `'Lider'` sem
  acento dela casa pela collation `utf8mb3_general_ci`), e a segunda vez não mudou nada.
- **A chave estrangeira copia a tabela.** `ALTER TABLE ... ADD CONSTRAINT ... ALGORITHM=INPLACE` foi
  recusado ("Adding foreign keys needs foreign_key_checks=OFF") — por isso o aviso de rodar fora do
  movimento na linha da `pontuacoes_historicos` e na da `vendas`.
- **O passo 1, simulado.** A estrutura de A, passada pelo `mysqldump --no-data`, foi o `mysql.sql` de
  um diretório à parte no contêiner, e os dois geradores rodaram sobre ela: o `schema.sql` gerado tem
  as mesmas linhas que o `postgres/schema.sql` de hoje, menos a `iframe_bi text` (a ordem de três
  `CREATE INDEX`/`ADD CONSTRAINT` muda, porque o servidor lista os índices na ordem em que foram
  criados).
- **O passo 5, simulado.** Esse schema carregado num PostgreSQL descartável, e
  `BANCO_MYSQL=… BANCO_PG=… php scripts/conferir-migracao.php` contra os dois, vazios: **5 provas
  falharam** — as cinco colunas de `sessoes` —, e depois de `sql/sessoes.mysql.sql` em A (duas vezes),
  **TUDO CONFERE**, com 946 colunas comuns e 81 a mais no PostgreSQL (76 `inquilino_id` e as 5 de
  `inquilinos`). *Esta prova deixou de fora o que a revisão achou: o schema dela foi gerado de A
  **antes** de a `sessoes` entrar em A; na ordem do procedimento, o passo 1 a levaria ao `mysql.sql`
  antes do gerador.*
- **O portão**, com 19 mutantes, um de cada vez: tirar do passo 0 a linha do hash anterior, a da
  `iframe_bi`, a do Líder ou a do `sql/sessoes`; citar uma migração com o nome errado; uma coluna, um
  índice ou uma tabela falsos e um tipo mudado numa cópia do `mysql.sql`; e, nas migrações, o
  `ADD COLUMN` virado comentário, o tipo errado, a nulabilidade errada, sem o `ADD CONSTRAINT`, sem o
  `ADD KEY`, uma coluna a menos na tabela criada e o `ALTER` apontado para outra tabela — os dezesseis
  saem com 1, nomeando o que falta. Uma linha de tabela que ele não sabe ler, um `mysql.sql` vazio e
  o git sem o commit saem com 2. Cada migração mudada voltou ao conteúdo de antes, conferido por
  sha256. Um deles é mais estrito que o MySQL: sem o `ADD KEY`, o servidor criaria sozinho o índice
  da chave estrangeira com o mesmo nome, e o portão reprova assim mesmo — a migração tem de dizer o
  que o `mysql.sql` diz.

**O que não foi provado:** ~~as migrações no MySQL **8.0** de produção~~ — *errado: a produção é
8.4.7 (`docs/ambiente-homologacao.md`, correção de 19/08; `docs/operacao-atual.md`), a versão do
ensaio. O 8.0.46 é o das réplicas do BVIP, onde o 5 de "O que hoje bloqueia" manda medir os logins;
a revisão provou lá (abaixo).* O tempo do `ALTER` das chaves estrangeiras sobre 2,2 e 1,2 milhões de
linhas: os bancos do ensaio eram vazios. E a carga em si: o `migrar-dados.sh` não roda aqui.

#### A revisão, em 03/10/2026 — o laço tinha a `sessoes`, que parava a janela no passo 4

Uma revisão adversarial do conserto acima confirmou dez achados (dois eram o mesmo). Conferidos um a
um no código, todos de pé; consertados no repositório, menos uma escolha que é do dono.

- **A `sessoes` no passo 0 parava a janela no passo 4** (os achados 1 e 6, alta). Com a tabela em
  produção, o passo 1 a leva ao `mysql.sql`, e `gerar-ddl-postgres.php` emitia `CREATE TABLE sessoes`
  duas vezes: a dele, à mão, e a do dump, com `inquilino_id`, RLS e política — que impedem o login. **Medido**
  num MySQL descartável montado do dump com o laço e a `sessoes`: o gerador antigo deu dois
  `CREATE TABLE sessoes` (linhas 47 e 971) e uma `POLICY` nela, e o `psql -v ON_ERROR_STOP=1` saiu com
  3 em `relation "sessoes" already exists`. **O conserto:** a linha do passo 0 virou "não aplicar", com
  o porquê; `conferir-migracao.php` aceita a `sessoes` ausente no MySQL (exceção nomeada, como a
  `inquilinos`) e, se ela estiver nos dois, a confere como as outras; e o gerador não emite mais a
  `sessoes` do dump (avisa se as colunas dela forem outras). Com o gerador novo, o schema sai **igual
  byte a byte** com e sem a `sessoes` no dump, carrega, e a `sessoes` fica sem RLS. A prova 6, com o
  laço sem a `sessoes`: o roteiro antigo, **5 falhas**; o novo, **TUDO CONFERE**, 941 colunas comuns e
  86 a mais no PostgreSQL (76 `inquilino_id`, 5 de `inquilinos`, 5 de `sessoes`). Com a `sessoes` nos
  dois, 946 comuns; e `sessoes.dados NOT NULL` só no MySQL reprova (`DIVERGE`).
- **O portão lia como código o comentário DENTRO do literal do `ALTER`** (o achado 2). O molde manda
  aquele texto ao `PREPARE`, que ignora os comentários de dentro dele; o portão os lia como texto, e
  um `-- , ADD COLUMN hash_anterior_usado_em ...` ali dentro saía verde, com a coluna fora de
  produção. Agora cada literal que começa por `ALTER`/`CREATE`/`DROP`/`RENAME TABLE` é lido como o
  servidor o executa: desescapado e sem os comentários dele.
- **Índice, restrição e opções contavam só pelo nome** (o achado 3). O índice agora confere colunas e
  unicidade; a restrição, a definição inteira (colunas, tabela de destino, `ON DELETE`/`ON UPDATE`); as
  opções, o valor.
- **O portão não lia o laço que o operador roda** (o achado 5). Agora o laço tem de ser a lista das
  linhas `| 0 | **Aplicar …**`, na mesma ordem; só as migrações do laço cobrem diferença; e nada do
  laço pode levar ao `mysql.sql` o que o de hoje não tem — a regra que pega a `sessoes` (e qualquer
  outra tabela ou coluna fora do `mysql.sql`).
- **A linha da `iframe_bi` dizia "a carga não a copia"** (o achado 4): copia, porque o passo 4 regera o
  roteiro do `mysql.sql` refeito, e o gerador só deixa de fora a `usuarios.senha_php83` (o próprio
  D379 já dizia). Corrigido; inofensiva do mesmo jeito.
- **O Líder antes da janela dá ao código antigo mais que relatório** (o achado 7). No commit da
  importação (`eaf1fca`), `grupos.gerente` autoriza fiado sem olhar a unidade de quem autoriza
  (`VendasPagamentosController:126`), devolve crédito de cliente com a senha de um gerente de qualquer
  unidade (`ClientesController:657`) e cancela venda da própria unidade (`VendasController:174`). As
  cinco pessoas do grupo 8 passariam a fazer isso na produção congelada até o corte. **Quando aplicar é
  do dono** (lote de perguntas): a linha do passo 0 diz "espera a sua decisão", a migração saiu do
  laço, e o passo 3 traz o comando para o caso de ser na janela. Ensaiada de novo: `gerente = 1` na
  primeira vez, nada na segunda (com o `'Líder'` gravado em UTF-8 — gravado por um cliente em latin1,
  o nome vira `LÃ­der` e ela não casa).
- **O código do D349 era pré-condição sem estar em "O que hoje bloqueia"** (o achado 8). Entrou como
  o 6, e o passo 8 voltou a ter regra de falha: qualquer falha no teste manual é volta atrás (ela tinha
  saído junto com a suíte).
- **"MySQL 8.0 de produção"** (o achado 9): corrigido acima e no roadmap. E **provado no 8.0.46**, num
  contêiner descartável do `mysql:8.0` que já estava na máquina, sem rede: as seis do laço duas vezes
  (a segunda não muda nada; o retrato, 1.390 linhas, idêntico ao do `mysql.sql` de hoje, menos a
  `iframe_bi`), e `medir-logins-com-maiuscula.sql` com os dois `UPDATE` — os mesmos números do 8.4.
- **"3 logradouros"** (o achado 10): a `franquiabv_dev` tem 5 hoje. Corrigido no procedimento e no
  ensaio geral.

**Refeito na revisão, em bancos descartáveis com nome próprio, apagados no fim** (conferido: nenhum
`franquiabv_ensaio%` no MySQL nem no PostgreSQL, o contêiner do 8.0 removido, o `/tmp` do contêiner da
aplicação limpo): as seis do laço duas vezes sobre o dump, no 8.4.11 e no 8.0.46 (1.321 linhas de
retrato antes, 1.391 depois da primeira, nada na segunda; menos a `iframe_bi`, idêntico ao do
`mysql.sql` de hoje, com as colunas de `unidades` na mesma ordem); o passo 1 simulado com os dois
geradores; e o passo 5, como acima.

**O portão, com 39 mutantes, cada um numa CÓPIA da árvore** (o portão roda nela com `GIT_DIR`
apontando para o repositório; nada no repositório é tocado). **Os 17 novos saíam todos verdes no
portão de antes:** cinco comentários dentro do literal (`--`, `#` e `/* */` no `ADD COLUMN`, `--` no
`ADD KEY` e no `ADD CONSTRAINT`), cinco de definição (o índice com outra coluna e virado `UNIQUE`, a
chave estrangeira para outra tabela e com `ON DELETE CASCADE`, a opção com outro valor), cinco do laço
(sem uma migração, duas trocadas, a `sessoes` a mais, o laço sumido, um item que não é migração) e
dois do que o `mysql.sql` não tem (a `sessoes` no laço como "Aplicar" — o próprio estado da árvore
antes da revisão, verde — e uma coluna inventada). No portão novo, 15 saem com 1 e os dois de laço
ilegível com 2. Os 16 da primeira rodada continuam com 1, os 3 "não consegui medir" com 2, e um literal
sem fim, que dava 1 com um motivo falso, dá 2. Os dois controles — a árvore de hoje, e a opção posta
com o valor certo — saem com 0.

### D324 — Duas "ocupações da agenda" com regras opostas: a da agenda do dia conta o bloqueado como tomado — REGISTRADO em 02/10/2026 — PARCIAL em 12/10/2026 — CORRIGIDO em 07/11/2026

**Aberto pela F4.3 do Profissional Parceiro** (`docs/f3-relatorios.md`, seção 5), que pôs na tela a
ocupação do mês pela decisão do dono de 30/09/2026: **o horário bloqueado sai da conta** (folga,
feriado, ausência, qualquer motivo -- a B5), e os motivos aparecem ao lado. A agenda do dia, da tela
nova, faz o contrário: `ocupacao()` em `frontend/src/telas/modeloDaAgenda.ts` conta como **tomado**
todo horário que não está livre -- o agendado e o fechado --, e a coluna fechada o dia inteiro é 100%.

**O que isso faz com quem lê as duas:** num dia com 6 horários, 2 agendados e 4 bloqueados por folga,
a agenda do dia diz 100% e o Profissional Parceiro diz 2 de 2, também 100% -- mas com 2 agendados e 2
bloqueados em 6, a agenda diz 67% e o relatório, 2 de 4, 50%. Na unidade 1, em julho de 2026, a régua
nova do relatório tirou 559 horários bloqueados de 4.285; a agenda do dia os conta como ocupação.

**Por que não se alinhou na F4.3:** a decisão B10 do juiz da F4 (01/10/2026) deixou a agenda do dia como
está, e o alinhamento é uma fatia de tela própria (a F4.5, "só se o dono quiser"). A agenda do dia
responde "quanto da minha coluna ainda posso vender hoje" -- e ali o bloqueado não é vendável --; o
relatório responde "quanto do que estava aberto foi agendado".

**O conserto, se o dono quiser uma régua só:** `ocupacao()` passa a dividir os agendados pelos
horários menos os fechados, e o fechado o dia inteiro deixa de ser 100% (vira "fechado"). Só a tela,
e vale para o passado.

#### 12/10/2026 — A OCUPAÇÃO DA AGENDA DO DIA NA RÉGUA DO RELATÓRIO — pela resposta 111 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 111** (a opção "a"): *"Uma régua só, a do relatório -- a agenda do dia da tela nova divide os
agendados pelos horários abertos (o bloqueado sai da conta), e a coluna fechada o dia inteiro diz 'fechado'. Só a
tela."*

**O conserto.** `ocupacao()` (`frontend/src/telas/modeloDaAgenda.ts`) divide os horários agendados -- marcado, em
atendimento e atendido -- pelos horários ABERTOS da coluna, os que não estão bloqueados; e devolve `null` quando
não há horário aberto: a coluna que não abriu (férias, sem agenda) e a que tem todo horário bloqueado. A tela
escreve `textoDaOcupacao`: "50%", ou "fechado". Mudou nos três lugares que a mostram -- o cabeçalho da coluna, a
linha do tempo e os cartões do "quem está livre agora" --, e o título e a leitura em voz alta dizem "N% dos
horários abertos agendados" (era "da agenda tomada", que contava o bloqueado como tomado). Só a tela: o servidor e
o relatório não mudaram, e a conta vale para o passado como para hoje.

O exemplo da pergunta: num dia de 6 horários, com 2 agendados e 2 bloqueados, a agenda dizia 67% e o relatório,
50%; agora as duas dizem 50%.

**Provado:** `modeloDaAgenda.teste` (o dia da pergunta dá 50%; o dia com um bloqueado sai da conta; a coluna toda
bloqueada e a sem horário são `null` e "fechado"; a de férias, "fechado" mesmo com horário), e o cabeçalho, a linha
e o cartão da coluna fechada dizem "fechado" (`GradeDaAgenda.teste`, `FormasDaAgenda.teste`), como o cabeçalho da
coluna aberta com todo horário bloqueado (caso novo); o cabeçalho do Joaquim passou de 75% a 67% -- o almoço
bloqueado saiu da conta. O front inteiro com `--maxWorkers=4`, 1.685 de 1.685; os arquivos mudados também com
`AMPLIFICAR_EFEITOS_MS=50`; build e lint no piso de antes.

Mutantes, um de cada vez, contra os quatro arquivos de teste, restaurados pelo sha256: a divisão por todos os
horários (a régua de antes), 3 casos; a coluna que não abriu de volta a 100, 4; sem horário aberto, zero, 1; o
bloqueado contado como agendado, 3; o cartão com a taxa crua, 1; e o cabeçalho decidindo pela falha, e não pela
ocupação, 1 -- o caso da coluna aberta com todo horário bloqueado, que entrou por causa dele: o mutante seguinte
(o cabeçalho da coluna aberta com a taxa crua) ficou verde, e é equivalente -- aquele ramo só roda com número, e
`${taxa}%` é o mesmo texto --, mas a leitura mostrou que nada media a coluna aberta toda bloqueada.

#### 12/10/2026 — A VERIFICAÇÃO: o horário com bloqueio e agendamento, e o banco que desempatava

A verificação adversarial do item achou que, no horário com um bloqueio E um agendamento no mesmo instante, as duas
réguas ainda discordam. A grade desenha a PRIMEIRA linha da hora (`Util\Agenda::buscarAgenda`): a de bloqueio sai
"bloqueado", e a tela a tira da conta; o relatório (`AgendaDoParceiro::conta`) faz o agendamento vencer o bloqueio, e
o conta como ocupado. Contado no `franquiabv_dev`: 7.558 pares, de 2020-08-14 a 2026-11-21, em 159 colaboradores, 1
no futuro.

E QUAL linha vinha primeiro dependia do banco: `selecionarPorColaboradorData` ordenava só por `age.data`. No MySQL o
plano lê pelo índice do colaborador, e o InnoDB põe o id no fim da chave -- a mais antiga; no PostgreSQL, a do plano
(no colaborador 729, em 07/03/2026, saía o bloqueio às 13:00 e o agendamento às 13:30).

**O conserto, a parte que não é escolha:** o desempate pelo id (`ORDER BY age.data, age.id`), nos dois bancos -- a
ordem que o MySQL de produção já devolve, e o desempate que o dono decidiu para os horários do cliente (D426, pergunta
46) e que a família ganhou no D455. Nada muda no MySQL de hoje; o PostgreSQL passa a desenhar o mesmo.

**O que resta, e por isso PARCIAL:** qual das duas linhas o horário mostra. Em 7.520 dos 7.558 pares o agendamento é
o mais antigo, a grade o mostra, e a régua bate com a do relatório; em 38, o bloqueio é o mais antigo, a grade mostra
"fechado" com o cliente por baixo, e a régua da agenda do dia não conta esse horário. Fazer o agendamento vencer muda
o desenho da tela antiga também, e a resposta 111 foi "só a tela": a pergunta foi ao lote (`AGENDA-ANTIGA-E-REGUA (a
verificação, o D324)`), recomendando que o agendamento apareça.

**Provado:** `GradeDaAgendaApiTest::testOHorarioComBloqueioEAgendamentoDesenhaOMaisAntigoNosDoisBancos` -- o
agendamento entra primeiro na tabela, com o id maior, e o bloqueio depois, com o menor: a grade desenha o bloqueio,
nos dois bancos. Sem o desempate o caso fica vermelho no PostgreSQL (desenha o agendamento) e verde no MySQL, onde a
ordem do id sai do próprio plano -- como o mutante do D455.

#### 07/11/2026 — O AGENDAMENTO APARECE, NAS DUAS TELAS (a fatia 107, AGENDA-ORIGEM-E-GRADE) — pela resposta 157 (a) do dono, sob o pedido *"Atualize o roadmap e depois Continue de forma autonoma"*

**A resposta 157** (`bloqueio-e-agendamento-no-mesmo-horario`, a opção "a", 12/10/2026): *"O agendamento aparece, nas duas
telas"* -- no horário com um bloqueio e um agendamento no mesmo instante, a grade mostra o cliente, e a régua da agenda do
dia bate com a do relatório do Profissional Parceiro em todo horário.

**Conferido antes:** o defeito existia. `AgendaRepository::selecionarPorColaboradorData` ordenava só `age.data, age.id`, e
`GradeDaAgendaApiTest` ainda prendia o bloqueio mais antigo desenhado.

**O conserto, numa linha da consulta.** No mesmo instante, a linha SEM fechamento vem antes:
`ORDER BY age.data, ehBloqueio, age.id`, com `CASE WHEN age.fechamento IS NULL THEN 0 ELSE 1 END AS HIDDEN ehBloqueio` (só
no ORDER BY; as linhas continuam com as mesmas chaves). É o critério do relatório (`AgendaDoParceiro`: a linha com
fechamento é bloqueio, a sem fechamento é agendada, e o agendamento vence o bloqueio). As duas grades desenham a primeira
linha da hora (`Util\Agenda::buscarAgenda`), e as duas leem esta consulta pelo `GradeDaAgendaService` (a tela nova pelo
recurso, a antiga pelo `AgendasController::getAgenda`): os 38 horários em que o bloqueio era o mais antigo passam a mostrar
o cliente, `marcado`, e a ocupação da agenda do dia (`ocupacao()`, a 111) os conta como agendados, como o relatório. Os
outros 7.520 não mudam. O id continua desempatando dentro de cada grupo (dois agendamentos, ou dois bloqueios, no mesmo
instante). O front não mudou: a régua já conta pelo estado que o servidor manda. Os outros leitores da consulta (o
aplicativo, os horários livres, as conferências do destino) perguntam só se a hora tem linha, e a ordem não os muda.

##### Provado

- `GradeDaAgendaApiTest`: o caso de 12/10 virou `testOHorarioComBloqueioEAgendamentoDesenhaOAgendamentoNosDoisBancos` -- o
  bloqueio com o id menor, e a grade mostra o agendamento, `marcado`, com o cliente --, e ganhou o controle
  `testOAgendamentoMaisAntigoQueOBloqueioContinuaAparecendo` (a regra é "o agendamento vence", e não "o mais novo vence").
- `AgendaAntigaComoANovaTest::testOHorarioComBloqueioEAgendamentoMostraOClienteNaGradeAntiga`: a agenda fechada às 10:00
  pelo serviço, e o agendamento por cima; o `atualizar` da tela antiga, despachado em processo, desenha a célula das 10:00
  com o id do agendamento e a classe `agenda-appointment` (o botão do cliente), e não a do bloqueio.
- Nos dois bancos, em série. Mutantes, um de cada vez, nos dois bancos, devolvidos pelo sha1: a ordem só pelo id (G1, a de
  12/10) deixa vermelhos o caso da grade nova e o da antiga; o id ao contrário sem o grupo (G2) deixa vermelho o controle;
  e o bloqueio antes do agendamento (G3) deixa os três vermelhos.

### D325 — Reabrir a agenda apaga os bloqueios sem rastro, e a ocupação de um mês passado muda — REGISTRADO em 02/10/2026 — PARCIAL em 12/10/2026 — CORRIGIDO em 12/10/2026

**A decisão B7 do juiz da F4** (01/10/2026), registrada agora porque a F4.3 passou a mostrar a
ocupação de meses fechados. `AgendaService::abrir` remove o fechamento, e a chave estrangeira
`FK_AGENDAS_fechamento` (`ON DELETE CASCADE`, `mysql.sql`) apaga junto as linhas de bloqueio. Nada
guarda o que estava bloqueado: o arquivo de exclusões (`arquivarAgendaExclusao`) pula de propósito
as linhas com fechamento.

**O efeito na régua:** o horário que foi bloqueado e depois reaberto volta a ser **livre** no mês dele,
e a taxa daquele mês desce -- como o cancelamento de uma venda muda o mês dela (a resposta 6 do dono:
"o número se calcula na hora"). Em julho de 2026 faltam 487 dos 2.326 ids de fechamento do intervalo
(levantamento da F4, `q1.sql`); não se sabe quantos eram de horário que já tinha passado quando foram
reabertos.

**O conserto, daqui para a frente:** gravar os bloqueios reabertos numa tabela própria antes do
`remove` (o desenho do dado propôs `agendas_bloqueios_reabertos`), ou recusar reabrir horário que já
passou. O que for reaberto até lá se perde. A retenção do arquivo de exclusões no PostgreSQL, que não
tem a limpeza de 60 dias do MySQL, é o D26.

#### 12/10/2026 — HORÁRIO QUE JÁ PASSOU NÃO SE REABRE — pela resposta 110 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 110** (a opção "b", e NÃO a recomendada): *"Recusar reabrir horário que já passou. Sem esquema; o
futuro continua reabrindo, e o horário passado fechado por engano deixa de ter conserto. Nas duas telas (a antiga
e a nova) e na API."*

##### O conserto

`ISCadastro\Service\ReaberturaDaAgenda` decide pelas LINHAS de bloqueio -- uma por horário, com o instante dele --,
e "passou" é o horário que já começou -- o das 09:20, reaberto às 09:20:30, já começou e fica (o instante é o do PHP,
inteiro, e nunca o `NOW()` do banco):

- **nenhuma linha no passado**: o fechamento sai inteiro, como sempre (`AgendaService::abrir`) -- inclusive o que
  não tem linha nenhuma, que não muda régua nenhuma;
- **todas no passado**: não se reabre nada, e a frase diz por quê: *"Os horários deste fechamento já passaram, e
  horário que passou não se reabre."*;
- **umas e outras** (o fechamento que começou e não acabou): saem SÓ AS FUTURAS. As que passaram ficam, presas ao
  fechamento -- que por isso fica --, e o fim dele passa a ser o instante da reabertura: a lista de bloqueios e a
  pergunta de reabrir diriam "fechado até o dia 20" de uma agenda aberta desde hoje.

As três portas de REABRIR passam por ela (as que apagam a linha pelo Desmarcar eram outras, e ficaram para a
verificação, abaixo): o `abrir` da tela antiga (a pergunta "Deseja abrir a agenda no período X a Y?"
diz o X de agora quando o começo já passou, e nem pergunta quando tudo passou -- a frase vai para o aviso; o
"Sim" recusa do mesmo jeito), o `bloqueiosRemover` da lista de bloqueios (o aviso, ou "Removido a partir de ...:
os horários que já passaram continuam fechados."), e a API -- `DELETE /adm/api/agendas/fechamentos/:id` responde
**409** quando tudo passou (o pedido está certo; o estado não deixa), e o `GET` e o `DELETE` trazem `reabre`
(aditivo: `{de, tudo}`, ou `null` quando nada reabre). A tela nova (`ReabrirFechamento`) lê o `reabre`: a pergunta
diz de quando em diante e que o passado continua fechado, ou diz que já passou e não envia; o aviso de "Agenda
reaberta" diz o mesmo. O JS da tela antiga não mudou.

O que já foi reaberto até hoje se perdeu, como este débito dizia -- a opção "a" (guardar o que se reabre) era a
que pedia esquema. **Uma escolha de leitura, e escrita aqui:** o fechamento que começou reabre o futuro, em vez de
ser recusado inteiro -- é o "o futuro continua reabrindo" da resposta.

##### Provado

- `ReaberturaDaAgendaTest` (novo, 16 casos, nos dois bancos): a regra pelas linhas, com o relógio na mão (nada
  passou sai tudo, desde o início; tudo passou fica, com as duas linhas; o que começou às 09:25:41 fica com as das
  09:00 e 09:20 e o fim vira 09:25:00; o horário das 09:20 reaberto às 09:20:30 já começou, e fica -- o caso que
  um mutante meu achou faltando: a primeira versão cortava o agora no minuto, e o reabria; perguntar não mexe), e as três portas
  com o relógio de verdade e linhas de dois dias atrás e de daqui a dois dias: a tela antiga não pergunta e não
  reabre o que todo passou, pergunta e reabre só o futuro, e reabre inteiro o futuro; a lista de bloqueios avisa e
  não remove o passado, e remove só o futuro dizendo de quando; a API dá 409, diz `reabre` nos três casos, e reabre
  só o futuro.
- Tela nova: `AgendaLigada.teste` ganhou 2 casos -- o fechamento que já começou (a pergunta e o aviso dizem de
  quando, e que o passado fica fechado) e o que já passou inteiro (a pergunta diz por quê, e o Reabrir não envia).

- Mutantes, um de cada vez, no MySQL, restaurados e conferidos pelo sha256, cada um com o seu vermelho: sem o
  "nada" (o que passou inteiro reabriria o futuro, que não tem), 7 casos -- a regra, a tela antiga, a lista, o 409 e o
  `GET`; o "só o futuro" sem mudar o fim; o "só o futuro" apagando tudo, 4 casos; a pergunta da tela antiga com o
  início pedido; a API sem o 409; o `GET` sem o `reabre`; a lista de bloqueios reabrindo tudo, 2 casos; e o agora
  cortado no minuto (o horário deste minuto). No front: a pergunta pelo início pedido, a
  pergunta sem o "só o futuro", o aviso sem dizer que o passado fica fechado (o caso do fechamento que começou), a
  pergunta sem o "já passou" e o "já passou" sem travar o envio (o caso do que passou inteiro) -- cada um vermelho.
- Vizinhos: os 702 casos (ver o D216), verdes no MySQL e no PostgreSQL, em série. **Não rodei o
  `ensaiar-fechamento-da-agenda.php`**: ele fecha a agenda de verdade no banco em que roda.

#### 12/10/2026 — A VERIFICAÇÃO: o Desmarcar, a porta que não era de reabrir

A verificação adversarial do item achou que o título prometia mais do que o conserto: as três portas eram as de
REABRIR, e as que APAGAM a linha do bloqueio eram mais. O Desmarcar com o id de uma linha de bloqueio --
`POST /mod-cadastros/agendas/desmarcar` e `DELETE /adm/api/agendamentos/:id` -- conferia só a unidade, e o `desmarcar`
do serviço a apagava sem rastro (o arquivo de exclusões pula as linhas de fechamento): o horário passado voltava a ser
livre, e a ocupação do mês descia. Nenhuma tela oferece o botão ali -- a célula fechada só oferece o reabrir --; só o
pedido escrito à mão de quem gerencia a agenda chegava lá, e a porta já existia antes.

**O conserto:** as duas recusam a linha sem cliente com a resposta de "não existe" -- "Não foi possível localizar o
horário." na tela antiga, 404 na API --, como o remarcar e o arrastar já faziam. **E o irmão:** a chegada pela API
(`POST /adm/api/agendamentos/:id/checkin`) gravava `checkin` na linha de bloqueio, e o aviso de "Seu Cliente Chegou!"
ia montar a mensagem de um cliente que não existe; recusa igual. A lista saiu do código, e não das ações de reabrir:
quem apaga linha de `agendas` é o `AgendaService::desmarcar` (e o `abrir`, pelo `ON DELETE CASCADE`), e quem o chama é
o desmarcar das duas telas, a remarcação (que já recusava a linha sem cliente) e as duas rotas do aplicativo -- a nova
(`DELETE /api/v4/agendamentos/:id`) só alcança o horário do cliente autenticado.

**O que resta, e por isso PARCIAL:** a rota antiga do aplicativo (`POST /api/agendas/cancelar`,
`AplicativoController::agendaDesmarcarAction`) apaga qualquer linha pelo id, também a do bloqueio. É contrato do
aplicativo e a mesma rota da pergunta 106 (o D237), que espera o dono; a pergunta desta foi ao lote
(`AGENDA-ANTIGA-E-REGUA (a verificação, o D325)`), recomendando a mesma recusa.

**Provado:** `ReaberturaDaAgendaTest` ganhou 3 casos (19), nos dois bancos: o Desmarcar da tela antiga e o `DELETE` da
API com o id da linha de bloqueio de dois dias atrás recusam, e o horário passado continua fechado; a chegada da API
na linha de bloqueio é 404, e não grava. Os mutantes de cada guarda, cada um vermelho, estão na tabela do D219.

#### 12/10/2026 — A ROTA ANTIGA DO APLICATIVO RECUSA A LINHA DO BLOQUEIO — pela resposta 158 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 158** (a opção "a"): *"a rota antiga do aplicativo que desmarca (POST /api/agendas/cancelar,
agendaDesmarcarAction) recusa a linha do bloqueio (sem cliente) como 'ID inválido.', a mesma recusa das outras portas;
o aplicativo não muda. Depois disso o D325 fecha."*

**O conserto:** `AplicativoController::agendaDesmarcarAction` só desmarca a linha com cliente; a do bloqueio é "ID
inválido.", e fica -- também para quem tem "Agendas unidades". Era a última porta que apagava a linha de um fechamento
sem rastro (o arquivo de exclusões pula as linhas de fechamento): o horário passado voltava a ser livre, e a ocupação do
mês descia. Nenhum aplicativo manda esse id.

**Provado:** `RotasAntigasDoAplicativoTest::testODesmarcarRecusaALinhaDoBloqueio`, nos dois bancos (sem e com "Agendas
unidades"): a linha de bloqueio de dois dias atrás continua no banco. Mutante: o desmarcar sem a condição do cliente --
os dois vermelhos. Entrada no `conferir-recorte-por-unidade.php` (a mesma do desmarcar, com as duas condições).


### D326 — A cópia do teste leva um CPF de pessoa física e 243 telefones reais em links — REGISTRADO em 02/10/2026

**Medido na conferência da cópia de 02/10/2026** (`base-teste.sql.gz`, selo `fca02f5a26f7…`, commit
`2d85434`), por quatro lentes e um cético por achado grave. O anonimizador e o endurecimento deixam
passar duas coisas:

- **`unidades.faturamento_documento` da unidade 27 é um CPF** com dígito verificador válido, e o
  endereço de faturamento dela não é o da loja. O anonimizador trata as colunas `faturamento_*` como
  dado de pessoa jurídica, e nem o endurecimento nem a régua as olham. A `faturamento_nome` da unidade
  60 tem a forma de nome civil completo (empresário individual) -- não confirmado sem máscara.
- **`notificacoes.link` traz 243 links `wa.me`** com o telefone de quem os recebe, que o anonimizador
  não alcança.

**Decisão do dono, 02/10/2026:** "sim, suba como está com senha HTTP" -- o ambiente não será exposto, e
a senha HTTP fica ligada. É dado pessoal num ambiente fora da produção: débito de LGPD, futuro, como os
outros.

**O conserto:** em `scripts/endurecer-base-teste.sql` (que o `base` roda de novo no servidor) e em
`scripts/anonimizar.sql.modelo`, trocar por valores de exemplo o documento e o endereço de faturamento
de toda unidade cujo documento não seja um CNPJ válido (ou de todas), e os links `wa.me`; a régua
ganha os dois contadores. Muda o selo da cópia: commit, exportação nova e `base` de novo.

**De passagem, da lente de segredos (não confirmado, e não é da cópia):** `arquivos.nome` (109) e
`visagismo.imagem` (2.740 fotos) são os nomes físicos que a produção serve sem sessão em
`/arquivos/<nome>` e `/visagismo/<nome>`. Quem lê a cópia conhece esses endereços. Não foi sondado na
produção, e não deve ser; o visagismo é débito futuro por decisão do dono.

### D327 — A cópia do teste mistura fixtures e restos de ensaio, e a base real para em 16/08/2026 — REGISTRADO em 02/10/2026

A `franquiabv_dev` é a cópia da produção de 16/08/2026 MAIS o que a suíte e os ensaios gravaram nela, e
o exportador leva tudo. Medido na mesma conferência:

- **Fixtures:** 456 linhas em 36 tabelas. Quatro unidades de fixture estão ativas e aparecem nos
  seletores (36 ativas, 32 reais), com 2 grupos "(teste)", 5 logins ativos e 12 vendas somadas na rede.
  Vêm junto `_fixtures_versao` e `testes_estilhos_*`: o dump tem 87 tabelas, não as 83 da documentação.
- **Restos de ensaio em unidade real:** uma comanda aberta de 29/09 e 30 vendas canceladas de 15/09 na
  unidade 1, entre outras linhas gravadas depois de 16/08 -- e, nessa faixa, pelo menos 46 linhas são
  da produção de 16/08 entre 18:14 e 18:59: o corte não pode ser pela data.
- **Os contadores herdaram a faixa:** 49 tabelas passam de 90 milhões, e a primeira venda feita no teste
  recebe o nº 990.099.319.
- **A base real termina em 16/08/2026 às 18:59:** não há venda concluída depois, e os relatórios de
  setembro em diante saem vazios. Ficaram 139 caixas abertos de outro dia, 83 deles em 24 unidades
  ativas; 38 logins ativos têm um desses caixas, e a venda deles é recusada (D28) até fechar o antigo.
- O grupo 8 ("Líder") segue com `gerente = 0`: a migração do D124 não está na base.

**O conserto:** no endurecimento, apagar as fixtures (ids 90000000 a 90009999) e os restos dos ensaios
(90010000 em diante) com os dependentes, na ordem das chaves estrangeiras, e devolver os contadores
(`ALTER TABLE … AUTO_INCREMENT = 1`); ou exportar de uma cópia sem a suíte. Fechar os caixas antigos na
cópia é decisão do dono. Até lá, quem testa precisa saber as quatro coisas acima
(`docs/ambiente-teste.md`, "O que fica de fora").

**E uma tabela nova para a próxima cópia (10/10/2026, D461):** `clientes_aparelhos`, os aparelhos do push pelo
Firebase, liga um identificador de aparelho a um cliente, e guarda o telefone da sessão que o registrou. O exportador
faz o `mysqldump` da origem inteira e só confere o número de tabelas: sem um passo que a trate, ela vai à cópia com o
que tiver. **O lugar é o da irmã de token, `clientes_sessoes`** (verificação de 10/10/2026, que corrigiu este texto, que
dizia "o anonimizador"): o `DELETE FROM clientes_aparelhos` no `endurecer-base-teste.sql` (a `clientes_sessoes` está
na linha 51) e a contagem zero dela no `conferir-base-teste.sql` (a da `clientes_sessoes`, na linha 89), que faz o
`conferir` falhar fechado se o endurecimento não rodar. **O gatilho é a primeira cópia tirada da produção depois do
passo 0 do corte** — ela traz a tabela com o que o aplicativo tiver gravado, qualquer que seja a resposta à pergunta da
cópia local —, ou a cópia local que a receber antes disso. As duas mudanças mudam o selo. Nada mudou nos cinco arquivos
do selo agora: a cópia de 02/10/2026 está subindo no BVIP, e o `DELETE` numa cópia sem a tabela falharia.

**O gatilho chegou em 11/10/2026** (a resposta 72 do dono; o D461): a cópia local tem `clientes_aparelhos` desde as
06:33 desse dia, nos dois bancos, vazia. **O passo antes da próxima exportação** é este: o `DELETE FROM
`clientes_aparelhos`` no `endurecer-base-teste.sql`, ao lado do da `clientes_sessoes` (linha 51), e a contagem zero
dela no `conferir-base-teste.sql`, ao lado da da `clientes_sessoes` (linha 89) -- conferidas por leitura nesse dia.
**O selo não mudou agora, por decisão do dono** ("o selo não muda agora": a cópia que subia no BVIP), e os cinco
arquivos dele estão como estavam. Até o passo, a cópia exportada da local leva a tabela com o que um ensaio local
tiver gravado nela; hoje, nada (0 linhas nos dois bancos).

### D328 — O portão de e-mail do `deploy-teste.sh base` some com a linha de byte inválido sob locale UTF-8 — REGISTRADO em 02/10/2026

O deploy não fixa o locale. Com `LC_ALL=C.UTF-8`, o GNU grep 3.11 descarta em silêncio a linha com byte
inválido: num ensaio sintético, um INSERT de `usuarios` com um byte `0xFF` e um e-mail real sumiu da
extração do portão (0 de 1 linha), e o `grep -v GTID_PURGED` da carga perdeu a linha sem erro. Com
`LC_ALL=C`, nada some. A cópia de 02/10/2026 tem 0 linhas com UTF-8 inválido, então não é afetada;
um dump futuro com coluna binária seria. **O conserto:** `LC_ALL=C` nos `grep` do `base` e da carga,
com um caso no arnês do deploy.

Na mesma conferência, uma de três cargas completas pelo cano do Windows parou com `ERROR 1064` a 33 KB
do fim e não se reproduziu de jeito nenhum (a linha isolada, o fluxo com sha idêntico, a carga 100%
Linux e a repetição passaram). Se acontecer no servidor, o `base` apaga a base e o site segue fora do
ar: basta repetir.

### D329 — O passo 7 do corte põe o modelo de desenvolvimento do PostgreSQL no lugar do `local.php` de produção — REGISTRADO em 03/10/2026 — CORRIGIDO em 08/11/2026

Achado ao escrever, no `procedimento-de-corte.md`, o que o `local.php` de produção precisa desde o
SEGREDOS (a chave do Asaas e o `cron.token`). O passo 7 guarda o `local.php` de produção e o
**sobrescreve** com `config/autoload/local.postgres.php.dist` -- e esse arquivo é o modelo de
**desenvolvimento e homologação** (o cabeçalho dele diz isso). Lido em 03/10/2026, ele:

- **liga** `view_manager.display_exceptions` e `display_not_found_reason`, que o D16 tirou de produção (a
  página de erro publica o rastro, com o SQL e os parâmetros ligados);
- aponta o e-mail para o Mailpit (`'host' => 'mailpit'`, usuário e senha `dev`);
- não tem OneSignal, Zoomtec nem SMS, e traz vazios o Yungas, o Asaas e o `cron.token`.

Seguido ao pé da letra, o corte tira de produção o push, o e-mail, o visagismo, o SMS, a cobrança de
royalties e as 26 rotinas por URL (estas em silêncio, D4) -- e liga o rastro de pilha. O
`verificar-local-php.php` acusaria quase tudo (o rastro com saída 1), mas o passo 7 não o manda rodar;
o parágrafo do SEGREDOS, logo abaixo do passo, passou a mandar.

**O conserto proposto**, só de documento: no passo 7, partir do `local.mysql.php.guardado` e trocar
**apenas o bloco `doctrine`** (o `driverClass`, o host, a porta, o banco, `bvip_app` e a senha), em vez
de copiar o dist; e rodar o `verificar-local-php.php` antes do passo 8. O ensaio do corte
(`scripts/ensaiar-corte.sh`) faz o mesmo `cp`, mas contra um `local.php` de desenvolvimento, onde o
dist é o certo -- e é por isso que o ensaio não pega.

**CORRIGIDO em 08/11/2026 (a fatia 128, ROTEIROS-DO-CORTE-E-DA-CARGA).** O passo 7 de
`docs/procedimento-de-corte.md` guarda o `local.php`, e o roteiro novo `scripts/trocar-doctrine-do-local-php.php` escreve o
do PostgreSQL A PARTIR DO GUARDADO, trocando só o bloco `doctrine` (o driver do PostgreSQL, `--host`, `--porta`,
`--banco`, `bvip_app` e a senha do `SENHA_APP`, nunca na linha de comando nem na saída). O bloco é achado pelos tokens do
PHP no arranjo devolvido; o resto do arquivo sai igual, byte a byte, e o roteiro o confere AVALIANDO os dois arquivos antes
de escrever. Recusa sem escrever (saída 1, com o motivo): o guardado que já é do PostgreSQL, o bloco que aparece duas
vezes, o destino que é o próprio guardado, e o bloco `doctrine` com chave além da conexão `orm_default` (a recusa diz o
nome; o bloco novo não a levaria). O destino é escrito no lugar, como o `cp`. **O `verificar-local-php.php` vem antes do
passo 8, com a saída 1 parando** -- e ele conectava SEMPRE pelo MySQL: sobre o `local.php` do PostgreSQL ouviria
`mysql:host=<o do PostgreSQL>;port=5432` e sairia 1 com as credenciais certas, parando todo corte ali. Agora a conexão segue
o `driverClass` (no PostgreSQL, `SHOW server_version` e as tabelas do esquema do `search_path`); o `--sem-conexao` pula a
prova da conexão (para o ensaio). Medido no contêiner daqui: o dist do PostgreSQL dá "CONEXÃO OK — PostgreSQL 16.15, 95
tabelas" (e sai 1 pelo rastro de pilha, como deve). A nota da etapa 1 (`procedimento-de-corte-etapa-1.md`, o que a etapa
não faz e o que a etapa 2 herda) diz o mesmo. **Os testes:** o `scripts/ensaiar-troca-do-doctrine.sh` (8 casos, no
`conferir-tudo.sh` e no CI) sobre um `local.php` INVENTADO no formato do de produção (com o `driverOptions` do MySQL) --
a troca verde (o avaliado aponta para o PostgreSQL com `bvip_app`, sem o `driverOptions`; o texto antes e depois do bloco
igual; o guardado igual; a senha fora da saída; o verificador sai 0), o passo antigo como contraste (o verificador sai 1),
e as recusas; e o `D329VerificarConectaPeloDriverTest` (2 casos, nos dois bancos) roda o verificador com a conexão da
própria suíte: "CONEXÃO OK — PostgreSQL" no PostgreSQL e "— MySQL" no MySQL, o host que não existe fatal, e o
`--sem-conexao` sem tentar. Mutantes vermelhos: o resto do arquivo cortado (T1), a recusa da chave a mais arrancada (T2), a
do guardado do PostgreSQL (T3), a da contagem do bloco (T4), a do destino igual ao guardado (T5), nas cópias; e a conexão
sempre pelo MySQL (V1), no verificador de verdade, no PostgreSQL, devolvido pelo sha1. **O que fica igual, de propósito:**
o `ensaiar-corte.sh` e o bloco do laboratório em `docs/ambiente-homologacao.md` seguem com o `cp` do dist -- lá o
`local.php` é o de desenvolvimento, e o dist é o certo. **Não lido:** o `local.php` de produção (só existe no servidor); se
o bloco `doctrine` dele tiver outra chave, o roteiro recusa com o nome dela, na janela.

**A correção da verificação (08/11/2026, a fatia 128).** A verificação em três lentes achou cinco furos, consertados:
(1) **sem a extensão do driver no PHP da linha de comando**, o verificador pulava a conexão calado e, com as chaves
presentes, dizia "Deploy liberado" e saía 0 -- o passo 7 promete que ele conecta e que a saída 1 para o corte, e o
`pdo_pgsql` é um item à parte da etapa 2 (o ini da linha de comando pode ser outro que o do php-fpm). Agora é **FATAL**:
"CONEXÃO NÃO PROVADA — o PHP desta linha de comando não tem a extensão pdo_pgsql" (ou `pdo_mysql`), saída 1. (2) **O
`--sem-conexao` diz que não conferiu:** "CONEXÃO NÃO CONFERIDA" na conferência e, no fim, "A CONEXÃO NÃO FOI CONFERIDA
(--sem-conexao): o deploy só se libera rodando de novo sem a opção" no lugar do "Deploy liberado"; ele confere as chaves
fatais e as indevidas sem conectar, e é o modo que o item 13 da etapa 1 pedia pela 201 (a) (a etapa 1 diz isso agora;
fora do servidor, o `USUARIO_WEB=` com o próprio usuário, ou a permissão do arquivo acusa o `www-data`). (3) **O
temporário com os segredos:** o `trocar-doctrine-do-local-php.php` avalia o arquivo novo num temporário (o `local.php` de
produção inteiro mais a senha do `bvip_app`), e o `unlink` vinha só depois do `require` -- a recusa de dentro da
avaliação e o erro fatal (o guardado que declara uma função no topo morre com "Cannot redeclare" na segunda avaliação)
deixavam o arquivo no `/tmp`; agora uma função de encerramento o apaga também no `exit` e no fatal (só o SIGKILL a pula).
(4) **As três guardas sem caso** ganharam caso no `ensaiar-troca-do-doctrine.sh` (agora 13): a outra conexão
(`doctrine.connection.orm_outro`, que a conferência avaliada não pega: o bloco novo é o pedido), a chave a mais no
`orm_default` (`wrapperClass`), a troca que muda outra chave (um `__LINE__` depois do bloco), o destino novo com a
permissão (0640) e o grupo do guardado, e a avaliação que morre sem deixar o temporário (o `TMPDIR` do ensaio vazio no
fim). (5) O `D329VerificarConectaPeloDriverTest` tem 4 casos nos dois bancos: o verificador com `php -n` (sem extensão do
ini) sai 1 com a frase da extensão, e o arquivo com toda chave preenchida e `--sem-conexao` sai 0 sem "Deploy liberado".
Mutantes vermelhos e devolvidos: S1 (o laço da outra conexão arrancado), S2 (o da chave a mais), R1 (a conferência do resto),
C1 (o `chmod` do destino novo), G1 (o `chgrp`), L1 (sem a função de encerramento), nas cópias, contra o ensaio; V2 (sem o
fatal da extensão) e V3 (sem a ressalva no fim), no verificador de verdade, nos dois bancos, devolvido pelo sha1. **O que
fica:** o grupo do destino novo não se mede no contêiner (o root não tem outro grupo; o ensaio avisa e mede no Mac e no
CI); e o guardado que use `__DIR__` fora do bloco faz recusar (o temporário fica no `/tmp`, e o `__DIR__` muda) -- a
recusa diz o nome da chave, e o operador leva a mão, na janela.

### D330 — O deploy de teste ainda não confere o Asaas nem o cron vazios, e três comentários dele os dão como escritos no código — REGISTRADO em 03/10/2026

Achado pela revisão do SEGREDOS (D3/D4). Desde aquele dia a chave do Asaas e o token das rotinas
**só existem no `local.php`** -- e o ambiente de teste não mede se o dele os tem:

- **O `conferir` do `scripts/deploy-teste.sh`** acusa como erro uma credencial de produção
  preenchida no `local.php` do teste, mas a lista dele tem só as duas do OneSignal, `sms.usuario`,
  `zoomtec.bearer_token` e as três do Yungas. `asaas.api_key` e `cron.token` não estão nela. Um
  `local.php` copiado da produção -- já com a chave rotacionada e o token -- passa o `conferir`, e o
  risco fica só com as camadas do nginx e do `extra_hosts` (`api.asaas.com` no loopback). O
  `ambiente-teste.md` diz que o deste ambiente não as tem; nada mede isso.
- **Três comentários** ainda dizem que o código traz a chave e o token: o cabeçalho do
  `scripts/deploy-teste.sh`, o `docker-compose.teste.yml` (linhas 12-14) e o
  `docker/nginx/teste-host.conf.modelo` (linhas 65-66).

**Por que não foi feito junto:** os três arquivos estão em `FERRAMENTAS` e `CAMADAS` do
`deploy-teste.sh`, e ficam congelados enquanto a cópia do teste sobe no BVIP -- mudar a carga troca
o que o servidor confere.

**Ao resolver**, depois que a cópia estiver de pé: acrescentar `asaas.api_key` e `cron.token` à
lista do `conferir` (vazias, ou o `cron.token` com um valor só do teste, nunca o da produção) e
acertar os três comentários; falsificar o `conferir` com um `local.php` de ensaio que traga as duas.

### D331 — O crontab de produção não lê a resposta das rotinas, e manda o token na URL: o 401 do D4 só aparece no log do nginx — REGISTRADO em 03/10/2026 — PREPARADO em 07/11/2026: sem e-mail, o logger nas linhas

Achado ao fechar o D4 (03/10/2026). Desde aquele dia a recusa do token é **401** em JSON, e o token
vale por `Authorization: Bearer` além da query. O código ficou pronto; o lado do crontab é da produção:

- **O crontab chama por `curl -s` e não lê a resposta** -- o levantamento de 27/08
  (`docs/f1-6-agendamento.md`) e a linha modelo do `docs/crontab-a-aplicar.md`, que a joga em
  `/dev/null`. Com o token errado ou o `cron.token` vazio, as rotinas param **com 401** -- e o 401
  só aparece no log de acesso do nginx. Ninguém recebe aviso: era o silêncio do D4, um degrau
  acima (agora há onde olhar).
- **As 25 linhas mandam o token na query**, então ele segue no log de acesso, uma linha por chamada
  -- `npsNotificacoes` roda a cada 5 minutos.

**Por que não foi feito junto:** as duas coisas mudam o crontab de produção, que só se toca na
migração final, por decisão sua de 11/09. O código aceita as duas formas justamente para o corte não
depender disso.

**Ao resolver**, no passo 9 do corte (ou depois): trocar cada linha para
`curl -fsS -m <limite> -H "Authorization: Bearer <cron.token>" "https://<domínio>/api/..."` -- o `-f`
faz o 401 virar código de saída diferente de zero, e com `MAILTO` (ou um `|| logger`) o cron avisa; o
`-H` tira o token da URL. Conferir com o `coletar-producao.sh`, que desde o D4 redige também o
`Bearer`. A pergunta, com as opções, está no lote de 03/10.

**Até lá, onde se olha** é a conta do passo 9 do `docs/procedimento-de-corte.md`, refeita na revisão
do D4 (03/10/2026): a de antes lia só o `access.log` e dava 0 com as rotinas paradas.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 5): a opção (b), "curl -fsS com o
token no cabeçalho, e MAILTO" -- o token sai da URL e do log, e o cron avisa quando receber 401 ou 500.
Entrou no roteiro do corte. No passo 9, um `awk` refaz as linhas a partir do crontab guardado no passo
1: o `-s` vira `-fsS`, o `?token=` vira `-H "Authorization: Bearer <token>"`, o `2>&1` sai (o erro chega
ao cron), o `-m` de cada linha fica, e o crontab ganha `MAILTO` -- com o token lido do `local.php` do
passo 7, nunca impresso, e uma conferência só de contagens antes do `crontab`. O passo 7 passou a ser
onde a rotação da linha 9 do passo 5 do dia 0 acontece, se ainda não aconteceu (`sh
scripts/sortear-token-cron.sh`): a forma nova vai junto com o valor novo. A conta do log do passo 9
perdeu o `token=` do filtro, que não acharia pedido nenhum com o token no cabeçalho, e a linha de
`docs/crontab-a-aplicar.md` passou à forma nova. **Provado:** o bloco do passo 9, tirado do próprio
procedimento e rodado sobre um crontab inventado, com dublês de `php`, `curl` e `crontab`, no Git Bash
(bash e gawk) e no contêiner da aplicação (dash e mawk), dá o mesmo crontab byte a byte -- o token velho
em linha nenhuma, o novo nas seis linhas convertidas (a comentada também), o arquivo em `-rw-------` --,
e a conferência acusa a linha com o `-sm` colado (5 curl, 4 `-fsS`, 4 `-m`). A conta do log, com logs
inventados na forma do cabeçalho: `3 " 200 "` com o token certo em dois vhosts, `2 " 401 "` com o
errado, nada sem pedido; o filtro de antes não via nada no log certo. **Não provado:** a forma real das
25 linhas de produção, que não estão no repositório por terem o token -- a conferência é o que pega a
linha fora do molde --; e que o servidor de produção entrega o e-mail do cron. O endereço do `MAILTO` é
pergunta no lote. O que resta deste débito: o passo na janela.

**A revisão de 06/10/2026 achou que a conferência não segurava nada.** Ela só imprimia as contagens, e o
`crontab` vinha na linha seguinte do mesmo bloco, sem `if` nem `exit`: quem colasse o bloco inteiro com
o `cron.token` vazio instalava `Authorization: Bearer ` vazio nas 25 linhas, com o `MAILTO` no molde --
as rotinas recebendo 401 e nenhum aviso saindo, até a conta dos 10 minutos. O ensaio do rascunho provava
que a conferência acusava (`0 Bearer`), e terminava chamando o dublê do `crontab`. O passo 9 tem agora
dois blocos: o primeiro gera o arquivo, e o segundo confere e **só instala se a conferência bater** --
todo `curl` com `Bearer`, `-fsS` e `-m`, nenhum `token=`, nenhum erro descartado, um `MAILTO` com
endereço (`if awk ... exit !(...)`); é o segundo que se roda de novo depois de corrigir uma linha à mão.
`sh scripts/ensaiar-crontab-do-passo-9.sh` tira os dois blocos do procedimento e roda o mesmo texto
sobre um crontab inventado de 25 linhas: o caso certo instala (o token novo nas 25, `token=` e o token
velho em nenhuma, o `MAILTO` na primeira), e o token vazio, o `MAILTO` no molde e quatro linhas fora do
molde (o `-sm` colado, o `--silent`, a linha sem `-m`, o `2>/dev/null`) e um `token=` fora da URL não
instalam -- no Git Bash (bash e gawk) e no contêiner da aplicação (dash e mawk). Com o bloco de antes,
três desses instalavam. Tirar da condição o `Bearer`, o `MAILTO`, o `-fsS`, o `-m`, o `token=` ou o erro
descartado, pôr o `crontab` fora do `if` ou tirar o `exit`: vermelho em cada um.

**Decidido pelo dono em 07/10/2026** (pergunta 31 da página do lote): a opção (a), "Um endereço de
e-mail, com um teste de envio antes da janela" -- "Escreva o endereço no campo abaixo." **O endereço
falta: o campo veio vazio.** O procedimento ganhou o teste em 08/10/2026: o 8 de "O que hoje bloqueia",
no dia 0, antes da janela, no servidor de produção -- se o `/usr/sbin/sendmail` existe, quantos avisos o
cron já jogou fora (`No MTA installed`), e um e-mail de teste ao endereço, com a tabela do que cada
resultado quer dizer; sem programa de e-mail, instalar um é mudança na produção, e volta ao dono. O lugar
do endereço é um só, marcado `<o e-mail que recebe o aviso das rotinas>` no teste e no `CRON_MAILTO` do
passo 9, com o comentário de que ele falta; nenhum endereço foi inventado, e a conferência do passo 9 não
instala o crontab com o molde (`sh scripts/ensaiar-crontab-do-passo-9.sh` segue verde: o caso "MAILTO no
molde" não instala). **Não provado:** o servidor de produção, que nunca foi lido daqui, e que o e-mail do
teste e o do cron saem iguais (o cron põe outro remetente). O que resta deste débito: o endereço, o teste
no dia 0 e o passo na janela.

**A revisão de 08/10/2026 achou duas coisas no teste, e nenhum portão o lia.** Com o endereço no molde, ele
mandava a mensagem a `<o e-mail ...>`: nada chegava, e a tabela levava a "relay, SPF, a caixa", o
diagnóstico errado. E o `grep -h 'No MTA installed' /var/log/syslog*` lia o girado `.gz` como binário, e a
contagem dos avisos descartados saía menor. Agora o bloco só manda com um endereço (`?*@?*`, sem `<`, `>`
nem espaço) e diz "NAO ENVIADO" com o molde, e conta pelo `zgrep -h`, que lê o texto e o `.gz`. E
`sh scripts/ensaiar-crontab-do-passo-9.sh` passou a rodar também este bloco, tirado do procedimento depois
de "**O 8, o teste do e-mail do cron**", com um `sendmail` e um `journalctl` de mentira e um log do
sistema inventado: com o molde não manda, preenchido manda ao endereço, e a contagem dá 5 (duas linhas no
log atual, uma no girado, uma no `.gz`, uma no journal). **Falsificado** numa cópia do procedimento: o
bloco de antes (3 vermelhos: mandou, não disse NAO ENVIADO, contou 4), só o `grep` no lugar do `zgrep`
(1), e o bloco apagado (saída 2: não consegui medir -- nunca verde). Um mutante ficou verde, e é
equivalente: tirar da guarda o `<`, o `>` e o espaço não muda nada, porque o molde não tem `@` e cai no
"não é um e-mail". Verde no Git Bash e no contêiner (dash).

**Sem e-mail, com o aviso no log do sistema — feito em 07/11/2026 (a fatia 113; o item 4 de "O que falta" da etapa 1).**
A resposta 128 (b) do dono (12/10/2026), *"Sem e-mail: o aviso vai ao log do sistema"*, mudou a 31: o endereço nunca veio, e
com ele a conferência do passo 9 não instalava nunca. Agora:

- o primeiro bloco do passo 9 escreve `MAILTO=""` (o cron não manda a saída a lugar nenhum), troca o `www` pelo domínio
  raiz (a 194 (a)) e põe no fim de cada linha `|| logger -t rotinas "falhou (curl $?): <a rota>"`: o `-f` faz o 401 e o
  500 saírem com o código 22, o tempo esgotado com o 28, e o código e a rota vão ao log do sistema (`journalctl -t
  rotinas`). **A linha da apuração dos royalties** (as 05:25) guarda a resposta e manda também o `aviso` dela ao log
  (`R=$(curl ...) || logger ...; case $R in *aviso*) logger -t rotinas "<a rota>: $R" ;; esac`) — o resto do **D364**;
- a conferência do segundo bloco só instala com o `logger` em todo `curl`, todo `curl` no domínio raiz, nenhum `%` (o cron
  o troca por fim de linha), nenhum `MAILTO` com endereço e **uma** linha dos royalties com o aviso — além do que já
  exigia (o `Bearer`, o `-fsS`, o `-m`, nenhum `token=`, nenhum erro descartado);
- o 8 de "O que hoje bloqueia" passou do teste do e-mail ao do log do sistema: o `logger` existe, escreve com a marca
  `rotinas`, e a linha aparece no journal ou no `/var/log/syslog*` (o `zgrep` lê também o girado);
- `docs/crontab-a-aplicar.md`: as três linhas (os convênios e as duas limpezas do **D26**) na mesma forma, no domínio raiz;
- a etapa 1 (`procedimento-de-corte-etapa-1.md`): os itens 3 e 4 de "O que falta", o 8 de "pronto", o passo 12, a volta, e
  o que a etapa 2 herda; e, da verificação da 113, a leitura 20 (que ainda mandava contar o `No MTA installed` do 8 de
  antes e dizia o aviso "a escrever") e o `dia0-remediacao-producao.md` (o 9 da tabela e o texto do D4 ainda diziam
  `MAILTO`).

**Provado** pelo `sh scripts/ensaiar-crontab-do-passo-9.sh`, no Mac (bash e gawk) e no contêiner (dash e mawk): o caso certo
instala (o token novo nas 25 linhas, o `www` e o `token=` em nenhuma, o `logger` nas 25, o `MAILTO=""` na primeira, a linha
dos royalties com o `case`); não instalam o token vazio, as quatro linhas fora do molde, o `token=` fora da URL e, corrigidas
à mão entre os dois blocos, a linha sem o `logger`, com outra marca, no `www`, com `%`, o `MAILTO` com endereço, os
royalties sem o aviso e os royalties duas vezes; **e as linhas instaladas rodam** com um `curl` e um `logger` de mentira (a
que dá certo cala; a do 401 escreve `falhou (curl 22): /api/cronjobs/rotina4`; a dos royalties escreve a resposta com
`aviso`, nada sem ele, e `falhou (curl 28)` no tempo esgotado). O 8: com o `logger` escreve e conta 2 (o log e o journal);
sem ele diz `SEM O logger` e conta zero; e **a linha de um teste de antes não conta** (a verificação da 113: o 8 roda antes
da etapa 1 e de novo antes da 2, e a contagem do texto fixo achava a linha do primeiro no log, e dava "chegou" com o caminho
do `logger` quebrado depois — o verde falso que o D331 existe para impedir). O bloco marca a linha com a hora e o número do
processo (`MARCA=...$(date +%s)-$$`) e conta só ela (`grep -cF "$MARCA"`); o ensaio roda o 8 duas vezes no mesmo log, a
segunda com um `logger` que sai 0 sem escrever, e exige `ESCRITO` e zero. **Falsificado** numa cópia do procedimento, cada um vermelho: a conferência sem o
`logger`, sem o domínio raiz, sem o `%`, sem o `MAILTO`, sem os royalties e sem o aviso deles; o primeiro bloco sem o
`logger`, sem a troca do `www`, sem o aviso dos royalties, sem o `MAILTO=""` e com o `MAILTO` de antes; o 8 com `grep` no
lugar do `zgrep` (lido sem os comentários: a linha de agora nunca está num `.gz`, e nenhuma contagem o pega) e sem o aviso do
`logger` que falta (e sem o journal, saída 2: não consegui medir); e, da verificação da 113, o 8 contando o texto fixo, a
marca sem a hora e o número, e o `logger` escrevendo o texto fixo. **Não provado:** a forma
real das linhas da produção (a conferência é o que pega a fora do molde), que o log do sistema de lá guarda o que o `logger`
escreve (o 8, no servidor, antes da janela), e quem o lê depois (a 128 (b): *"alguém tem de olhar o log"*). A rotina que
responde 200 com `sucesso: false` (a trava detida) não vira aviso. **O que resta deste débito:** o 8 no servidor e o passo
na janela da etapa 1.

### D332 — A apuração do incidente LGPD procura só em `access.log*`, e o site tem log próprio por vhost: "nenhum acesso externo" pode ser o arquivo errado — REGISTRADO em 03/10/2026 — CORRIGIDO em 08/11/2026

Achado de passagem na revisão do D4 (03/10/2026), sem conserto: está fora daquele item, e o
roteiro roda na produção.

O **passo 2 do dia 0** (`docs/dia0-remediacao-producao.md`, a apuração de quem baixou o
`clientes.txt`), o **passo 6** (quem chamou o `migrar-senhas`) e o **`scripts/remediacao-dia0.sh`**,
que os automatiza, procuram só em `/var/log/nginx/access.log*`. O roteiro escreve "Nenhum acesso
externo registrado" quando não acha nada -- e o próprio texto dele diz que **é essa ausência que
sustenta a decisão de não notificar a ANPD**.

Mas o site tem **log próprio por vhost**: é o registro do **D36** -- a coleta que olhou só
`access.log*` achou **zero** pedidos a `/api/versao/app`, chamado a cada abertura do aplicativo, e
a que varreu `*.log` achou 11.032. Se os pedidos do site não estão no `access.log*`, a apuração acha
zero com ou sem acesso externo: é a comparação contra o vazio, no lugar onde ela mais custa. O
`scripts/inventariar-versoes-app.php --dir=` tem o mesmo glob.

**O que não sei:** o nome dos arquivos de log na produção -- só o D36 e o comentário do
`scripts/coletar-producao.sh`, que por isso varre `$LOGS_NGINX/*.log` e `*.log.*`.

**Ao resolver:** varrer `*.log` e `*.log.*` (como o `varrer()` do `coletar-producao.sh`), e
**exigir o controle positivo** antes de escrever "nenhum acesso": a mesma busca, sobre uma rota
que todo dia recebe pedido (`/api/versao/app`), tem de achar mais que zero -- senão o relatório diz
"não li o log do site", e não "ninguém acessou".

**CORRIGIDO em 08/11/2026 (a fatia 128, ROTEIROS-DO-CORTE-E-DA-CARGA).** O `scripts/remediacao-dia0.sh` varre
todo `*.log` e `*.log.*` de `$LOGS_NGINX` (o `.gz` pelo `zgrep`, arquivo por arquivo; os de erro ficam de fora: não
registram pedido atendido, e a primeira coluna deles é a data), procura o termo como texto (`-F`), e exige o **controle
positivo**: `/api/versao/app` tem de aparecer. O relatório (`docs/incidente-lgpd-dia0.md`) passa a dizer os arquivos
lidos e o número do controle; com o controle em zero ele diz "NÃO LI O LOG DO SITE", escreve "Não apurado" no termo sem
retorno (e nunca "Nenhum acesso externo"), a conclusão diz que não há conclusão, e o roteiro sai com **2**. Os passos 2 e
6 de `docs/dia0-remediacao-producao.md` mandam a mesma busca, com o controle antes. O
`scripts/inventariar-versoes-app.php --dir=` lê todo `*.log` e `*.log.*` (menos os de erro) e, sem nenhuma chamada a
`/api/versao/app`, diz "NAO LI O LOG DO SITE" e sai 1. **O ensaio** `scripts/ensaiar-varredura-dos-logs.sh` (no
`conferir-tudo.sh` e no CI), com logs inventados (endereços de documentação) e uma raiz de mentira: o log do vhost achado
(o `clientes.txt` externo), o `.gz` lido (o `migrar-senhas`), o 127.0.0.1 e o log de erro fora, o controle no relatório; o
diretório sem o log do site dando "não li" e saída 2 (e 1 no inventário). Mutantes, nas cópias, vermelhos: o roteiro do
HEAD, o glob antigo (`access.log*`), o controle arrancado, e os mesmos três no inventário. **Não rodou na produção:** a
apuração roda lá, pelo dono, e o nome dos arquivos de log de lá continua não lido daqui. O `varrer()` do
`coletar-producao.sh`, que este conserto tomou por molde, conta em dobro quando falta o rotacionado: o **D743**.

**A correção da verificação (08/11/2026, a fatia 128).** Dois furos do contrato novo, consertados: (1) **o diretório que
não existe** (o `LOGS_NGINX` digitado errado, ou o servidor sem ele) só imprimia o aviso -- não gravava relatório (o de uma
rodada anterior ficava), não contava achado e saía 0, e com o passo 1 e o 6 limpos o resumo dizia "Nada pendente nos
passos 1, 2 e 6". Agora a apuração segue com zero arquivos: o relatório diz que o diretório não existe e "NÃO LI O LOG DO
SITE", troca o da rodada anterior, e o roteiro sai **2**. (2) **O arquivo que não se lê contava como lido:** a lista dos
"Arquivos lidos" era a dos que existem, a busca cala o erro do `zgrep`, e o controle positivo é um só, somado sobre todos
-- o `.gz` da semana do incidente truncado, ou sem permissão para quem roda, saía como lido, e a conclusão cobria o período
dele. Agora cada arquivo é conferido antes (o `gzip -t` no `.gz`, que lê o arquivo inteiro: o `zgrep` do macOS sai 0 no
truncado; o `[ -r ]` e a leitura no comum; a compressão que o `zgrep` não abre, `.bz2`, `.xz`, `.zst`, fica de fora): o
que não se lê vai à lista "Arquivos que NÃO li" do relatório, fora da busca e da contagem, o termo sem retorno diz "nos
arquivos lidos", a conclusão diz "Nenhuma conclusão sobre o período inteiro" (e, com acesso achado, que a contagem pode
ser maior), e o roteiro sai **2**. Os passos 2 e 6 do documento ganharam a mesma conferência à mão (um laço com o `gzip
-t`, que imprime `NAO LIDO`) e o `zgrep` sem o `2>/dev/null`. O ensaio passou a 6 casos: o diretório que não existe (com o
relatório da rodada anterior no lugar) e o `.gz` truncado com um `.bz2`. Mutantes, nas cópias, vermelhos: D1 (o diretório
que não existe saindo 0), D2 (o `.gz` sem o `gzip -t`), D3 (o `.bz2` lido), D4 (sem a saída 2 dos não lidos), D5 (a
conclusão antiga com arquivo não lido). O ensaio rodou no Mac e no contêiner (dash e o `zgrep` do GNU). **O que fica:** o
inventário (`inventariar-versoes-app.php`) só avisa o arquivo que não abre (o `.gz` truncado entra pela metade, e conta
em "Arquivos lidos") -- ele não sustenta a decisão da ANPD, e o controle dele segue somado.

### D333 — A API de unidades grava a taxa de royalty de 3,5% como 35% e o ticket médio cem vezes maior, e todo PUT apaga o ID da Yungas — REGISTRADO em 03/10/2026 — CORRIGIDO em 16/10/2026

Achado pelo desenho da tela de Unidades (02/10/2026; `docs/f3-telas-restantes/tela-unidades.md`, U1 a
U3), e reconferido no código em 03/10/2026. **Latente:** nenhuma tela chama `PUT /adm/api/unidades` --
só `scripts/ensaiar-permissao-adm-api.php`, `scripts/medir-adm-api-no-postgres.php` e
`tests/Caracterizacao/UnidadeApiTest.php` citam o endereço. Por isso tem de sair antes da tela nova.

- **O número cresce na fronteira.** `paraOServico` manda o número cru como float
  (`module/ISConfiguracao/src/Controller/Adm/UnidadesController.php:428-429` e `:455`), e os setters o
  passam por `Monetario::removeMask` (`module/ISConfiguracao/src/Entity/Unidade.php:1080-1095` e
  `:1121`), que apaga o ponto por ser separador de milhar (`module/ISBase/src/Util/Monetario.php:7-15`):
  3.5 vira "35", e 12345.67 vira "1234567". A faixa "entre 0 e 100" é conferida antes da conversão
  (`UnidadesController.php:335`), e o comentário de `:428` ("o setter guarda o que recebe") está errado.
- **O caminho até o dinheiro:** o retrato das 05:25 recalcula ontem e hoje com a taxa do cadastro, e a
  fatura do dia 16 soma o retrato e segue para o Asaas (`module/ISRelatorio/src/Service/FranquiaService.php`).
  O ticket multiplicado vai à meta da Yungas às 23:40.
- **Todo PUT apaga o ID da Yungas.** `UnidadeService::update` grava `setIdYungas(null)` quando a chave
  não vem (`module/ISConfiguracao/src/Service/UnidadeService.php:34`), e a API nunca a manda; a unidade
  sem o ID sai da sincronização das 23:40. E o PUT de corpo vazio grava: atualiza a data, remonta o nome
  e, por isso, também apaga o ID.
- **Medido** (consultas R3, R4 e R19 do desenho): 2 unidades ativas têm 3,5% nas quatro taxas, e uma tela
  que lê "3.50" e devolve o que leu grava 35%; 27 das 32 ativas têm o ID da Yungas; a fixture 90001200
  já tem 3,5 de serviço, e as quatro unidades de fixture estão sem o ID -- o caso do U2 tem de
  preenchê-lo, ou passa sem medir nada.

**O conserto** (a F0 de Unidades, sem decisão): `Monetario::deDecimal` na fronteira para as quatro
taxas, o ticket e a redução, com a faixa conferida no número já convertido; o ID da Yungas só muda
quando a chave vem (o formulário do ERP sempre a manda, e lá nada muda); o PUT vazio responde 400
"Nada a alterar", como nos convênios. O caso: o PUT só da observação e o PUT que devolve o que leu
deixam 3.5 no banco, nos dois bancos.

#### CORRIGIDO em 16/10/2026 -- a F0 de Unidades (a fatia 6 da fila das telas que faltam, UNIDADES-SERVIDOR)

**O conserto, como o desenho pedia.** A conferência e a tradução do corpo saíram do controller para a regra pura
`module/ISConfiguracao/src/Regra/RegraDaUnidade.php`: as quatro taxas e o ticket viram a máscara da casa na fronteira
(`Monetario::deDecimal`: "3.50" chega ao setter como "3,50", que `removeMask` devolve a 3.50), e a faixa de 0 a 100 é
conferida no número já convertido; a redução do potencial, que o setter guarda crua, vai como número.
`UnidadeService::update` só mexe no ID da Yungas quando a chave vem (o nulo explícito apaga; a ausência não). O corpo sem
nada a gravar -- o vazio, o que só traz o `alteradoEm`, o de grupos vazios -- é 400 *"Nada a alterar: mande ao menos um
campo."*, sem tocar na data nem no nome. O recurso ganhou o `idYungas`. Os U4 a U8 ganharam número no mesmo commit: D502 a
D506; os U10 a U12, D507 a D509; e o achado do cadastro, D510.

**Provado** (`UnidadeEscritaApiTest`, em processo, nos dois bancos, na 90001200 das fixtures com o ID da Yungas 777 e o
ticket 12.345,67 postos pelo caso): o `PUT` que devolve as seções que leu deixa 3,5% de serviço, 6% de produto, 0% de Lava
VIP, o ticket 12.345,67 e o ID 777; o só da observação também; o vazio é 400 com a data ainda nula. E no ensaio de recusas,
por HTTP, os casos 155 a 157. O `RegraDaUnidadeTest` confere a tradução (a taxa, o ticket, a faixa, o tipo, os booleanos,
os tetos e a chave que não se grava).

**Os mutantes**, cada um sozinho, com os testes da fatia no MySQL de fixtures (o roteiro aplica, roda, restaura e confere o
sha1 do arquivo):

| mutante | vermelho no MySQL (os testes da fatia) |
|---|---|
| (controle: nada mudado) | nenhum -- 43 casos verdes |
| a taxa vai crua ao setter, sem `deDecimal` | `RegraDaUnidadeTest::testATaxaEOTicketChegamAoSetterComoAMascaraDaCasa`; `UnidadeEscritaApiTest::testOPutQueDevolveOQueLeuMantemATaxaOTicketEOIdDaYungas`; `UnidadeEscritaApiTest::testOCadastroGravaATaxaCerta` |
| o ticket vai cru ao setter | `RegraDaUnidadeTest::testATaxaEOTicketChegamAoSetterComoAMascaraDaCasa`; `UnidadeEscritaApiTest::testOPutQueDevolveOQueLeuMantemATaxaOTicketEOIdDaYungas` |
| a faixa de 0 a 100 sem o teto, no numero convertido | `RegraDaUnidadeTest::testAFaixaDaTaxaEConferidaNoNumero`; `UnidadeEscritaApiTest::testOQueNaoServeE422ComOCampo` |
| o ID da Yungas volta a virar nulo sem a chave | `UnidadeEscritaApiTest::testOPutSoDaObservacaoNaoMexeNoResto` |
| o corpo sem nada a gravar grava | `UnidadeEscritaApiTest::testOCorpoSemNadaAGravarE400` |
| o cadastro da API sem a guarda de administrador | `UnidadeEscritaApiTest::testQuemNaoEAdministradorNaoEscreve` |
| o `PUT` sem a guarda de administrador | `UnidadeEscritaApiTest::testQuemNaoEAdministradorNaoEscreve` |
| `/ativacao` e `/desativacao` sem a guarda | `UnidadeEscritaApiTest::testQuemNaoEAdministradorNaoEscreve` |
| qualquer tipo serve | `RegraDaUnidadeTest::testOTipo`; `UnidadeEscritaApiTest::testOQueNaoServeE422ComOCampo`; `UnidadeEscritaApiTest::testOCadastroGravaATaxaCerta` |
| o teto do numero do endereco a 2000 | `UnidadeEscritaApiTest::testOQueNaoServeE422ComOCampo` (o banco recusa, e a cascata do `EntityManager is closed` em outros quatro); `RegraDaUnidadeTest::testOsTextos` |
| o emoji passa na regra | `UnidadeEscritaApiTest::testOQueNaoServeE422ComOCampo` (o banco recusa, e a cascata do `EntityManager is closed` em outros quatro); `RegraDaUnidadeTest::testOsTextos` |
| sem o 409 do `alteradoEm` | `UnidadeEscritaApiTest::testOAlteradoEmVelhoE409` |
| a leitura da data sem `FOR UPDATE` | `UnidadeEscritaApiTest::testAPrimeiraLeituraDaGravacaoETravada` |
| o editar do ERP volta a gravar o POST cru | `TelasDasUnidadesDoErpTest::testOEditarGravaOQueOFormularioFiltrouPeloIdDaRota`; `TelasDasUnidadesDoErpTest::testOEditarNaoTocaNoEstado` |
| o `id` do corpo vai ao servico | `RegraDaUnidadeTest::testOFormularioDoErpVaiCampoACampo`; `TelasDasUnidadesDoErpTest::testOEditarGravaOQueOFormularioFiltrouPeloIdDaRota` |
| o `status` do formulario vai ao servico | `RegraDaUnidadeTest::testOFormularioDoErpVaiCampoACampo`; `TelasDasUnidadesDoErpTest::testOEditarGravaOQueOFormularioFiltrouPeloIdDaRota`; `TelasDasUnidadesDoErpTest::testOEditarNaoTocaNoEstado` |
| o que o POST traz alem do formulario vai ao servico | `RegraDaUnidadeTest::testOFormularioDoErpVaiCampoACampo`; `TelasDasUnidadesDoErpTest::testOEditarGravaOQueOFormularioFiltrouPeloIdDaRota`; `TelasDasUnidadesDoErpTest::testOEditarNaoTocaNoEstado` |
| o socio de outra unidade serve | `TelasDasUnidadesDoErpTest::testOSocioDeOutraUnidadeEORemovidoSaoRecusados` |
| o socio removido serve | `TelasDasUnidadesDoErpTest::testOSocioDeOutraUnidadeEORemovidoSaoRecusados`; `TelasDasUnidadesDoErpTest::testAPesquisaEARemocaoDoSocio` |
| o editar do ERP sem a guarda | `TelasDasUnidadesDoErpTest::testQuemNaoEAdministradorVoltaALista` |
| o novo do ERP sem a guarda | `TelasDasUnidadesDoErpTest::testQuemNaoEAdministradorVoltaALista` |
| o alternar do ERP sem a guarda | `TelasDasUnidadesDoErpTest::testQuemNaoEAdministradorVoltaALista` |
| os textos da unidade sem a guarda | `TelasDasUnidadesDoErpTest::testQuemNaoEAdministradorVoltaALista` |
| o salvar do socio sem a guarda | `TelasDasUnidadesDoErpTest::testOSocioDeOutraUnidadeEORemovidoSaoRecusados` |
| a pesquisa do socio sem a guarda | `TelasDasUnidadesDoErpTest::testAPesquisaEARemocaoDoSocio` |
| o remover do socio sem a guarda | `TelasDasUnidadesDoErpTest::testAPesquisaEARemocaoDoSocio` |
| o tipo do formulario volta a `disable_inarray_validator` | `TelasDasUnidadesDoErpTest::testOQueOFormularioRecusa` |
| o filtro do ERP sem os tetos e o emoji | `TelasDasUnidadesDoErpTest::testOQueOFormularioRecusa` |
| a lista da API volta a pedir os acessos | `UnidadeEscritaApiTest::testAListaDaApiNaoPerguntaPelosAcessos` |
| o recurso da lista volta a ter o total | `D76AcessosDaPropriaPessoaNasListasTest::testAListaDeUnidadesContaSoAsEntradasDeQuemEDaUnidade`; `D76AcessosDaPropriaPessoaNasListasTest::testQuemSoFoiAssumidoNaoTemAcessoNasListas`; `UnidadeApiTest::testAListagemMoldaOsAgregados` |
| os filtros so das ativas | `UnidadeApiTest::testOsFiltrosSaoDaSituacaoPedida` |
| o alcance conta o administrador | `UnidadeApiTest::testOAlcanceContaQuemDeixaDeEntrarEORoyaltyDoCiclo` |
| o alcance conta a entrada assumida | `UnidadeApiTest::testOAlcanceContaQuemDeixaDeEntrarEORoyaltyDoCiclo` |
| o royalty fora do ciclo entra | `UnidadeApiTest::testOAlcanceContaQuemDeixaDeEntrarEORoyaltyDoCiclo` |
| (na retomada) o `/filtros` sem a recusa de quem não tem sessão | `UnidadeEscritaApiTest::testOsFiltrosPeloEndereco` |
| (na retomada) o `/filtros` sem o privilégio de ler | o portão `conferir-acl-da-api.php` (saída 1); a sessão dublê dos casos sempre lê |
| (na retomada) o `/filtros` aceita qualquer estado | `UnidadeEscritaApiTest::testOsFiltrosPeloEndereco` |
| (na retomada) o `/filtros` sempre das ativas | `UnidadeEscritaApiTest::testOsFiltrosPeloEndereco` |

Na retomada da fatia (o implementador anterior parou antes do relatório), dez dos mutantes acima foram repetidos, um a
um, com o controle, e deram o mesmo vermelho: a taxa sem `deDecimal`, o ID da Yungas sem a chave, o teto do número, o 409,
a trava, o `status` do formulário, o sócio de outra unidade, o alternar do ERP sem a guarda, a lista com os acessos e o
royalty fora do ciclo.

**Na verificação da fatia (16/10/2026)**, doze achados confirmados viraram conserto ou caso, e cada caso novo tem o seu
mutante -- cada um sozinho, com os testes da fatia no MySQL de fixtures (`RegraDaUnidade`, `UnidadeEscritaApi`,
`UnidadeApiTest`, `TelasDasUnidadesDoErp`: 44 casos verdes no controle, repetido antes dos dois do alcance), restaurado e
conferido pelo sha1:

| mutante (na verificação) | vermelho no MySQL (os testes da fatia) |
|---|---|
| a lista sem o `tipo` na consulta | `UnidadeEscritaApiTest::testAListaDaApiNaoPerguntaPelosAcessos` |
| a lista com o WhatsApp sempre desligado | `UnidadeApiTest::testAListagemMoldaOsAgregados`; `UnidadeEscritaApiTest::testAListaDaApiNaoPerguntaPelosAcessos` |
| o cadastro sem exigir o faturamento | `RegraDaUnidadeTest::testOCadastroQueServeNaoMarcaNadaEAAlteracaoConfereSoOQueVeio`; `RegraDaUnidadeTest::testOFaturamentoEObrigatorioNoCadastro`; `UnidadeEscritaApiTest::testOCadastroGravaATaxaCerta` |
| o nulo da unidade nunca alterada é 409 | `UnidadeEscritaApiTest::testOAlteradoEmVelhoE409`; `UnidadeEscritaApiTest::testOPutQueDevolveOQueLeuMantemATaxaOTicketEOIdDaYungas` |
| o novo do ERP volta a gravar o POST cru | `TelasDasUnidadesDoErpTest::testONovoGravaOQueOFormularioFiltrouENasceAtiva` |
| a inauguração com o dia e o mês trocados | `UnidadeEscritaApiTest::testOPutQueDevolveOQueLeuMantemATaxaOTicketEOIdDaYungas` |
| a abertura e o fechamento trocados | `UnidadeEscritaApiTest::testOPutQueDevolveOQueLeuMantemATaxaOTicketEOIdDaYungas` |
| os dias de convênio invertidos | `UnidadeEscritaApiTest::testOPutQueDevolveOQueLeuMantemATaxaOTicketEOIdDaYungas`; `UnidadeEscritaApiTest::testOCadastroGravaATaxaCerta` |
| o sócio administrador do faturamento na coluna do nome | `UnidadeEscritaApiTest::testOPutQueDevolveOQueLeuMantemATaxaOTicketEOIdDaYungas` |
| o `GET` sem o alcance | `UnidadeEscritaApiTest::testOPutQueDevolveOQueLeuMantemATaxaOTicketEOIdDaYungas` |
| o `PUT` sem o alcance | `UnidadeEscritaApiTest::testOPutQueDevolveOQueLeuMantemATaxaOTicketEOIdDaYungas` |
| o `/filtros` sempre das inativas | `UnidadeEscritaApiTest::testOsFiltrosPeloEndereco` |
| o royalty do ciclo sem o limite de hoje | `UnidadeApiTest::testOAlcanceContaQuemDeixaDeEntrarEORoyaltyDoCiclo` |
| o cadastro aceita o `alteradoEm` | `UnidadeEscritaApiTest::testOCadastroGravaATaxaCerta` |
| o padrão do banco vence o corpo | `UnidadeEscritaApiTest::testOCadastroGravaATaxaCerta` |
| o formulário do ERP sem a recusa do `<` e do `>` | `TelasDasUnidadesDoErpTest::testOQueOFormularioRecusa`; `TelasDasUnidadesDoErpTest::testAPaginaDeEditarSalvaComoONavegadorAMandaSemReativar` |
| o bairro do ERP volta a ter o `StripTags` | `TelasDasUnidadesDoErpTest::testOQueOFormularioRecusa`; `TelasDasUnidadesDoErpTest::testAPaginaDeEditarSalvaComoONavegadorAMandaSemReativar` |
| o e-mail do ERP sem a forma da API | `TelasDasUnidadesDoErpTest::testOQueOFormularioRecusa` |
| o `<select>` do tipo sem o tipo que a unidade tem | `TelasDasUnidadesDoErpTest::testOQueOFormularioRecusa`; `::testOEditarGravaOQueOFormularioFiltrouPeloIdDaRota`; `::testOEditarNaoTocaNoEstado`; `::testAPaginaDeEditarSalvaComoONavegadorAMandaSemReativar` |

Os 19, vermelhos; ao todo, 57 mutantes do servidor e três controles.

### D334 — Abrir a lista antiga de sorteios grava a situação de todo sorteio vencido da rede, e o aplicativo depende desse GET — REGISTRADO em 03/10/2026 — ANOTADO em 11/10/2026: vai com a F0 de Sorteios — PARCIAL em 21/10/2026: a situação é calculada e o aplicativo a lê da regra; o sortear do vencido fica com a fatia 25 — CORRIGIDO em 21/10/2026: as duas telas sorteiam o vencido, pela regra e sob trava — PARCIAL em 21/10/2026, na verificação da fatia 25: o GET da lista antiga ainda grava, agora sob a trava (o D559) — CORRIGIDO em 07/11/2026: o GET da lista antiga não grava mais, e o filtro, a coluna e o "Realizar Sorteio" dela leem a regra

Achado pelo desenho de Sorteios (`docs/f3-telas-restantes/tela-sorteios.md`, DS-1). É a forma do D264
dos cupons: a escrita mora dentro do GET da lista antiga.

- **O que acontece:** a lista chama `validarVencimento`
  (`module/ISMarketing/src/Controller/SorteiosController.php:51`), que grava `aguardandoSorteio` em todo
  sorteio `ativo` vencido da rede (`module/ISMarketing/src/Service/SorteioService.php:118-129`; a consulta
  em `module/ISMarketing/src/Entity/SorteioRepository.php:67-74`). O `$unidadeId` passado é ignorado (o
  método do repositório não tem o parâmetro); a troca não grava `data_alteracao`; nenhuma rotina a faz;
  e as duas finalizações exigem o estado já trocado.
- **O risco:** quando o menu abrir a tela nova no lugar da lista antiga, o sorteio que terminar fica
  `ativo` para sempre -- ninguém consegue sorteá-lo, e o aplicativo mostra "Ativo" a quem tem cupom.
- **Medido** (consulta C1): hoje nenhum sorteio está `ativo`; são 2 `finalizado`, 1 `cancelado` e 1
  `aguardandoSorteio` -- o de Dia das Crianças de 2025, parado há 352 dias, que é a pergunta P2 do
  consolidado (`docs/f3-telas-restantes/telas-consolidado.md`).

**O conserto** (a F0 de Sorteios, sem decisão): a situação passa a ser calculada numa regra pura; o
sortear aceita o `ativo` vencido; e as duas rotas do aplicativo tiram o rótulo da mesma regra -- o
`ativo` vencido sai como "Aguardando Sorteio", o texto de hoje, e o contrato do aplicativo não muda. A
lista antiga continua trocando o estado, agora gravando a data.

**ANOTADO em 11/10/2026 (o item RESPOSTAS-SEM-RODADA): o que este débito espera é a F0 de Sorteios, e
ela espera a vez dela.** Pela pergunta 36 da página do lote (07/10/2026), a opção (c), "Não agora:
Sorteios fica na ordem das telas" -- as fatias F0 a F3 de Sorteios não são construídas agora (o D371,
ANOTADO em 08/10 pela mesma resposta). O conserto acima é a F0, e por isso fica para a vez de Sorteios na
ordem das telas, por decisão do dono, e não por falta de resposta nem por congelamento de provedor
(não há nada de OneSignal, visagismo, SMS ou LGPD aqui). O risco
continua o do corpo: ele só vira dano quando o menu abrir a tela nova no lugar da lista antiga, e isso
também é Sorteios. Nada mudou no código.

**PARCIAL em 21/10/2026 (a fatia 24 da fila das telas que faltam, SORTEIOS-LEITURA; a F0 de
`docs/f3-telas-restantes/tela-sorteios.md`).** Duas das três partes do conserto estão feitas:

- **A situação é calculada, num lugar só:** `RegraDoSorteio::situacao(status, inicio, fim, hoje)`
  (`module/ISMarketing/src/Regra/RegraDoSorteio.php`), pura, com o relógio do servidor. A
  `/adm/api/sorteios` nova lê a situação dela -- e a lista e o sorteio aberto NÃO GRAVAM nada (o
  `SorteiosPelaApiTest::testNenhumGetGrava` tira o retrato da tabela antes e depois; o mutante que chama o
  `validarVencimento` no GET novo fica vermelho). O filtro de situação da lista nova e o vencimento da lista
  antiga (`SorteioRepository::validarVencimento`) também passam pela regra, em PHP: a verificação da fatia
  achou os cinco casos reescritos em DQL no filtro, com a fronteira do primeiro dia sem caso, e o SQL próprio
  da lista antiga (`DATE(sor.dataFim) < :data`) -- três escritas da mesma regra. O cenário ganhou o sorteio que
  começa hoje (`recebendo`).
- **As duas rotas do aplicativo leem o rótulo da mesma regra** (`RegraDoSorteio::rotuloDoAplicativo`):
  `AplicativoController::sorteiosSelecionarCuponsAction` e `V4\ClientesController::sorteiosAction`. O
  `ativo` vencido sai "Aguardando Sorteio", sem gravar; o do último dia continua "Ativo". A forma não mudou
  (`status: string`): o contrato do aplicativo, 74 de 74, com os snapshots `*sorteios*` iguais. Provado
  em processo nas duas rotas (`SorteiosNoAplicativoTest`), nos dois bancos.
- **A lista antiga continua trocando o estado, agora gravando a data** (`SorteioService::validarVencimento`
  chama `setDataAlteracao`; `SorteiosPelaApiTest::testAListaAntigaTrocaOEstadoEGravaAData`).

**O que resta: o sortear aceitar o `ativo` vencido.** As duas finalizações da tela antiga ainda exigem
`aguardandoSorteio` (`SorteiosController.php:203`, `:256`). Não há travamento hoje: o menu abre a tela nova,
que só lê; a lista antiga continua no ar pelo endereço -- a tela nova não aponta para ela (o cadastrar de lá
o dono adiou, a resposta 1 (c); o sortear e o informar de lá ainda põem um aviso automático na fila, que ele
tirou, a 217 (c)) --, e abri-la continua passando o vencido a `aguardandoSorteio` antes do sortear. O sortear sob
trava, que aceita o vencido pela mesma regra, é a fatia 25 da fila (SORTEIOS-SORTEAR, a F2 do desenho).

**CORRIGIDO em 21/10/2026 (a fatia 25 da fila das telas que faltam, SORTEIOS-SORTEAR; a F2 do desenho).** A parte que
restava está feita: **o sortear aceita o `ativo` vencido**, pela mesma regra. `SorteioService::sortear` e
`::informarGanhador` -- as duas telas passam por eles -- aceitam o que a `RegraDoSorteio::situacao` diz `faltaSortear`, com
o "hoje" de quem chama (o relógio do servidor; o do caso nos testes): o `aguardandoSorteio` e o `ativo` cujo último dia
passou. As duas finalizações da tela antiga deixaram de exigir o estado gravado. Assim, o sorteio que terminar com a tela
nova no lugar da lista antiga se sorteia -- e o aplicativo já o mostra "Aguardando Sorteio" pela regra (a F0). A lista
antiga continua trocando o estado do vencido ao ser aberta, agora com a data (o precedente dos cupons); isso não prende mais
o sortear (mas veja o PARCIAL abaixo: a troca disputava a linha com ele). **Provado** nos dois bancos: `SorteiosSortearPelaApiTest::testOAtivoVencidoSeSorteiaEntreAsVendasAtivas` (o `ativo`
vencido sorteado pela API), `::testAmanhaODoUltimoDiaSeSorteia` (o do último dia, com o relógio de amanhã),
`::testOQueNaoFaltaSortearE409` (o do último dia ainda recebe: 409) e
`TelasDosSorteiosDoErpTest::testOSortearDaTelaAntigaNaoAvisaEEscapaONome` (o `ativo` vencido pela tela antiga); pelo HTTP, o
caso 15 do `ensaiar-sorteios-adm-api.php`. Mutantes: o `recebendo` aceito, o "hoje" cru no lugar do relógio do controller e
a tela antiga voltando a exigir o `aguardandoSorteio` -- vermelhos.

**PARCIAL em 21/10/2026 (a verificação da fatia 25).** O título continua verdadeiro: **abrir a lista antiga ainda grava** --
o conserto acima a deixou trocando o estado, de propósito, e a tela nova não aponta para ela. E a verificação achou que essa
escrita, com o sortear aceitando o `ativo` vencido, **podia desfazer um sorteio recém-feito**: a lista lia o vencido sem
trava, o sortear da outra aba gravava o ganhador e `finalizado`, e o `UPDATE` da lista, que só esperava a trava, gravava
`aguardandoSorteio` por cima -- o sorteado voltava a "falta sortear", com o ganhador, e podia ser sorteado de novo (o D559,
consertado: a troca relê a linha dentro da trava). **O que resta:** a escrita em si, no GET da lista antiga; ela sai com a
lista antiga, quando o menu deixar de chegar a ela.

**CORRIGIDO em 07/11/2026 (a fatia 112 da fila dos débitos de pé, MARKETING-CAMPANHAS-E-SORTEIOS): a escrita saiu antes da
lista antiga.** Ela era redundante desde a fatia 25 -- a situação é calculada em todas as leituras (as duas listas e as duas
rotas do aplicativo) e o sortear aceita o `ativo` vencido --, e tirá-la não muda regra; a lista antiga continua no ar pelo
endereço, e por isso o filtro, a coluna e o botão dela passaram a ler a mesma regra:

- `SorteiosController::indexAction` não chama mais o `validarVencimento`. O `SorteioService::validarVencimento`, o
  `::passarAAguardando` e o `SorteioRepository::validarVencimento` saíram (nenhuma outra porta os chamava), e com eles a
  disputa da linha com o sortear (o D559): o estado do sorteio vencido só muda por quem aperta o botão.
- **O filtro de "Status"** passa pela `RegraDoSorteio::entraNoFiltroDaListaAntiga`, em PHP, como a lista nova (o banco
  filtra só o nome): "Aguardando Sorteio" mostra o `aguardandoSorteio` gravado E o `ativo` vencido; "Ativo", o que ainda não
  começou e o que recebe até o último dia; sem filtro, todos menos o cancelado. As opções são as quatro de sempre.
- **A coluna** sai da `rotuloDaListaAntiga` e **o "Realizar Sorteio"** da `faltaSortearNaListaAntiga`: o `ativo` vencido é
  "Aguardando Sorteio" e tem o botão, sem gravar nada.

**O que muda para quem usa:** nada que se veja -- a lista mostra o que mostrava depois de gravar. O que muda é o banco: o
sorteio que vence fica `ativo` gravado até ser sorteado ou cancelado (na cópia, 6 sorteios, nenhum `ativo`). Nenhum leitor do
sistema olha mais o `aguardandoSorteio` gravado sem a regra (grep em `module/`: a regra, o rótulo e a finalização da venda,
que só dá cupom ao `ativo` dentro da janela); quem ler a tabela crua fora do sistema verá `ativo`.

**Provado** nos dois bancos, no `TelasDosSorteiosDoErpTest` (o controller despachado em processo e a lista renderizada):
`testNenhumGetDaListaAntigaGrava` (o retrato da tabela antes e depois de abrir a lista sem filtro e com cada um dos quatro;
o vencido continua `ativo`, sem `data_alteracao`), `testOFiltroDaListaAntigaEDaRegra` (os sete jeitos de um sorteio estar,
em cada filtro) e `testAColunaEOBotaoDoVencidoSaoDaRegra` (o vencido com cupom diz "Aguardando Sorteio" e tem as duas
finalizações; o do último dia, com cupom, diz "Ativo" e não tem). Os casos que mediam a troca saíram com ela: dois do
`SorteiosPelaApiTest` e dois do `SorteioSobTravaTest` (o D559). **Mutantes**, um por vez e devolvidos pelo sha1: a escrita do
vencido de volta no GET (vermelho nos dois bancos), o filtro "Ativo" com o vencido, o filtro pelo estado gravado (vermelho
nos dois), a coluna pelo estado gravado e o botão só no `aguardandoSorteio` gravado. **Não visto num navegador.**

**A paginação (07/11/2026, a verificação da fatia 112).** Com o filtro em PHP, o `listagemIndex` deixou de paginar no
banco (o `AdapterQuery`) e pagina o que a regra deixou passar (`Laminas\Paginator` sobre a lista, de 20 em 20, a página do
pedido), e nenhum caso pedia a página. Agora o `TelasDosSorteiosDoErpTest::testAPaginacaoDaListaAntigaEDepoisDoFiltro`, nos
dois bancos: 22 `ativo` vencidos e 3 que não começaram -- no filtro "Aguardando Sorteio", 22 no total, os 20 mais novos na
página 1 e os 2 mais antigos na 2; sem filtro, 25, e a página 2 com os 5 vencidos mais antigos; em "Ativo", os 3, numa
página. **Mutantes**, um por vez e devolvidos pelo sha1: a página pedida ignorada (vermelho nos dois bancos) e a página de
10 (vermelho no MySQL). O custo: a lista antiga lê do banco todos os sorteios da rede que casam com o nome, a cada página
(na cópia, 6).

### D335 — Remover um molde da tela de grupos é um link: um GET tira uma funcionalidade de até 177 pessoas em 44 unidades — CORRIGIDO em 04/10/2026

Achado pelo desenho de Grupos e privilégios (`docs/f3-telas-restantes/tela-acl.md`, D-ACL-1). É uma das
ações do D199, as remoções por GET, agora com o alcance medido -- o maior de todas elas.

- `removerAction` não confere `isPost()`
  (`module/ISConfiguracao/src/Controller/PrivilegiosController.php:139-153`), e a lista a chama pelo
  `modal_remover`, que só troca o `href` do "Sim"
  (`module/ISConfiguracao/view/is-configuracao/privilegios/index.phtml:99`).
- **Medido** (consulta C9): o molde 159 (grupo Colaborador, "Assistente IA - Usuários") apaga 177 linhas,
  de 177 pessoas ativas, em 44 unidades; os cinco moldes de maior alcance são do grupo 1 e apagam de 173
  a 177 linhas cada. O levantamento dizia 516: são as linhas em dia do grupo inteiro, com os inativos, e
  `removerEmDia` só percorre os ativos.
- O `SameSite=Lax` do D198 não protege: o Lax manda o cookie na navegação de topo por GET. Quem alcança
  a ação: os 10 administradores.

**O conserto** (a F0 de Grupos, sem decisão): a ação só aceita POST, a tela chama `modalRemoverPorPost`,
o "Sim" vira formulário, e a trava `REMOCOES_POR_GET_ESPERADAS` de
`scripts/conferir-remocao-por-post.php:54` desce um no mesmo commit.

#### CORRIGIDO em 04/10/2026 -- o item GRUPOS-F0

**O conserto:** `PrivilegiosController::removerAction` recusa o GET -- e quem não escreve -- com 404,
antes de qualquer coisa; a lista chama `modalRemoverPorPost`, e o "Sim" do modal é
`<form id="form_remover_post" method="post">`. O molde que não existe também é 404, e não 500 (o D396):
o segundo envio encontra o molde já removido. A trava `REMOCOES_POR_GET_ESPERADAS` desceu de 2 para 1
(o D199 a tinha levado de 13 a 2) -- fica o "Remover" dos banners, que é decisão. Medido de novo em
04/10/2026 no `franquiabv_dev`: o molde 159 continua o de maior alcance, 177 pessoas ativas em dia, em
44 unidades.

**Provado** nos dois bancos (`PrivilegiosDoErpTest`, o controller despachado em processo e a lista
renderizada): o GET dá 404 com o molde e a linha da pessoa intactos; o POST remove o molde e a linha de
quem estava em dia; o segundo POST, 404; quem só lê, 404; a lista tem o X por POST, o "Sim" como
formulário e a pergunta de confirmação. Mutantes, um de cada vez: a ação aceitando GET, o X de volta ao
`modal_remover`, o "Sim" de volta a link -- vermelhos no caso; e o portão
`conferir-remocao-por-post.php` vermelho nos três e na trava de volta a 2. **Nada visto no navegador.**

**A revisão (04/10/2026): o remover é só da mesma origem.** O POST bastava contra o link de outro site,
que chega sem o cookie; mas o de outro subdomínio do mesmo site chega com ele -- o `SameSite=Lax` não os
separa (o D198) --, e o remover não conferia nem origem nem token. A janela de privilégios da pessoa e o
"Autenticar como" já tinham a barreira; este remover alcança o grupo inteiro. Agora `removerAction`
passa por `OrigemDoPedido::recusaDoPedido` depois da guarda do POST: `Sec-Fetch-Site` que não é
`same-origin`, ou o `Origin` de outro host, é 403 com a frase da barreira, antes de ler o molde. O "Sim"
da lista é da mesma origem. **Provado** nos dois bancos (`PrivilegiosDoErpTest`): outro site, outro
subdomínio e o navegador que só manda o `Origin` -- 403, com o molde e a linha de quem estava em dia
intactos; o controle da mesma origem (com um `Origin` de outro host, que o `Sec-Fetch-Site` vence)
remove. O mutante sem a barreira: os três vermelhos. A escolha entre barreira e token para os botões do
ERP segue no lote do dono (a pergunta do TRAVA-POST-PAIS, com a recomendação da barreira); esta é a
mesma barreira, e não impede o token depois.

### D336 — Reativar alguém devolve a funcionalidade de rede que ele tinha, sem passar pela regra que proíbe o gerente de concedê-la — CORRIGIDO em 04/10/2026 — CORRIGIDO em 17/10/2026: a reativação volta com o molde do grupo, e o grupo de rede só o administrador reativa (a resposta 212 b)

Achado pelo desenho de Grupos e privilégios (`docs/f3-telas-restantes/tela-acl.md`, D-ACL-8). A pergunta
P13 do consolidado (com que privilégios a pessoa volta) é do dono; a guarda abaixo vale com qualquer
resposta.

- Reativar não refaz nem confere as linhas de privilégio: `UsuarioService::mudarEstado`
  (`module/ISConfiguracao/src/Service/UsuarioService.php:175-208`) troca o estado e o login, e só. A
  regra 3 do D288 -- só o administrador concede funcionalidade de rede (`RegraDoPrivilegio::DE_REDE`) --
  vale ao conceder e ao pôr no grupo (`recusaDoGrupo`, `UsuarioService.php:524`), e não ao reativar, na
  API nem no ERP.
- **Medido** (consulta C8): 3 inativos do grupo 5, numa unidade ativa, têm "Agendas unidades" -- a agenda
  da rede inteira. O gerente que reativar um deles a devolve sem passar pela regra.

**O conserto** (a F0 de Grupos, "o que pode ser feito já", sem decisão): quem não é administrador não
reativa pessoa com funcionalidade de rede, nas duas portas.

#### CORRIGIDO em 04/10/2026 -- o item GRUPOS-F0

**O conserto:** `UsuarioService::recusaDaReativacao` -- quem não é administrador não reativa uma pessoa
que tem uma linha concedendo (`ler` ou `escrever`) uma funcionalidade de `RegraDoPrivilegio::DE_REDE` --,
chamada nas três portas que reativam: a API (`Adm\UsuariosController`, 403 com `motivo: de_rede`), o
botão da lista do ERP (`ativarRemoverAction`) e o campo "Status" da edição (`editarAction`), com a frase
`RegraDoPrivilegio::FRASE_DA_REATIVACAO`. Desativar não passa pela regra, e o administrador reativa. A
tela nova mostra a recusa como mostra as outras (o `Erro` da troca de estado); ela não foi mudada.

**Ficou de fora, de propósito: a recusa pelo molde do grupo.** O desenho (seção 5, item 6) a punha junto
-- "nem uma pessoa de grupo com molde de rede". Com a reativação de hoje o molde não é reaplicado, e a
recusa pelo grupo só impediria: medido no `franquiabv_dev` em 04/10/2026, o grupo 5 tem 11 inativos, 7
em unidade ativa, e 3 deles com a linha de rede (todos os inativos com linha de rede da cópia); os outros
4 voltariam sem nada da rede, e sairiam do alcance do gerente. Ela entra com a resposta da P13 (reaplicar
o molde), junto com a reaplicação -- a pergunta foi ao lote.

**Provado** nos dois bancos (`UsuarioAtivacaoTest`): o gerente recusado nas três portas, com o estado
intacto; o administrador reativa nas três; a linha "nada" de rede não recusa; desativar passa. Mutantes,
um de cada vez: a regra arrancada de cada porta, a exceção do administrador, e a linha "nada" contando
-- cinco, vermelhos. O `conferir-autorizacao-privilegiada.php` passou a vigiar a chamada nas três portas;
falsificado: arrancar a chamada da API o deixa vermelho, mas um `false &&` na frente da chamada passa por
ele, que é textual -- quem pega esses são os casos.

#### MUDOU no mesmo dia, com o teto de rede do D346 (o item USUARIOS-F0 e a revisão dele)

O que está acima descreve o D336 sozinho, e deixou de valer em três pontos:

- **O gerente não chega às três portas.** O teto de rede (o D346) responde 404 antes -- no `podeMexerEm`
  do ERP e no `alcanca` da API -- a quem não é administrador, diante de quem tem funcionalidade de rede.
  A reativação dessa pessoa continua só do administrador, agora pelo teto.
- **"Desativar não passa pela regra" não vale mais:** o gerente também não DESATIVA quem tem funcionalidade
  de rede (404). Se ele deve poder ao menos desativar foi ao lote (USUARIOS-F0), com a recomendação de
  manter.
- **A chamada de `recusaDaReativacao` nas três portas ficou atrás do teto**, e nenhum caso a derrubava: a
  revisão do USUARIOS-F0 mediu que, arrancada de qualquer porta, a suíte seguia verde. Ela fica, como o
  piso se o teto sair -- "a volta" do D346 é tirar a pergunta de rede das guardas, e sem ela o D336
  reabriria calado. **Provado de novo** com o teto desligado
  (`UsuarioAtivacaoTest::testSemOTetoDeRedeAsTresPortasAindaRecusamAReativacao`, nos dois bancos: um
  `UsuarioService` que responde "não" só à pergunta do teto): as três portas recusam como recusavam aqui
  (403 `de_rede` na API, a frase no botão e no "Status" da edição), e o controle mostra que o gerente
  alcança a pessoa. Mutantes, um de cada vez: a chamada arrancada de cada porta -- três, vermelhos.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, perguntas 25 e 26): na 25, a opção (a),
"Decidir junto com a P13" -- a recusa pelo molde do grupo entra no mesmo commit que reaplicar o molde,
se a P13 do consolidado (`docs/f3-telas-restantes/telas-consolidado.md`, "Quando alguém é reativado, com
que privilégios volta?") for "O molde atual do grupo" ou "Os seus, mais o molde"; se for "Os de quando
saiu", fica só a recusa pela linha, como está. A decisão fica condicionada à P13, e até lá nada muda
para os 4 inativos do grupo 5. Na 26, a opção (a), "Tudo, como está" -- o gerente continua sem ver nem
desativar a conta com funcionalidade de rede (o D346).

#### CORRIGIDO em 17/10/2026 -- a resposta 212 (b), na fatia ACL-API

**A P13 veio:** a 212 (b), *"O molde de hoje do grupo, como num cadastro novo"* -- "os ajustes de quando a pessoa saiu se
perdem [...] e quem não é administrador deixa de reativar os 4 do grupo 5". E a 25 (a) do lote de 06/10 punha a recusa
pelo molde no mesmo commit que a reaplicação.

**O conserto:** `UsuarioService::mudarEstado`, ao reativar (de inativo a ativo), apaga as linhas da pessoa e grava as do
molde do grupo (`salvarPrivilegios`, o mesmo do cadastro e da troca de grupo), na transação do estado, depois da trava do
usuário (`registrarHorario`); desativar não mexe nas linhas. `recusaDaReativacao` recusa também quem é de grupo com
funcionalidade de rede no molde, a quem não é administrador -- 403 com `motivo: de_rede`, como o desenho pede (o item 6 da
seção 5) e como a troca de grupo responde, e a frase do grupo, `RegraDoPrivilegio::FRASE_DA_REATIVACAO[GRUPO_DE_REDE]`
(a primeira versão levava `grupo_de_rede` na resposta; a verificação a trouxe ao texto do desenho, `MOTIVO_DA_REATIVACAO`,
com o caso e o mutante) --, pela pergunta única `UsuarioService::grupoTemFuncionalidadeDeRede` (a mesma da
troca de grupo, `recusaDoGrupo`). No campo "Status" da edição do ERP, a recusa pergunta pelo grupo que a gravação deixa: tirar
a pessoa do grupo de rede e reativá-la, na mesma gravação, é pô-la num grupo que o gerente pode dar. A recusa pela linha
da pessoa (o conserto de 04/10) fica: com o molde reaplicado ela não devolve mais a linha de rede, e quem a tem está atrás
do teto do D346 -- é o piso dele. Medido na cópia de 16/08/2026 (C8 do desenho): 66 inativos tinham algo tirado, 8 tinham
mais do que o molde dá, 112 tinham funcionalidade que o molde não tem (164 linhas), e 1.245 não tinham linha nenhuma -- e
voltavam sem acesso a nada.

**Provado** nos dois bancos (`UsuarioAtivacaoTest`): o reativado pelas três portas -- a API, o botão do ERP e o campo
"Status" -- volta com o molde, e só ele (o ajuste e a linha sem molde saem, a do molde que faltava entra), e o desativar não
mexe nas linhas; o gerente recusado, com a frase, ao reativar quem é de grupo com molde de rede (a API e o botão; na edição,
quem não é administrador nem vê o grupo de rede na lista), e o administrador reativa, com o molde; a edição que tira do grupo
de rede e reativa passa, com o molde do grupo novo. O caso do D346 que reativava pelo administrador passou a devolver a
linha de rede antes de medir o desativar: a reativação a tira. Mutantes, um de cada vez: sem a reaplicação, sem a recusa
pelo molde, a edição perguntando pelo grupo de antes, e o molde reaplicado também ao desativar -- quatro, vermelhos. O
`conferir-autorizacao-privilegiada.php` vigia a reaplicação no `mudarEstado` e a pergunta do grupo na recusa. **A frase na
tela antes do "ativar"** -- que a pessoa volta com o molde do grupo -- ficou registrada à parte: o D513.

**A verificação da fatia** (17/10/2026) achou a tela nova de usuários mostrando, na aba "Privilégios", as linhas de quando a
pessoa saiu depois do "ativar" -- o servidor já as tinha trocado pelo molde, e só o "atualizar" mostrava. O `UsuariosLigado`
relê os privilégios depois de ativar (desativar não mexe neles), sem os velhos à vista -- a releitura que falha oferece
"tentar de novo", e não os mostra --, e uma gravação de caixa que estava no ar deixa de valer, porque a resposta dela
cancelaria a releitura. Três casos novos no `UsuariosLigado.teste.tsx` e um acrescido (o desativar não relê), e quatro
mutantes vermelhos (sem a releitura, sem
invalidar a gravação no ar, relendo também ao desativar, relendo com os velhos à vista).

### D337 — A pergunta da adesão é gravada e impressa sem escape, inclusive na página pública que abre com os dados pessoais do cliente — CORRIGIDO em 04/10/2026

Achado pelo desenho de Pesquisas (`docs/f3-telas-restantes/tela-pesquisas.md`, DP-1, "alta").

- **Gravada crua:** a pergunta e a opção vêm de `getPost()->toArray()`, sem formulário, e o título passa
  pelo `StripTags` só no `getData()`, que o controller não usa.
- **Impressa crua em dez lugares**, entre eles a etapa 5 da adesão
  (`module/ISCadastro/view/is-cadastro/fidelidade/cadastro.phtml:222` e `:228`) e o link público
  (`module/ISConfiguracao/view/is-configuracao/pesquisas/responder.phtml:50` e `:60`). A página da adesão
  abre com nome, nascimento, telefone, e-mail e endereço do cliente preenchidos: um script ali lê e manda
  embora. No editar, roda na sessão de quem administra.
- **Latente:** nenhum dos cinco textos de hoje tem `<` ou `>` (consultas C1 a C3); podem gravar as 11
  pessoas com o privilégio (C6). É da família do D203, com página pública e dado pessoal -- débito
  próprio, como o D254 foi a parte dos convênios.

**O conserto** (a fatia A de Pesquisas, sem decisão): `escapeHtml` nas dez impressões, e a regra recusa
`<` e `>` nas duas portas. A prova renderiza as cinco views com `<b>` no texto, um mutante por impressão.


#### CORRIGIDO em 04/10/2026 -- o item MARKETING-CONSERTOS

**O conserto:** `escapeHtml` nas oito impressões que faltavam -- a pergunta e a opção da etapa 5
(`module/ISCadastro/view/is-cadastro/fidelidade/cadastro.phtml`), o título, a pergunta e a opção do link
público (`responder.phtml`), o título do agradecimento e a pergunta e a opção do editar; as duas da lista
já saíam escapadas desde o D203. A pergunta e a opção, que a tela antiga gravava com o POST cru, passam
pela `RegraDaPesquisa` (`module/ISConfiguracao/src/Regra/RegraDaPesquisa.php`), que recusa `<` e `>` com a
frase do campo; o título passa a sair do `getData()` do formulário, e o `<` e o `>` nele também são
recusados com a frase (`PesquisaFilter`, pela `RegraDaPesquisa::SEM_SINAL_DE_TAG`).

**Provado** nos dois bancos: `TelasDasPesquisasDoErpTest` renderiza as quatro páginas pelo `ViewRenderer`, a
partir do controller despachado em processo -- a adesão aberta por um cliente com o hash de 8 caracteres
que o fluxo exige --, com uma marca por campo; `PesquisasPelosControllersTest`, a pergunta e a opção com
`<b>` recusadas sem gravar; `RegraDaPesquisaTest`, a regra pura. Mutantes: cada uma das oito impressões
crua, e a regra aceitando `<` e `>` -- vermelhos.

**A revisão do mesmo dia** achou o título tratado de outro jeito: o `StripTags` do filtro, que passou a
valer com o `getData()`, cortava calado -- "Nota < 5" virava "Nota", e a tela dizia "editada com sucesso".
Saiu, e o título recusa `<` e `>` no campo, com a frase, como a pergunta e a opção (a seção 2.2 do
desenho). Provado em `PesquisasPelosControllersTest` (o título com `<` e com marcação recusados, a marca e
o título onde estavam, e a frase); mutantes: o validador arrancado e o `StripTags` de volta -- vermelhos.

### D338 — A etapa 5 da adesão ao programa se desliga em silêncio para a rede inteira por quatro caminhos da tela antiga de pesquisas — CORRIGIDO em 04/10/2026

Achado pelo desenho de Pesquisas (`docs/f3-telas-restantes/tela-pesquisas.md`, DP-2, "alta"). A pesquisa
marcada para a adesão é uma só, para a rede, e quatro caminhos a tiram sem aviso:

1. salvar o editar da pesquisa em uso com "Não"
   (`module/ISConfiguracao/src/Service/PesquisaService.php:30-43`);
2. marcar "Sim" numa pesquisa nova, que nasce sem pergunta (`:15-28`);
3. marcar "Sim" numa inativa, que a adesão não acha (`selecionarPorFidelidade` exige `status = 1`);
4. excluir a única pergunta.

A trava do D58 só cobre o ativar e desativar, e o próprio D58 previu o caminho da edição. O efeito: a
página não cai; a adesão segue sem a pergunta, sem erro para ninguém. **Medido** (C7): são 1.548
adesões em 12 meses, em 29 das 32 unidades ativas. Não há sinal de que já tenha acontecido: os 25.471
votos não ficam abaixo das 25.452 etapas 5.

**O conserto** (a fatia B, sem decisão; a pergunta 4 do desenho, "a adesão pode ficar sem pergunta?",
segue a recomendação "não"): a marca só por um método do serviço, que exige pesquisa ativa e completa; o
"Não" na pesquisa em uso recusado com a frase do D58; a exclusão da última pergunta dela recusada.


#### CORRIGIDO em 04/10/2026 -- o item MARKETING-CONSERTOS

**O conserto:** a marca só por `PesquisaService::marcarParaAdesao`, que relê a pesquisa com trava e só a
põe na adesão ATIVA e COMPLETA (`RegraDaPesquisa::completa`: ao menos uma pergunta ativa, e cada uma com
duas opções ativas ou mais), numa transação com a troca (o D400). Os quatro caminhos: o "Não" na
pesquisa em uso é recusado sem gravar nada, com a frase do D58; o "Sim" no cadastro é recusado -- a
pesquisa nova não tem pergunta; o "Sim" na inativa e na incompleta, recusados; e excluir a pergunta ou
a opção que deixaria a pesquisa da adesão sem nenhuma pergunta respondível é recusado. Só o que tira a
última: a pesquisa marcada que já estava sem pergunta respondível -- o estado que a tela antiga alcança
-- continua podendo ser corrigida. A pergunta 4 do desenho segue a recomendação "não".

**Provado** nos dois bancos (`PesquisasPelosControllersTest`: a tela antiga despachada em processo, com o
formulário e o token de verdade, e cada recusa conferida no banco): os quatro caminhos e a opção
recusados, com a marca onde estava e sem carimbar a data; a troca para a ativa e completa passa; a
exclusão fora da adesão segue livre. Mutantes: cada recusa arrancada, e a recusa da exclusão sem o "só
o que tira a última" -- vermelhos.

### D339 — Uma pergunta sem opção ativa trava a adesão ao programa de fidelidade em todas as unidades — CORRIGIDO em 04/10/2026

Achado pelo desenho de Pesquisas (`docs/f3-telas-restantes/tela-pesquisas.md`, DP-3, "alta", achado
novo).

- A página imprime a pergunta sem nenhum rádio, e a validação exige uma resposta de toda pergunta ativa
  (`module/ISCadastro/src/Controller/FidelidadeController.php:187-198`): "Responda a pesquisa.", para
  sempre. Recarregar volta à etapa 1 e para de novo na 5, e sem a etapa 5 o `programaFidelidade` nunca é
  gravado.
- **Alcançável pelo uso normal da tela antiga:** "Nova pergunta" grava a pergunta sozinha, e as opções
  vêm depois, uma por vez, cada uma recarregando a página. Entre uma coisa e outra, quem estiver na etapa
  5, em qualquer unidade, fica preso. Excluir as opções faz o mesmo.
- **Latente:** em oito anos, só 10 clientes passaram da etapa 4 e não da 5 (consulta C8).

**O conserto** (a fatia A, sem decisão): a adesão só mostra e só exige a pergunta com duas opções ativas
ou mais -- um método do repositório usado pela view e pela validação, como o D58 fez com a pesquisa nula
--; e a API grava a pergunta com as opções numa transação.


#### CORRIGIDO em 04/10/2026 -- o item MARKETING-CONSERTOS

**O conserto** (a camada 2 do desenho): `PesquisaPerguntaRepository::selecionarPerguntasRespondiveis` -- as
perguntas ativas com duas opções ativas ou mais (`RegraDaPesquisa::OPCOES_PARA_RESPONDER`) -- é o que a
página da etapa 5 mostra e o que a validação exige, pelo mesmo método; e a resposta tem de ser uma das
opções ativas da pergunta. O fluxo da tela antiga -- a pergunta, depois as opções, uma a uma -- para de
prender quem está na etapa 5 sem precisar mudar. A camada 1, a pergunta gravada com as opções numa
transação, é da API da tela nova (a fatia C do desenho), que não existe.

**Provado** nos dois bancos (`AdesaoComPesquisaTest`, a etapa 5 despachada em processo): com zero e com uma
opção a etapa passa e o cliente adere; com duas, a pergunta é exigida e o voto conta uma vez; a página
renderizada mostra só a respondível; sem pesquisa marcada, a página abre e a etapa passa em branco (o
D58, que o `PesquisaFidelidadeTest` só conferia lendo o código, agora renderizado). Mutantes: o limite
de duas virar uma, a validação voltar a ler todas as perguntas e a página voltar a mostrar todas --
vermelhos.

**A camada 1, em 24/10/2026 (a fatia 35, PESQUISAS-API):** a pergunta nova da API nasce com duas opções ou mais, numa transação (`PesquisaService::novaPerguntaComOpcoes`); e, na pesquisa em uso, a penúltima opção de uma pergunta não sai quando há outra pergunta respondível (`PesquisaService::PERGUNTA_DA_ADESAO_COM_UMA_OPCAO`, nas duas portas) -- com uma, ela sairia da etapa 5 calada. A que já tinha uma, ou nenhuma, segue corrigível. A tela antiga continua gravando a pergunta sozinha e as opções depois: com esta camada 2, isso não trava ninguém. Provado no `PesquisaApiTest::testAsExclusoesQueOEstadoNaoDeixa` e no `PesquisasPelosControllersTest::testAPenultimaOpcaoDeUmaPerguntaDaAdesaoNaoSai`, nos dois bancos.

### D340 — O abrir dos assistentes acha o assistente pelo id e ignora o grupo e a situação: 194 pessoas abrem os de fora do seu grupo — CORRIGIDO em 04/10/2026

Achado pelo desenho dos Assistentes (`docs/f3-telas-restantes/tela-assistentes.md`, Dγ).

- `abrirAction` faz `find` pelo id da rota e mostra o que achar
  (`module/ISCadastro/src/Controller/AssistentesUsuariosController.php:38-46`). A lista aplica a regra de
  grupo e unidade; o abrir, não. Um assistente inativo também abre pela URL (lido; hoje não há inativo).
- **Medido** (consulta Q12): 194 não administradores, em 30 unidades, abrem assistentes de fora do seu
  grupo, e 50 deles não veem cartão nenhum.

**O conserto** (a F1 dos Assistentes, sem decisão): o abrir usa a mesma consulta da lista, para um id só,
e dá 404 fora dela.

#### CORRIGIDO em 04/10/2026 (o item ASSISTENTES-F0-F1)

O abrir pede o assistente a `AssistenteRepository::disponivelPara`, que passa o id pela mesma consulta dos
cartões (`listagemIndexUsuarios`, com o `id`): ativo, do grupo e da unidade de quem pede, e o vínculo vazio
quer dizer todos e todas. Fora dela, 404 -- a mesma resposta para o inexistente, o inativo e o de outro
grupo ou unidade. O privilégio pedido continua o de hoje (`agendas/visualizar/escrever`): trocá-lo é o Dβ,
a F1b, que espera a P3 do dono.

*Nota de 06/11/2026 (a fatia 86):* a frase de cima valeu até a F1b. O Dβ ganhou número, o **D729**, e foi
corrigido: a lista e o abrir pedem agora `assistentes/usuarios/ler`, e não mais a agenda; o recorte deste
débito não mudou. A P3 foi seguida na recomendação A, sem resposta própria, e foi ao lote como "feito; quer
voltar?" (ver o D729).

**Provado** nos dois bancos, contra os quatro assistentes novos da fixture (um do grupo Gerente, um do
grupo Colaborador só na unidade 90001000, um sem vínculo, um inativo): para o gerente, o colaborador da
unidade e o colaborador de outra unidade, o abrir abre exatamente os que a lista mostra, e os outros dão
404 (`AssistentesPelosControllersTest`). Mutantes, vermelhos nos dois bancos: o abrir de volta ao `find`,
e a consulta sem a situação, sem a unidade, sem o grupo e sem o id. E duas entradas novas no
`conferir-recorte-por-unidade.php` (de 176 para 178 métodos), falsificadas arrancando o código e deixando
o nome num comentário.

*Revisão (04/10/2026):* o HTML dos quatro assistentes da fixture ficou vazio, como o
`endurecer-base-teste.sql` deixa a base de teste. A primeira versão gravava o molde com um `chatbot-id`
falso -- e o molde carrega o script do Zapier: o abrir de um deles no navegador, contra o banco de
contrato, chamaria a conta de produção. O recorte (200 ou 404) não depende do HTML; um caso novo confere
que nenhum assistente da fixture vira conversa (`testNenhumAssistenteDaFixtureViraConversa`), vermelho nos
dois bancos com o molde de volta na fixture.

### D341 — As telas dos assistentes imprimem cru o HTML do Zapier, o nome e a descrição — CORRIGIDO em 04/10/2026 — CORRIGIDO em 07/10/2026

Achado pelo desenho dos Assistentes (`docs/f3-telas-restantes/tela-assistentes.md`, Dδ e seção 1.6).

- O HTML da conversa sai cru (`module/ISCadastro/view/is-cadastro/assistentes-usuarios/abrir.phtml:36`),
  na origem do ERP e sem CSP no repositório; o nome sai cru no mesmo arquivo (`:20`) e nas duas listas;
  a descrição sai só com `nl2br` (`:28`). O `StripTags` do nome não age, porque o serviço recebe o POST
  cru.
- **Alcance hoje** (consultas Q1b e Q1c): nenhum dos 4 nomes e descrições tem `<`, `>` ou `&`, e o HTML
  dos 4 é um molde só, de 285 bytes, em que só o `chatbot-id` muda. O risco é o próximo que alguém
  escrever: o HTML que um dos 8 administradores colar ali roda na sessão de quem abre.

**O conserto** (a F1, sem decisão): escapar o nome e a descrição; o abrir imprime o molde a partir do
`chatbot-id` extraído do que está gravado, e nada quando o HTML não casar com o molde.

#### CORRIGIDO em 04/10/2026 (o item ASSISTENTES-F0-F1)

**A lista branca é o molde inteiro** (`ISConfiguracao/src/Regra/RegraDoAssistente.php`, a classe pura da
seção 2.3 do desenho). `chatbotIdDe` devolve o id só quando o HTML gravado é **igual** ao molde de hoje
remontado com aquele id -- a folga é só a do navegador (o CRLF do `<textarea>` e o espaço das pontas) --,
e o id casa com `^[A-Za-z0-9_-]{1,64}$`. `htmlDoMolde` monta o molde no servidor. O abrir imprime
`htmlDoMolde(chatbotIdDe(iframe))`, e nada quando o HTML não é o molde; nunca o texto gravado. O nome e a
descrição saem escapados (a descrição com a quebra de linha sobre o escapado).

**E a porta de entrada:** o formulário da administração só aceita o molde no campo "iFrame" (com a frase
que diz o que colar), e o que se grava é o molde remontado, e não o colado. O `StripTags` saiu do nome --
com a gravação passando a sair do `getData()`, ele cortaria em silêncio o nome com `<` --, e o `<` grava
inteiro e sai escapado, como nos avisos (a decisão 7 do desenho de Mensagens).

**O que não muda para quem usa:** conferido no `franquiabv_dev`, só com SELECT e sem imprimir os ids, os 4
assistentes de hoje seguem o molde, e o molde remontado é igual byte a byte ao gravado -- o abrir imprime
para eles os mesmos 285 bytes de antes.

**Provado** nos dois bancos: `RegraDoAssistenteTest` (o molde exato, a quebra simples, as pontas; dezesseis
HTMLs que não são o molde -- o script a mais antes e depois, o `onclick`, outro endereço, outra altura, dois
componentes, o id com aspas, com espaço, com 65 caracteres --; as duas âncoras do padrão),
`TelasDosAssistentesDoErpTest` (o abrir renderizado: o nome e a descrição escapados, o molde remontado uma
vez, e nada para o HTML fora do molde; os cartões e a lista da administração, nas duas ramas, com o nome
hostil; o HTML gravado volta escapado ao campo do editar) e `AssistentesPelosControllersTest` (o cadastro
grava o molde remontado e recusa no campo o HTML que não é o molde). Mutantes, vermelhos: o abrir com o
`iframe` cru, o nome cru, a descrição só com `nl2br`, o nome cru nos cartões e nas duas ramas da lista, o
padrão sem as âncoras, a igualdade trocada por "contém", a regra gravando o colado, o filtro sem a
conferência do molde e o `StripTags` de volta.

**Não visto no navegador, de propósito:** abrir um assistente carrega o script do Zapier e chama a conta
de produção (seção 1.6 do desenho). O isolamento da conversa num quadro de outra origem é a F3, que espera
a P1.

*Revisão (04/10/2026): a frase do campo.* Ela dizia "cole o código do Zapier como ele vem", e não era
verdade: o código varia com o que se escolhe no painel do Zapier, e uma das 4 páginas públicas do
repositório usa `width='400px'` (`ia-motivacionalvip.phtml`) -- esse código, colado sem editar, é
recusado. A frase diz agora o formato aceito, atributo por atributo (`is-popup='false'`,
`height='600px'`, `width='100%'`; só o `chatbot-id` muda), e o `RegraDoAssistenteTest` confere cada
atributo do molde nela (mutante: a frase de antes -- vermelho). A exatidão é a do desenho; aceitar outros
tamanhos, remontando no molde, ficou como pergunta ao dono. Pelo aplicativo de verdade (o ensaio HTTP do
D409): o editar com `width='400px'` volta com a frase no campo e o colado escapado no `<textarea>`, e nada
grava.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 28): a opção (b), "Aceitar a variação
e remontar no tamanho de hoje" -- contra a recomendação (a): o campo "iFrame" da administração antiga
passa a aceitar `width`, `height` e `is-popup` com valores simples, e o abrir continua imprimindo o
molde remontado de hoje (100% e 600px, sem popup); o tamanho escolhido no Zapier é trocado em silêncio.
A implementação está na fila. A frase do campo, que hoje diz o formato exato, muda junto.

#### CORRIGIDO em 07/10/2026 -- o código do Zapier com outro tamanho (a resposta 28)

**O conserto.** O campo "iFrame" da administração antiga (`assistentesAdmin/novo` e `editar`) aceita o
código do Zapier com o tamanho escolhido no painel dele: `RegraDoAssistente::chatbotIdDoColado` casa o
texto inteiro, ancorado nas duas pontas -- o `<script>` do endereço do Zapier e o componente com os quatro
atributos na ordem do molde e entre aspas simples --, e só três valores variam: `is-popup` (`true` ou
`false`), `height` e `width` (um número de até 4 algarismos com `px` ou `%`). O que se grava continua
sendo o molde de hoje remontado a partir do id (100% e 600px, sem popup): o tamanho do Zapier é trocado,
como o dono escolheu, e a frase do campo o diz ("o tamanho escolhido no Zapier é trocado por este"), além
do que se aceita. **O abrir não mudou:** imprime `htmlDoMolde(chatbotIdDe(iframe))`, e `chatbotIdDe`
continua aceitando só o molde -- a entrada é mais larga que a saída, e nada do que se aceita a mais chega
à página como foi colado.

**Provado** nos dois bancos: `RegraDoAssistenteTest` (sete códigos aceitos -- o molde, a largura de 400px
do `ia-motivacionalvip.phtml`, outra altura, a altura em %, o popup, os três juntos com a quebra simples,
as pontas com espaço --; vinte e seis recusados -- os quinze do molde que seguem valendo e onze novos: outro
endereço com 400px, um script depois do código de 400px, um atributo desconhecido, a largura sem unidade,
com cinco algarismos, com `calc`, em `em`, o popup com outro valor, os atributos em outra ordem, sem a
altura, as aspas duplas --; o formulário com 400px grava o molde de hoje; o abrir recusa o de 400px; a frase
diz o que varia), `AssistentesPelosControllersTest` (o código de 400px com popup grava o molde de hoje no
cadastro e na edição; o script de outro endereço, o atributo desconhecido e o script a mais, com 400px,
são recusados no campo, e nada grava) e `TelasDosAssistentesDoErpTest` (o abrir não imprime o código de
400px gravado cru). Mutantes, cada um sozinho e restaurado por sha1, todos vermelhos: o campo voltando ao
molde exato, o formulário voltando ao molde exato, o formulário gravando o colado, o padrão sem as âncoras,
aceitando outro endereço, aceitando qualquer tamanho, o abrir aceitando o colado, e a frase de antes.

**Não visto no navegador, de propósito**, pelo mesmo motivo de 04/10: abrir um assistente chama a conta de
produção do Zapier.

### D342 — Três telas apagam os vínculos e os regravam um a um, sem transação: uma falha no meio abre o aviso, o assistente ou o curso para a rede inteira — PARCIAL em 04/10/2026: a parte das mensagens e a dos assistentes — PARCIAL em 25/10/2026: as mensagens inteiras (a inativa ligada fica, e só o que mudou se grava); faltam, nos assistentes, a forma do D256 e o só o que mudou, e a VIP Class inteira — PARCIAL em 05/11/2026: a VIP Class inteira; faltam, nos assistentes, a forma do D256 e o só o que mudou

Achado por três desenhos -- Mensagens (M3), Assistentes (Dι) e VIP Class (DV-6) -- e juntado pelo
consolidado (`docs/f3-telas-restantes/telas-consolidado.md`, seção 4, item 9). Lido no código, não
executado.

- **Nos três, vínculo vazio quer dizer "todos"** (`module/ISConfiguracao/src/Entity/MensagemRepository.php:55-56`;
  `AssistenteRepository.php:59-63`; `VipClassRepository.php:22-26`).
- **E nos três a gravação apaga antes de conferir:** o `salvarUnidadesGrupos` de cada serviço apaga os
  vínculos com DQL imediato e os regrava um por um, com um `flush` por linha e sem transação
  (`module/ISConfiguracao/src/Service/MensagemService.php:56-91`; `AssistenteAdminService.php:66-98`;
  `VipClassService.php:336-374`). Os formulários aceitam qualquer id (`disable_inarray_validator`), e o
  `find` de um id que não existe devolve nulo contra uma coluna `NOT NULL`: o INSERT falha depois de os
  vínculos já terem saído. O aviso, o assistente ou o curso fica sem vínculo -- e passa a valer para
  todos.
- **E a forma do D256:** os seletores só oferecem unidades e grupos ativos, então salvar pela tela antiga
  tira as ligações inativas sem aviso. Medido: os avisos 28 e 29 perdem 1 e 2 unidades inativas ao serem
  salvos (`tela-mensagens.md`, "Os números"); na VIP Class, 0 cursos ligados só a grupos inativos (M9).

**O conserto,** o mesmo nos três -- o do D256 e o do D258 juntos: os ids conferidos antes, e a troca dos
vínculos numa transação, só do que mudou. Fatias: a F0 de Mensagens, a F1 dos Assistentes e os
consertos da administração antiga da VIP Class (DV-5 a DV-12).


#### PARCIAL em 04/10/2026 -- a parte das mensagens (o item MARKETING-CONSERTOS)

O formulário dos avisos confere a unidade e o grupo contra os que ele mesmo oferece (`MensagemFilter`,
pela `RegraDaMensagem::idsOferecidos`): o id que não está na lista é recusado no campo, antes de
qualquer escrita -- e, se chegasse à regra, seria erro, e não "todas". E `MensagemService` grava o aviso
e os alvos numa transação: a falha no meio desfaz a exclusão dos alvos antigos.

**Provado** nos dois bancos: `MensagensPeloControllerTest` (a unidade e o grupo que não existem, no
cadastro e na edição: nada grava, e os alvos do aviso ficam) e `MensagemAlvosTest` (uma falha forçada no
`onFlush` na gravação do alvo novo, depois de os antigos terem saído: o alvo e o título voltam). Mutantes:
o filtro aceitando qualquer id, a regra transformando a unidade de fora em "todas" e a gravação sem a
transação -- vermelhos.

**A revisão do mesmo dia** acrescentou a trava que a F0 pedia (o molde do D258): a alteração lia o aviso
por `getReference`, sem trava, antes de apagar os alvos -- duas edições do mesmo aviso ao mesmo tempo,
no PostgreSQL (READ COMMITTED), apagavam cada uma só os alvos que viam, e o aviso ficava com a união dos
dois pedidos (raciocínio, não executado). Agora a linha é lida com `PESSIMISTIC_WRITE` dentro da
transação. Provado em `MensagemAlvosTest`: a primeira leitura da alteração é o `FOR UPDATE` do aviso;
mutante: de volta ao `getReference` -- vermelho nos dois bancos.

**Resta:** nas mensagens, a forma do D256 -- o seletor só oferece unidades ativas, e salvar os avisos 28
e 29 tira as ligações inativas deles -- e o "só o que mudou" (o M9 do desenho), com o caso "os ids das
linhas não giram" que a F0 pedia; os assistentes e a VIP Class inteiros.

#### PARCIAL em 04/10/2026 -- a parte dos assistentes (o item ASSISTENTES-F0-F1)

O mesmo conserto das mensagens, no molde delas: o formulário da administração confere a unidade e o grupo
contra os que ele mesmo oferece (`AssistenteAdminFilter`, pela `RegraDoAssistente::idsOferecidos`), e a
regra trata o que escapasse como erro, e não como "todas"; o `AssistenteAdminService` grava o assistente e
os vínculos numa transação, e a alteração lê a linha com `PESSIMISTIC_WRITE` antes de apagar os vínculos
(o D258).

**Provado** nos dois bancos: `AssistentesPelosControllersTest` (a unidade e o grupo que não existem, no
cadastro e na edição: nada grava, e os vínculos do assistente ficam) e `AssistenteVinculosTest` (uma falha
forçada no `onFlush` na gravação do vínculo novo, depois de os antigos terem saído: os vínculos, o nome e o
HTML voltam; no cadastro, nenhum assistente fica gravado; a primeira leitura da alteração é o
`FOR UPDATE`). Mutantes, vermelhos nos dois bancos: o serviço sem a transação e a alteração sem a trava;
e, no MySQL, o filtro aceitando a unidade e o grupo que não oferece.

**Resta:** nos assistentes, a forma do D256 (hoje nenhum vínculo aponta para grupo ou unidade inativa,
medido no `franquiabv_dev`) e o "só o que mudou"; nas mensagens, o que está acima; a VIP Class inteira.

#### PARCIAL em 25/10/2026 -- o resto das mensagens (a fatia 37 da fila das telas que faltam, MENSAGENS-API)

O que faltava da F0 de Mensagens (`docs/f3-telas-restantes/tela-mensagens.md`), feito junto com a API nova, que grava
pelo mesmo serviço:

- **A forma do D256.** O editar da tela antiga oferece, além das unidades e dos grupos ativos, os inativos **já ligados** ao
  aviso, com "(inativa)" e "(inativo)" (`MensagemRepository::unidadesDoFormulario` e `::gruposDoFormulario`, no molde do
  `ConvenioRepository::opcoesDoFormulario`): a ligação inativa volta no POST e fica ao salvar outro campo. Antes, os avisos
  28 e 29 da cópia perdiam 1 e 2 unidades inativas a cada salvar, e o aviso ligado só a inativas viraria aviso da rede. Na
  API, o `PUT` aceita as ligadas além das oferecidas, e o cadastro só as oferecidas.
- **O "só o que mudou" (o M9).** O `MensagemService` lê os alvos que o aviso tem (`MensagemUnidadeRepository::idsDaMensagem`
  e o irmão dos grupos), tira os que não vieram (`::removerUnidades`, `::removerGrupos`) e acrescenta os que não estavam,
  dentro da mesma transação, com a linha do aviso travada. Os ids das linhas que ficam não giram -- e a chave que não vem (o
  `PUT` da API sem `unidades`) não mexe nos alvos. Os dois `apagar*PorMensagem` saíram do código.

**Provado** nos dois bancos: `MensagemAlvosTest::testOsIdsDasLinhasNaoGiram` (o mesmo alvo, o que entra e o que sai, com os
ids das linhas conferidos) e `::testSemAChaveOsAlvosFicam`; `MensagensPeloControllerTest::testAEdicaoNaoTiraAInativaLigada`
(a inativa oferecida com "(inativa)", e mantida ao salvar o título); `MensagemApiTest::testOPutMantemAInativaLigada`; e, pelo
HTTP, o caso 28 do `ensaiar-mensagens-adm-api.php` (o grupo inativo ligado fica no `PUT`). A falha forçada no meio
(`::testAFalhaNoMeioDaGravacaoDosAlvosDesfazTudo`) passou a trocar uma unidade por outra: com o M9, salvar a mesma não grava
linha nenhuma. Mutantes: o editar sem as inativas ligadas, o "apaga tudo e regrava" e a chave ausente apagando os alvos --
vermelhos.

**Resta:** nos assistentes, a forma do D256 e o "só o que mudou"; a VIP Class inteira.

#### PARCIAL em 05/11/2026 -- a VIP Class inteira (a fatia 82, VIPCLASS-ADMIN-SERVIDOR; o DV-6 e o DV-7)

O `salvarUnidadesGrupos` saiu do `VipClassService`: o curso, as unidades e os grupos gravam numa transação, com a linha do curso
travada na alteração, e as ligações gravam só a diferença (`VipClassRepository::idsDasUnidades`/`::removerUnidades` e os dos
grupos) -- a que fica guarda o id; o id que não existe é erro dentro da transação, e tudo volta. As duas portas, a tela antiga e a
`/adm/api/cursos`, conferem os ids antes (`RegraDoCursoVipClass::conferirCurso` e `conferirFormularioDoErp`): o formulário oferece a
unidade e o grupo inativos já ligados, marcados, e a API os aceita na lista do PUT (a decisão 5); o PUT sem a chave não mexe nas
ligações. Provado no `CursoApiTest` (`testOServicoGravaTudoOuNada`, `testOPutSoMudaOQueVemEALigacaoInativaFica`) e no
`TelasDoVipClassDoErpTest` (`testALigacaoInativaFicaAoSalvar`, `testOIdQueNaoExisteEORequisitoDoProprioCursoNaoMudamNada`), nos
dois bancos; os mutantes S1 (a transação sem rollback), S2 (apaga e regrava), S3 (o alvo que não existe vira nulo), C7 (o PUT sem as
ligadas) e O10 (o formulário sem a inativa ligada), vermelhos. **Resta:** nos assistentes, a forma do D256 e o só o que mudou.

### D343 — A documentação lê o vínculo vazio da VIP Class e dos assistentes como função desligada, e o conselho dela fecharia os cursos de 31 unidades — CORRIGIDO em 04/10/2026

Achado pelos desenhos da VIP Class (`docs/f3-telas-restantes/tela-vipclass.md`, DV-15) e dos
Assistentes (`tela-assistentes.md`, Dα -- não confundir com o Dα dos Banners, que está no D361); o
consolidado o põe no passo 0, "a documentação que dá conselho errado".

- **O código lê vínculo vazio como "todas as unidades"**
  (`module/ISConfiguracao/src/Entity/VipClassRepository.php:22-26`;
  `module/ISConfiguracao/src/Entity/AssistenteRepository.php:59-63`). **A documentação lê o contrário:**
  o D57 tirou os assistentes da Fase 2 por "nenhuma unidade vinculada"; o `docs/roadmap.md` diz que
  "nenhuma das 62 unidades tem acesso" (`:2104-2108`) e que "o que falta para ligar a função não é
  programação: é vincular unidades" (`:2136-2142`); o `docs/funcionalidades-sem-uso.md` repete as duas
  coisas (`:40` e `:131`).
- **Medido:** a VIP Class tem 2.131 conclusões de 341 pessoas, de 24/04/2024 a 10/08/2026, com
  `vipclass_unidades` vazia desde sempre (`tela-vipclass.md`, M1); os assistentes estão no menu de 211
  pessoas, em 31 das 32 unidades ativas (`tela-assistentes.md`, Q12 e Q17).
- **O estrago do conselho:** vincular uma unidade a um curso o FECHA para as outras 31 unidades ativas.
  Quem seguisse o texto tiraria o curso de quem o usa.
- **Também errados no registro:** "105 com o relatório do VIP Class" em unidades ativas (o D237; em
  unidades ativas são 63, e os 105 contam unidades de qualquer situação) e "a data impressa" no
  certificado (o D72; o certificado imprime só o nome, o curso e a carga horária).

**O conserto,** só texto: o D57, o roadmap e o `funcionalidades-sem-uso.md` passam a dizer que vínculo
vazio é "todas", e o "como conferir" do D57 passa a medir quem vê o cartão. Nenhum código muda.

#### CORRIGIDO em 04/10/2026 (o item ASSISTENTES-F0-F1)

Uma *Correção de registro (04/10/2026, o D343)* ao lado de cada frase, sem apagar o texto de então: no
`docs/roadmap.md`, a leitura "nenhuma das 62 unidades tem acesso" e o conselho "é vincular unidades"; no
`docs/funcionalidades-sem-uso.md`, a tabela, "a chave nunca girada" e o mesmo conselho; no D57, o "como
conferir", que agora traz a consulta de quem tem cartão -- medida no `franquiabv_dev` em 04/10/2026: 215
pessoas, em 31 unidades, sem o privilégio do menu. E as duas do "também errados": a data do certificado,
no D72, e os 105 do relatório do VIP Class, no D237. A correção da tabela do D57 já estava lá, de
03/10/2026. A decisão do D57 continua a do dono, e volta a ele na P14 do consolidado.

**Conferido:** o `conferir-referencias-docs.php` e o `conferir-debitos-repetidos.php`, verdes.

**Decidido pelo dono em 08/10/2026 (pergunta 49 da página do lote, com o D57):** a opção (a),
*"Manter a ordem de 27/09"* -- "A tela antiga segue em uso, para os alunos e para quem administra,
até a nova chegar." A pergunta nasceu deste registro: a VIP Class tinha ganhado "prioridade baixa" em
30 e 31/08 pela leitura que este débito corrigiu (o vínculo vazio como função desligada). Com a premissa
certa -- 2.131 conclusões de 341 pessoas, a última em 10/08/2026 --, o dono manteve a VIP Class em 8º
de 9. Nada mudou no código; as duas correções de registro que diziam "até ele responder"
(`docs/roadmap.md` e `docs/funcionalidades-sem-uso.md`) ganharam a resposta ao lado.

### D344 — A API do catálogo e a dos pacotes gravam `<` e `>` no nome do item, e o Gerenciar das etiquetas o imprime cru ao administrador — REGISTRADO em 03/10/2026 — PARCIAL em 15/10/2026: as duas regras e os formulários antigos do produto, do serviço e do pacote recusam pela RegraDoTexto, e o Gerenciar e a lista das etiquetas têm caso; fica o medidor do D203 para o índice de arranjo — CORRIGIDO em 19/10/2026: o medidor do D203 vê o índice de arranjo

Achado pelo desenho das Etiquetas (`docs/f3-telas-restantes/tela-tags.md`, DT-A, "grave e latente"); o
consolidado o põe no passo 0.

- **De onde vem:** a regra do catálogo confere vazio, teto e emoji no nome, e não `<` e `>`
  (`module/ISConfiguracao/src/Regra/RegraDoCatalogo.php:134-144`); a dos pacotes tem o mesmo vão. As
  regras do convênio e do cupom já recusam, e a tela antiga de serviço e de produto passa o nome pelo
  `StripTags`: foi a API nova que abriu a porta.
- **Quem imprime:** `module/ISConfiguracao/view/is-configuracao/produtos-tags/gerenciar.phtml:122`, numa
  lista de todas as unidades que só o administrador abre; e, fora desta tela, as listas antigas de
  produtos e de serviços e dois relatórios (`echo $produto['nome']`).
- **O medidor do D203 não vê esta forma:** ele procura getters, e estas linhas leem índice de arranjo. É
  o mesmo ponto cego que deixou passar a descrição crua de Contas a Pagar (`tela-saidas.md`, débito 8).
- **Alcance** (consultas M12 a M14): 112 não administradores ativos escrevem no catálogo e 104 nos
  pacotes; nenhum dos 9.509 nomes de item tem `<` ou `>`, e ninguém fica bloqueado pelo conserto. O
  cookie é HttpOnly desde o D198, mas um script na página do administrador faz pedidos de mesma origem
  em nome dele, e não há CSP.

**O conserto** (a fatia S1a das Etiquetas, cerca de 20 linhas e 100 de teste, sem decisão): as duas
regras recusam `<`, `>` e caractere de controle no nome, com a frase do convênio; o Gerenciar e a lista
das etiquetas escapam; as outras telas ficam no D203, e o medidor ganha a forma do índice de arranjo.

*Em 15/10/2026 (a fatia REGRA-DO-TEXTO, a 3 da fila das telas que faltam), parcial:*

- **A origem pela API fecha** (a pela tela antiga, que este texto dava por fechada, não estava: ver a
  verificação, abaixo). A conferência do texto de uma linha saiu para uma peça só,
  `module/ISBase/src/Regra/RegraDoTexto.php` (`conferir` e `chavesQueNaoSeAlteram`): de 1 a N
  caracteres depois do `trim`, sem caractere de quatro bytes, sem `<`, `>` nem caractere de controle
  (procurado no texto inteiro, antes do `trim`), e a chave que a API não altera é 422 com o nome dela. O
  `RegraDoCatalogo` e o `RegraDoPacote` passam a usá-la no nome, com a frase do convênio ("Sem < nem >: o
  nome aparece cru em telas do ERP.") e a do controle ("Sem quebra de linha nem tabulação."). O
  `POST` e o `PUT` de `/adm/api/produtos` e de `/adm/api/pacotes` respondem 422 no `nome`, e nada grava.
- **O que muda para quem usa.** Na cópia (`franquiabv_dev`, SELECT de contagem): nenhum dos 9.509 nomes
  de item tem `<` ou `>`; dez têm tabulação, todos de 2016 a 2020 e de duas unidades -- seis só nas
  pontas (as quatro ativas entre eles) e quatro no meio (todas inativas). A tela nova apara o nome antes
  de mandar e só o manda quando ele muda. O item inativo se edita sem reativar (o filtro "inativos" da
  lista, e o "editar" depende só do privilégio): quem abrir um daqueles quatro e mudar o nome, mantendo a
  tabulação do meio, recebe a recusa no campo até tirá-la -- desde a verificação, nas duas telas.
  *(A primeira versão deste item dizia "só quem reativar"; a verificação de 15/10/2026 a corrigiu, e dizia
  também que a tela antiga continuava aceitando a tabulação, o que deixou de valer.)*
- **As cinco cópias viraram uma.** O convênio, o cupom e a pesquisa já recusavam os três; passam a chamar a
  peça com as frases de sempre, e os casos deles passam sem mudança. Os `CABE_NA_COLUNA` dos filtros
  (convênio, cupom, pesquisa, mensagem, assistente) e o `SEM_SINAL_DE_TAG` da pesquisa são os da peça.
- **O escape do Gerenciar e da lista** entrou em 04/10/2026, com a varredura do D203, sem caso. Agora tem:
  `TelasDasEtiquetasDoErpTest` renderiza as duas views com uma marca por campo (o item, a unidade, a
  etiqueta), e arrancar cada um dos cinco escapes fica vermelho.
- **Fica**, e por isso parcial: o medidor do D203 continua cego à forma `echo $x['campo']` -- o item 7 do
  consolidado, que a fatia 18 (ETIQUETAS-ERP) leva. As outras telas que imprimiam o nome (`produtos/index`,
  `servicos/index` e os dois relatórios) já escapam desde 04/10/2026.

*Na verificação de 15/10/2026* (três leitores sobre a fatia; onze achados, todos confirmados no código):

- **A tela antiga também gravava o `<`.** O "De onde vem" acima dizia que a tela de serviço e de produto
  passava o nome pelo `StripTags`, e que foi a API nova que abriu a porta. Não passava: `ProdutosController`,
  `ServicosController` e o `PacotesController` do ERP davam ao formulário o POST (`setData`) e ao serviço o
  POST cru (`getPost()->toArray()`), e o `StripTags` do filtro valia só na conferência -- `Pomada <img src=x
  onerror=alert(1)>` passava como "Pomada" e ia ao banco inteiro, e `produtos/estoque.phtml:24` o imprime
  com o getter, sem escape (a forma do getter que o D203 deixou para as telas). O mesmo vale para o
  `StringTrim`: 1.094 dos 9.509 nomes da cópia têm espaço nas pontas. **O conserto:** o nome dos três
  formulários tem a regra da API, com as frases dela (`RegraDoTexto::validadoresDoFormulario`), e sem o
  `StripTags`, que cortava calado ("Pomada < 5" virava "Pomada", como a pesquisa já tinha visto); e os seis
  `novo`/`editar` gravam o nome do `getData()`, aparado. O resto do POST desses formulários segue como era
  (o `atualizar` já o recorta pelos `CAMPOS_DA_EDICAO`, o D202). Caso: `D344NomeNaTelaAntigaDoCatalogoTest`
  (o novo e o editar das três telas, em processo: seis nomes recusados com a frase e nada gravado, e o nome
  gravado aparado).
- **O formulário e a API discordavam no controle.** A peça só dava ao validador `Regex` o emoji e o `<`; o
  nome com tabulação no meio que a tela antiga gravava a tela nova não alterava. Agora os validadores do
  formulário são os cinco motivos da API, um por chave; os três formulários acima usam todos, e o do
  convênio (`ConvenioFilter`) e o título da pesquisa (`PesquisaFilter`), que já tinham o teto e o emoji,
  ganharam o do controle. Caso: `RegraDoTextoTest` (o formulário diz o mesmo que a API, com a mesma frase,
  em cada fronteira), `TelasDosConveniosDoErpTest` e `PesquisasPelosControllersTest`.
- **"Caractere de controle" era só o do ASCII.** O NEL (U+0085, a quebra de linha do C1), o resto do C1, os
  separadores U+2028 e U+2029 e os que invertem a direção do texto (U+202E e o resto do `Bidi_Control`)
  passavam como texto de uma linha. A peça passou a recusá-los. Na cópia, nenhum nome de item, convênio,
  cupom, título ou pergunta de pesquisa tem algum deles: ninguém fica preso. O mesmo para o nome feito só do
  que não se vê (o U+00A0, o U+3000, o U+FEFF): o `trim` do PHP não os tira, e o nome passava no
  obrigatório; a tela nova já os aparava pelo `trim` do JS, e só quem chamava a API direto os gravava.
- **A chave em UTF-8 inválido num corpo de formulário** fazia o `json_encode` da resposta devolver `false`:
  o 422 saía sem corpo. A peça devolve a chave com o byte inválido trocado por `?` (`mb_scrub`).
- **O texto de várias linhas.** Os desenhos dos avisos e dos assistentes mostram a quebra de linha, e 6 dos
  74 avisos e 1 das 4 descrições da cópia a têm (todas com `\r\n`); o documento dizia que nenhum dos
  desenhos a pedia. A peça ganhou o `conferirComQuebras`, que aceita `\n` e `\r` e recusa o resto.
- **A frase de quem chama no controle** não tinha caso, e o mutante que a ignorava sobrevivia; agora cada
  motivo tem o seu.
- **Os textos que a API grava fora da peça** -- a descrição da saída, a observação da remarcação, o nome
  do amigo da fidelidade, o nome do cliente e os motivos da comanda e do fechamento -- ficaram como
  estavam, sem a recusa do `<` nem a do controle; a `docs/convencao-api.md` dizia que todos passavam pela
  peça. Registrado à parte, no D495.

*Em 19/10/2026 (a fatia 18 da fila das telas que faltam, ETIQUETAS-ERP), corrigido:* o medidor do D203 vê o índice de
arranjo -- e o getter e a variável -- e vigia as quatro views das etiquetas: `scripts/conferir-escape-nas-views.php`, no
`conferir-tudo.sh` e no CI (ver o D203, "o medidor para toda forma"). Arrancar o escape do nome do item no Gerenciar fica
vermelho no portão, além do `TelasDasEtiquetasDoErpTest`.

### D345 — A tela nova de usuários diz "Aparece na agenda: sim" de todo usuário, inclusive dos 191 ativos que não aparecem em agenda nenhuma — CORRIGIDO em 04/10/2026

Achado pelo desenho do resto de Usuários (`docs/f3-telas-restantes/tela-usuarios-resto.md`, D-E). Está
no ar: é a tela de Usuários já entregue.

- O servidor manda `visivelAgenda` cru -- `nenhuma`, `int`, `ext` ou `ext_int`
  (`module/ISConfiguracao/src/Api/UsuarioJson.php:119`) --; o front o tipa `boolean`
  (`frontend/src/api/usuarios.ts:81`) e mostra `simOuNao` (`frontend/src/telas/Usuarios.tsx:747`); e os
  testes usam `true`, valor que o servidor nunca manda. Toda cadeia não vazia vira "sim".
- **Medido** (consulta C1): os 191 ativos com `nenhuma` aparecem como "sim".

**O conserto** (a F0 de Usuários, sem decisão): o tipo com os quatro valores no front, e a frase de cada
um por extenso; os dublês dos testes com os valores que o servidor manda.

#### CORRIGIDO em 04/10/2026 -- o item USUARIOS-F0

**O conserto:** o servidor publica `visivelAgenda` com tipo -- um dos quatro valores
(`RetratoDoHorario::ONDE_APARECE`), ou nulo para o que estiver fora da lista -- e `naAgenda`, pela regra
da agenda: `ext_int` e `int` (`RetratoDoHorario::NA_AGENDA`). É a regra da F4 do Profissional Parceiro,
num lugar só: `AgendaDoParceiro::VISIVEIS` passou a apontar para ela. O front tipa os quatro valores
(`OndeAparece`, `frontend/src/api/usuarios.ts`), e a aba Dados diz por extenso, com o "sim" e o "não" do
servidor: "sim, na do ERP e no aplicativo", "sim, só na do ERP", "não: só no aplicativo, e o balcão não
vê na grade o que ele marca" (o D416), "não, em agenda nenhuma" e "não: o cadastro tem um valor fora da
lista". Os dublês do front (`UsuariosLigado.teste.tsx`, `Console.teste.tsx`) mandam o que o servidor manda.
Nenhum pedido novo.

**Provado:** `UsuarioApiTest::testAAgendaSaiComTipoPelaRegraDaAgenda`, nos dois bancos (os quatro valores e o
`sim` das fixtures antigas); no front, a frase de cada valor e do nulo (cinco casos). Mutantes, um de cada
vez: o valor cru de volta e o `ext` contado como agenda -- vermelhos nos dois bancos; o `simOuNao` de volta
na tela -- os cinco casos vermelhos.

#### A revisão do mesmo dia: o estado, e a regra que era uma só só no comentário

- **`naAgenda` não olhava o estado.** A grade só traz `status = 1`, e a ficha de quem está inativo com
  `ext_int` dizia "sim, na do ERP e no aplicativo" -- 971 inativos com `ext_int` e 215 com `int` no
  `franquiabv_dev`; e os 13 com `ext`, todos inativos, ouviam o aviso do D416, que também não vale para
  eles (o aplicativo exige `status = 1`, `UnidadeRepository`). Agora `naAgenda` é
  `RetratoDoHorario::naAgenda($status, $visivelAgenda)`: ativo, e `ext_int` ou `int`. A aba Dados diz, de
  quem está inativo, "não: está inativo (o cadastro marca na do ERP e no aplicativo)" -- o que o cadastro
  marca, para quando ele voltar.
- **"A regra é uma só" era tautologia.** O caso conferia `NA_AGENDA` contra `AgendaDoParceiro::VISIVEIS`,
  que é a mesma constante por definição, e a grade do ERP escrevia os dois valores no próprio DQL
  (`selecionarUsuariosAgenda`, `popularCombobox` com a agenda) e `RegraDaMarcacao::colaboradorRecebe` numa
  lista à parte. As três passaram a ler `NA_AGENDA`, e o caso tautológico saiu.
- **Provado** nos dois bancos (`UsuarioApiTest::testAApiDizOQueAGradeTrazNosDoisEstados`): para os quatro
  valores, o fora da lista e os dois estados, a API, a grade, a lista de colaboradores da agenda e quem
  recebe marcação respondem o mesmo -- perguntadas de verdade, e não pela constante --, com o controle de
  que só `int` e `ext_int` ativos têm grade; no front, a frase de quem está inativo (cinco casos), e os
  dublês de inativo com `naAgenda: false`. Mutantes, um de cada vez: o estado fora de `naAgenda`, o `int`
  fora do DQL da grade, o `int` fora da lista do `popularCombobox` e o de `colaboradorRecebe` -- vermelhos
  nos dois bancos; o inativo de volta à frase do ativo, na tela -- vermelhos.

### D346 — O gerente alcança a conta com funcionalidade de rede da própria unidade: troca a senha dela e entra como ela — CORRIGIDO em 04/10/2026 (a decisão do desenho, com o caminho de volta; o desativar foi ao lote) — DECIDIDO em 06/10/2026

Achado pelo desenho do resto de Usuários (`docs/f3-telas-restantes/tela-usuarios-resto.md`, D-A1, o
primeiro da lista "do mais grave ao menos").

- O teto de hoje é o do D204: quem não é administrador não mexe em administrador. É o que conferem
  `podeMexerEm` no ERP (`module/ISConfiguracao/src/Controller/UsuariosController.php:1207-1215`) e
  `alcanca` na API (`module/ISConfiguracao/src/Controller/Adm/UsuariosController.php:251-259`). Nenhum dos
  dois olha se o alvo tem funcionalidade de rede (`RegraDoPrivilegio::DE_REDE`).
- **O efeito:** o gerente troca a senha da conta e entra como ela -- contorna a regra 3 do D288 (só o
  administrador concede funcionalidade de rede) sem conceder nada.
- **Medido** (consulta C3): 1 conta, a "API Integração", numa unidade em que 2 gerentes escrevem em
  usuários.

**O conserto** (a F0 de Usuários, sem decisão): quem não é administrador não alcança quem tem
funcionalidade de rede, nas duas portas, com 404, como o teto do D204.

#### CORRIGIDO em 04/10/2026 -- o item USUARIOS-F0

O desenho dá o teto como "já" e o põe entre as decisões que ele toma e que continuam do dono ("a volta é
uma condição em duas guardas"); não é uma das perguntas P. Foi feito.

**O conserto:** `UsuarioService::temFuncionalidadeDeRede` -- a pergunta que a reativação do D336 já fazia
(uma linha da pessoa que concede `ler` ou `escrever` numa de `RegraDoPrivilegio::DE_REDE`; a linha "nada"
não conta), agora pública -- e o teto nas QUATRO guardas que tinham o do D204, e não só nas duas que o
desenho nomeia: `UsuariosController::podeMexerEm` (toda ação do ERP sobre um usuário: a ficha, a senha, as
permissões, a agenda, a cota-parte, a foto, os acessos, o estado), `Adm\UsuariosController::verAction` e
`alcanca` (a leitura e a troca de estado da API nova) e `Adm\PrivilegiosController::usuarioDaRota`. Quem
não é administrador recebe 404, como para um administrador. O `conferir-recorte-por-unidade.php` vigia as
quatro (de 178 para 182 métodos).

**O efeito no D336, e a pergunta:** o gerente também deixa de ver e de DESATIVAR a pessoa com
funcionalidade de rede -- o D336 deixava o desativar passar ("tirar não concede nada"). O caso dele mudou
junto (`UsuarioAtivacaoTest::testOGerenteNaoReativaQuemTemFuncionalidadeDeRede`): as três portas dão 404,
e a regra da reativação, que as portas continuam chamando depois do teto, é medida no serviço. É a forma
do teto do administrador; se o gerente deve poder ao menos desativar foi ao lote (USUARIOS-F0), com a
recomendação de manter como está.

**A volta:** tirar a segunda pergunta de `podeMexerEm`, `verAction`, `alcanca` e `usuarioDaRota`.

**Medido na cópia** (só contagens): 1 ativa não administradora com funcionalidade de rede (a "API
Integração"), 1 unidade, e nenhuma delas escreve em usuários; 3 inativas com "Agendas unidades" (as do
D336); 2 escritores não administradores nas unidades delas.

**O que fica:** a lista (`UsuarioRepository::listagemIndex`) continua mostrando a pessoa ao gerente -- a
linha abre em "Usuário não encontrado". Escondê-la mexe na consulta que também monta a lista de "quem
atendeu" do PDV novo; foi ao lote como opção, para a tela nova (F3).

**Provado:** `TetoDeRedeTest`, nos dois bancos: o gerente recebe 404 em seis ações do ERP (a ficha, a
senha, as permissões, a cota-parte, a agenda e a foto), no POST da senha (que não muda) e nos acessos; e
na API, na leitura, na desativação (o estado fica) e nos privilégios; o administrador alcança nas mesmas
portas; e o colega sem linha de rede e a linha "nada" passam (os controles). Mutantes, um de cada vez,
vermelhos nos dois bancos: o teto arrancado de cada uma das quatro guardas, a linha "nada" contando, e a
reativação sem a pergunta (o caso do D336). Os dois portões, falsificados arrancando o código de cada
guarda: vermelhos.

#### A revisão do mesmo dia: a própria pessoa, e o D336 atrás do teto

- **O teto barrava a própria pessoa.** Quem tem funcionalidade de rede sem ser administrador (a conta "API
  Integração" na cópia; o gerente a quem o administrador concede "Agendas unidades", como a regra 3 do
  D288 permite) recebia 404 na própria ficha, no ERP e na API, e nos próprios privilégios -- e o desenho
  conta com "a própria agenda pode ser gravada" (a F2). O teto protege a conta de OUTRO; as quatro guardas
  passaram a deixar passar `ehASiMesmo`. O próprio grupo, os próprios privilégios e o próprio estado
  continuam recusados nas ações, pelas travas do próprio (o D294, o D386). **Provado** nos dois bancos
  (`TetoDeRedeTest::testQuemTemARedeAlcancaAPropriaFicha`): a própria ficha no ERP e na API e os próprios
  privilégios dão 200, desativar a si mesmo dá 403 (a trava do próprio, e não o 404 do teto), e o colega
  com a mesma linha continua fora. Mutantes, um de cada vez: a exceção arrancada de cada uma das quatro
  guardas -- vermelhos.
- **A recusa da reativação do D336 ficou atrás do teto**, sem caso que a derrubasse; agora é medida com o
  teto desligado -- ver o D336.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 26): a opção (a), "Tudo, como está" --
só o administrador mexe em conta com funcionalidade de rede, inclusive para desativar. Nada muda no
código. A opção (c), esconder da lista quem o gerente não alcança, não foi escolhida: a linha continua
na lista e abre em "Usuário não encontrado", como o "O que fica" acima diz, e a tela nova (F3) pode
revisitá-la. Nada mais resta deste débito.

### D347 — Trocar a senha não derruba as sessões abertas nem o cookie de entrada automática, por nenhuma das três portas — PARCIAL em 03/10/2026: as portas da própria pessoa derrubam (a terceira na revisão de 04/10/2026); a senha definida para outra pessoa espera a P7 — DECIDIDO em 15/10/2026 pela resposta 101 (c): a senha dada por outra pessoa fica como hoje

Lido na revisão do D315 (ali, em "lidos no código, fora do D315 e ainda não registrados") e posto entre
os consertos do desenho do resto de Usuários (`docs/f3-telas-restantes/tela-usuarios-resto.md`, D-B).

- As três portas gravam a senha nova e deixam o `token_web`: a do gerente (`editarSenha`), a de "Meus
  dados" (`UsuarioService::updateMeusDadosSenha`,
  `module/ISConfiguracao/src/Service/UsuarioService.php:318-326` no registro; hoje `:310-318`) e a do link
  de "Esqueci a senha" (`AutenticacaoService::senhaConfirmar`,
  `module/ISConfiguracao/src/Service/AutenticacaoService.php:40-48` no registro; hoje `:77-87`).
  O cookie velho continua entrando, e a sessão aberta segue.
- Quem troca a senha porque desconfia que alguém a tem não tira essa pessoa do sistema.
- Não medível na cópia: não há registro de troca de senha.

**O conserto** (a F0 de Usuários, sem decisão): as portas apagam o `token_web`, e a sessão de quem não
fez a troca cai no pedido seguinte, pela guarda de login do D294 comparando uma impressão do hash
guardada na sessão -- sem coluna nova. O `token_web` e a guarda acabaram de mudar no D315: o caso da
personificação entra junto.

#### O conserto (03/10/2026, o item SESSAO-SENHA-EMAIL) — as duas portas da própria pessoa

- **"Meus dados" gira o token.** `meusDadosSenhaAction` passa a chamar
  `UsuarioService::trocarAPropriaSenha`, que grava a senha e um `token_web` novo na mesma gravação e o
  devolve; a ação o regrava na sessão de quem trocou (`SessaoAcl::manterTokenDoLogin`, o molde do D315).
  Quem trocou segue; toda outra sessão com o token velho cai no pedido seguinte, pela regra do um
  dispositivo por vez (`UmDispositivoPorVez`, com "usada em outro local"); e o cookie velho deixa de achar
  a pessoa. O cookie do próprio aparelho também: quando a sessão expirar, a pessoa entra com a senha nova.
- **Na personificação, o token da sessão não muda.** A troca de "Meus dados" é da pessoa assumida
  (`usuarioId`) e pede a senha atual dela; o token dela gira, e o da sessão -- que é o do administrador
  que entrou -- só é regravado quando quem entrou é a própria pessoa (`loginDaSessao`).
- **O link de "Esqueci a senha" apaga o token** (`AutenticacaoService::senhaConfirmar`), como a
  desativação: quem usa o link esqueceu a senha e não está logado. A sessão com o token velho cai com
  "Atualizamos o sistema", e o cookie velho não entra.
- **Pelo token, e não pela impressão do hash** que o desenho previa: o item pediu o molde do
  `manterTokenDoLogin`, e a sessão aberta de uma pessoa é a do último login dela (D223) -- tem o token
  dela, que a regra já confere. A impressão do hash cobriria também a sessão SEM token, que só existe no
  caminho do D127; ela fica de fora (abaixo).

**Provado:** `SenhaTrocadaTest`, 4 casos verdes nos dois bancos, despachados em processo com os
controllers de verdade: a troca em "Meus dados" (quem trocou segue com o token novo; o outro aparelho
com o token velho cai, com "danger"; o cookie velho no `entrar` não entra); a mesma troca personificando
(o token da pessoa assumida gira, o da sessão e o do administrador ficam); o link (o token sai, a sessão
cai com "Atualizamos o sistema", o cookie velho não entra); e o controle da P7 (a porta do gerente troca
a senha e deixa o token). Por HTTP, nos dois bancos de contrato, o caso 17 do ensaio da sessão: o
cookie com o token do login abre uma segunda sessão com o mesmo token; a troca na primeira a mantém
(API 200) e derruba a segunda (401); o cookie velho no `entrar` não entra; e a senha do contrato volta
no fim. Falsificado por HTTP: sem girar o token, a segunda sessão segue 200 e o cookie velho entra
(302), e só o caso 17 fica vermelho. Mutantes, um de cada vez,
vermelhos: sem girar o token (os dois de "Meus dados"); sem regravar a sessão (o de "Meus dados": quem
trocou cai); regravando mesmo personificando (o da personificação); o link sem apagar o token (o do
link).

**O que fica:**
- ~~a porta do gerente (`editarSenha`, que passa por `updateMeusDadosSenha`) troca a senha e deixa o token~~
  (revisão, 04/10/2026: só quando a senha é de OUTRA pessoa -- a própria, na própria ficha, gira; abaixo):
  se a senha dada por outra pessoa derruba as sessões dela é a P7 das telas, decisão do dono. O caso de
  controle do `SenhaTrocadaTest` a caracteriza, e muda junto quando a P7 for respondida;
- a sessão SEM token -- a do caminho do D127, quando a pessoa da rota está sem `token_web` -- não é
  conferida pela regra (`UmDispositivoPorVez`), e uma troca de senha não a derruba. É o D127;
- o aparelho derrubado pela troca em "Meus dados" vê "sua conta está sendo usada em outro local", e o
  derrubado pelo link vê "Atualizamos o sistema": a regra não sabe por que o token mudou. Uma frase
  própria pediria guardar o motivo.

#### A revisão (04/10/2026): a terceira porta da própria pessoa, e o link com ela logada

Dois achados da verificação, os dois sem decisão nova -- a regra do item é "a senha nova pela própria
pessoa gira o token e regrava o da sessão atual":

- **A própria ficha era uma terceira porta da própria pessoa.** Quem pode editar usuários (o
  administrador, ou o gerente com `configuracoes/usuarios`) abre a própria ficha, aba "Senha"
  (`editarSenha` com o próprio id): o `podeMexerEm` deixa passar a si mesmo, e a ação gravava a senha
  sem pedir a atual e sem tocar no token -- a outra sessão seguia, o cookie velho entrava. A P7 pergunta
  só pela senha de OUTRA pessoa. Agora, quando o alvo é QUEM ENTROU (`loginDaSessao`), a ação chama
  `trocarAPropriaSenha` e regrava o token na sessão (`manterTokenDoLogin`), como "Meus dados". Não é
  quem a sessão mostra: personificando X, a senha de X na ficha é A definindo a de outra pessoa (a P7,
  o token de X fica), e a de A na própria ficha gira a de A, que é o token da sessão.
- **O link derrubava também a sessão de quem trocou.** A rota do link é pública, mas a sessão logada
  passa por ela, e o `senhaConfirmar` apagava o token mesmo quando a própria pessoa estava logada no
  navegador que usou o link -- quem entra pelo cookie e não lembra a senha atual, que "Meus dados" pede,
  troca-a pelo link. Agora, se quem entrou nesta sessão é a pessoa do link, o token GIRA e a ação o
  regrava na sessão; se não há sessão, ou ela é de outra pessoa (inclusive o administrador que
  personifica a pessoa do link), o token sai, como antes.

**Provado:** `SenhaTrocadaTest` passou de 4 a 10 execuções, verdes nos dois bancos: a própria ficha
(quem trocou segue com o token novo, o outro aparelho cai com "danger", o cookie velho não entra e o
novo entra); personificando, a própria ficha gira o de quem entrou e a personificação segue; e a ficha
da pessoa assumida é a P7 (o token dela e o da sessão ficam); o link com a pessoa logada (ela segue com
o token novo, o outro aparelho cai, o cookie velho não entra e o novo entra); e o link com outra pessoa
logada e com o administrador personificando a pessoa do link (o token dela sai, a sessão fica). **O
arnês media mal o cookie:** `oCookieEntra` perguntava `hasIdentity()`, e o `entrarCookies` troca o
armazenamento da identidade depois de autenticar -- no primeiro login pelo cookie do processo, o
armazenamento novo está vazio e a resposta era "não entrou" mesmo entrando, e o "o cookie velho não
entra" passava sem medir, conforme a ordem dos casos. Medido: o controle positivo ficou vermelho
rodando sozinho. Passou a ler o redirecionamento, que o `entrarCookies` só dá quando a autenticação
vale. Mutantes, um de cada vez, vermelhos nos dois bancos com os mesmos casos: a ficha sem o ramo da
própria pessoa (os dois casos da própria ficha); sem regravar a sessão (os mesmos dois); comparando com
o `usuarioId` (a própria ficha personificando, e a P7 personificando); com o `ehASiMesmo` (a P7
personificando); o link sem passar a pessoa logada, sem regravar a sessão, ou com o serviço sempre
apagando (o do link com a pessoa logada); a pessoa sempre "logada" (o link sem sessão e os dois de outra
pessoa logada); e pelo `usuarioId` em vez de quem entrou (o do administrador personificando).

#### A P7 respondida (12/10/2026, registrada em 15/10/2026 pela fatia PONTE-PARA-A-ANTIGA)

O dono respondeu a pergunta 101 da página do lote -- "Quem pode definir a senha e trocar o e-mail de
outra pessoa, inclusive no cadastro?" -- com a opção (c), *"Como hoje"*: "Os gerentes definem e trocam,
e nada cai." É a parte que este débito esperava, e ela fica como está: a senha que o gerente (ou o
administrador) define para OUTRA pessoa não derruba as sessões nem o cookie dela, e o e-mail continua
trocável por quem edita a ficha. Não há código a mudar. O caso de controle do `SenhaTrocadaTest`
(`testASenhaDefinidaPeloGerenteAindaDeixaOToken`, e o `testPersonificandoASenhaDaPessoaAssumidaNaFichaEAP7`)
passa a prender a decisão, e não mais um estado à espera dela; o docblock dele ainda diz que "muda junto
quando a P7 for respondida", e a frase fica para a fatia USUARIOS-CADASTRO-E-SENHA (a 17 da fila das
telas), que escreve a senha e o e-mail de outra pessoa na tela nova "como hoje (101 c)".

Os outros dois pontos de "O que fica" não são este débito: a sessão sem token é o D127, e a frase
própria para o aparelho derrubado ("sua conta está sendo usada em outro local" contra "Atualizamos o
sistema") era uma nota, sem pedido.

**A tela nova, como hoje** (18/10/2026, a fatia 17, USUARIOS-CADASTRO-E-SENHA): o `PUT /adm/api/usuarios/:id/senha` define a senha
de outra pessoa pela mesma porta da ficha antiga (`updateMeusDadosSenha`, com a senha dentro do arranjo, e a mesma `RegraDaSenha`),
e nada cai -- o `token_web` fica, e o caso que o prova é o `UsuarioCadastroESenhaPelaApiTest::testDefineASenhaCruaENadaCai`
(mutante: a porta da própria pessoa, que gira o token, vermelha). A própria senha não se define por ela (403 `proprio`, também na
personificação): troca-se em "Meus dados", com a atual. A tela diz, antes de salvar, que ninguém sai do sistema. O docblock do
`SenhaTrocadaTest` deixou de dizer que o caso "muda junto quando a P7 for respondida": ele prende a decisão.

**A pessoa assumida, como na ficha antiga** (18/10/2026, a verificação da fatia 17): a porta nova recusava também a senha da pessoa
assumida na personificação (comparava pelo `ehASiMesmo`, que soma quem a sessão mostra), e a ficha antiga a trata como a de OUTRA
pessoa -- A, personificando X, define a de X, e o token de X fica (a P7, o `testPersonificandoASenhaDaPessoaAssumidaNaFichaEAP7`). A
101 (c) manda fazer "como hoje": o `definirSenhaAction` passou a comparar só com quem entrou (`senhaDeQuemEntrou`, o
`loginDaSessao`), a mesma pergunta da ficha. A de quem entrou continua recusada, também personificando, e a leitura diz onde ela se
troca (`senhaSeTroca`): `meus_dados`, ou `fora_da_personificacao` -- o "Meus dados" da sessão que personifica troca a senha da pessoa
assumida, e a nota da tela não manda lá. Caso: `UsuarioCadastroESenhaPelaApiTest::testPersonificandoASenhaDaPessoaAssumidaEADeOutraPessoa`
(a senha definida, o token dela que fica, e as quatro leituras do `senhaSeTroca`). Mutante: o `ehASiMesmo` de volta, vermelho.

### D348 — A senha do cadastro de usuário é gravada sem os sinais de tag, e quem os usou não consegue o primeiro acesso — CORRIGIDO em 04/10/2026

Achado pelo desenho do resto de Usuários (`docs/f3-telas-restantes/tela-usuarios-resto.md`, D-C).

- O filtro do cadastro passa a senha e a confirmação por `StripTags` e `StringTrim`
  (`module/ISConfiguracao/src/Form/UsuarioDadosFilter.php:243-268`), o cadastro grava o `getData()`
  filtrado, e a entrada compara com a senha como foi escrita. "a<b>cdefgh" vira "acdefgh" no banco
  (conferido pelo levantamento com `php -r`), e a pessoa não entra com a senha que escolheu. A troca de
  senha grava a crua: duas portas, duas regras.
- Não medível na cópia: o hash não diz.

**O conserto** (a F0 de Usuários, sem decisão): a senha sem filtro no cadastro, como na troca. O caso
cadastra "  a<b>cdefgh  " e confere com `password_verify`.

#### CORRIGIDO em 04/10/2026 -- o item USUARIOS-F0

**O conserto:** a senha e as duas confirmações do cadastro (`UsuarioDadosFilter`, o ramo `novo`) perderam o
`StripTags` e o `StringTrim`; o tamanho de 6 a 20 e a confirmação igual continuam. O `getData()` que o
`novoAction` grava é a senha como foi digitada. As telas de troca de senha (`UsuarioSenhaFilter`,
`MeusDadosSenhaFilter`) ainda filtram, mas só para validar: as três ações gravam o POST cru.

**Provado:** `UsuarioDadosTest::testASenhaDoCadastroGravaComoFoiDigitada`, nos dois bancos: o filtro devolve
"  a<b>cdefgh  " intacta, o cadastro grava, e `password_verify` confere com ela e não com "acdefgh".
Mutante: o `StripTags` e o `StringTrim` de volta na senha -- vermelho nos dois bancos.

### D349 — No PostgreSQL, quem digita o login com uma letra maiúscula não entra, e o cadastro aceita "USER1" ao lado de "user1" — CORRIGIDO em 04/10/2026 (o código; os pares que só diferem pela caixa são recusados, e o que fazer com eles é do dono) — PARCIAL, lido em 05/10/2026

> **Lido em 05/10/2026, pelo corpo inteiro:** o título dava este débito como fechado, e o corpo diz que ele está parcial: uma parte está consertada, e a outra não. Falta: a decisão do dono sobre os pares de caixa e o login com acento (no lote), medidos na réplica; depois, o índice único do D295.
>
> **06/10/2026:** decidido pelo dono (lote de perguntas, perguntas 8 e 9): o inativo de cada par que só difere pela caixa é renomeado no passo 3 do corte, e o login com acento espera a medida na réplica. Falta: as medidas, a decisão da 9 se a medida der mais que zero, o passo na janela e o índice único do D295.
>
> **08/10/2026:** o dono respondeu a pergunta 35 da página do lote (07/10/2026) com a opção (b), "Siga a regra da recomendação quando medir": a 9 não volta a ele com o número. Zero, nada; poucos, nada no código e um aviso a cada um; muitos, a comparação sem acento. O número que separa "poucos" de "muitos" não foi dado, e só a fronteira volta a ele. Falta: as medidas, o aviso ou a comparação conforme o número, o passo na janela e o índice único do D295 -- ver o fim deste registro.

Achado pelo desenho do resto de Usuários (`docs/f3-telas-restantes/tela-usuarios-resto.md`, D-D). Amplia
o D295, e é risco do corte, e não só da tela.

- A entidade grava login e e-mail em minúsculas, mas a entrada procura o login como veio (o adaptador
  de `config/autoload/doctrine.global.php:96-106`, pela propriedade `login`), e a unicidade do cadastro,
  da edição e do "Esqueci a senha" compara com caixa
  (`module/ISConfiguracao/src/Entity/UsuarioRepository.php:145`, `:163` e `:192`). No MySQL
  (`utf8mb3_general_ci`) a comparação ignora a caixa, e tudo funciona; no PostgreSQL, não. O
  `loginLivre` já usa `LOWER`.
- O campo de login não desliga a maiúscula automática do teclado do celular.
- **Medido** (consulta C16, no PostgreSQL local): `login = 'USER1'` acha 0, e `LOWER(login) = 'user1'`
  acha 1. Quantos logins com maiúscula a produção tem só a produção diz -- a cópia reescreveu os logins
  --, e a contagem vai à lista do corte:
  `SELECT COUNT(*) FROM usuarios WHERE login <> LOWER(login) OR email <> LOWER(email)`, antes de ligar o
  PostgreSQL.

**O conserto** (a F0 de Usuários, sem decisão): `LOWER` nas três consultas, a identidade em minúsculas
antes de entrar, e `autocapitalize="none"` no campo. O mutante que tira o `LOWER` tem de ficar vermelho
só no PostgreSQL -- é a prova de que o caso mede.

> **Correção de 03/10/2026, no mesmo dia (CORTE-LOGIN-COM-MAIUSCULA): a contagem acima dá zero no
> MySQL, com qualquer número de maiúsculas.** Na collation da coluna (`utf8mb3_general_ci`), `'User1'`
> e `'user1'` são iguais, e `login <> LOWER(login)` é falso em toda linha. Medido num banco descartável
> montado do dump de produção, com nove usuários inventados: ela deu **0**; byte a byte, eram 5 logins
> e 2 e-mails com maiúscula. A conta certa é `scripts/medir-logins-com-maiuscula.sql`, que compara
> `CAST(... AS BINARY)`, separa os ativos, conta os de acento e os **pares que só diferem pela caixa**
> -- sem imprimir login nem e-mail, só contagens e ids -- e traz, comentados no molde do D28, os dois
> `UPDATE` que baixam a caixa de quem não tem par e é todo ASCII (no mesmo banco: 3 logins e 1 e-mail
> na primeira vez, nada na segunda). No procedimento do corte ela é o 5 de "O que hoje bloqueia" (só
> lê, na réplica), e o `UPDATE`, o passo 3 da janela -- **só junto com o código deste débito**: o dado
> sem o código troca quem não entra (quem digitava "User1" como estava gravado), e o código sem o dado
> deixa de achar o "User1" gravado. O que fazer com os pares é pergunta no lote (D31, D295).
>
> **E o código deste débito é pré-condição do corte** (revisão de 03/10/2026): é o 6 de "O que hoje
> bloqueia". Sem ele, o teste manual do passo 8 -- entrar digitando a primeira letra maiúscula --
> reprova por construção no PostgreSQL. A medida e os dois `UPDATE` foram provados também no MySQL
> 8.0.46, a versão das réplicas do BVIP onde o 5 manda rodá-la: os mesmos números do 8.4.

#### CORRIGIDO em 04/10/2026 -- o item USUARIOS-F0 (o código)

**O conserto:**

- **A busca sem caixa:** `UsuarioRepository::selecionarPorLogin` e `selecionarPorEmail` comparam o `LOWER`
  da coluna com o valor sem a caixa e sem os espaços das pontas (`RegraDoUsuario::semCaixa`, com
  `mb_strtolower`, que baixa o acento como o `LOWER` do banco) e devolvem ATÉ DOIS, pelo id. O `LOWER` da
  coluna acha também o login gravado com maiúscula (de antes do setter, de importação): o código sozinho
  basta para entrar, e o `UPDATE` do 5 do corte fica inofensivo, e não mais necessário para a entrada.
- **A entrada procura por ali**, entre os ativos (o inativo não entra de qualquer jeito: o
  `credential_callable` confere o `status`), e o adaptador confere a senha DO USUÁRIO ACHADO, pelo id: o
  `identity_property` de `config/autoload/doctrine.global.php` passou de `login` a `id`, e o cookie também
  autentica pelo id. Pelo login, o adaptador fazia a própria busca -- com caixa no PostgreSQL, e no MySQL
  podendo achar a outra linha do par. A sessão é a do usuário conferido (`$result->getIdentity()`), e não
  uma terceira busca pelo login.
- **O par de ativos é recusado, e não escolhido em silêncio:** na entrada e no "Esqueci a senha", com a
  frase dele (`RegraDoUsuario::FRASE_DO_PAR`: "Há mais de um cadastro ativo com este login. Peça ao
  administrador que o corrija antes de entrar." -- só a quem sabe a senha de um dos dois, desde a
  revisão); a credencial do gerente (`autenticacao` -- cancelar, fiado, devolução, resgate) devolve nulo,
  e cada porta diz o par (a revisão). Com um inativo no par, só o ativo pode entrar, e entra. O que
  fazer com os pares continua do dono (D31, D295; o CORTE-LOGIN-COM-MAIUSCULA, no lote).
- **A unicidade do cadastro e da edição** (`UsuariosController::validarCampoUnico`) vê o segundo: basta
  um que não seja o próprio para o login, ou o e-mail, estar tomado. Na edição, só o valor que mudou (a
  revisão): o par que já existe não trava a ficha.
- **O teclado do celular:** `autocapitalize="none"` e `spellcheck="false"` no login da entrada, do cadastro
  e das credenciais do gerente (ERP e tela nova) e no e-mail do "Esqueci a senha"; `autocomplete`
  `username`, `current-password` e `email` onde o navegador guarda, e `off` no cadastro de outra pessoa.
- `conferir-linha-sorteada.php` desceu de 6 para 3: as três buscas deixaram de sortear.

**O que o deploy muda já no MySQL:** quem está num par de DOIS ATIVOS -- o mesmo login, com ou sem
diferença de caixa -- deixa de entrar, e de autorizar como gerente, até o administrador corrigir um dos
dois, e ouve a frase do par; quem está num par de dois ativos no mesmo E-MAIL deixa de pedir o link do
"Esqueci a senha". Antes, o MySQL entregava um deles. O par com um inativo não muda nada para o ativo, e
nenhum par trava a edição da ficha (a revisão). Em 19/08/2026 a produção tinha 0 logins com mais de um
ativo (D31); os e-mails repetidos entre ativos o D31 não separou, e ninguém mediu. As contas de hoje, as
dos logins e as dos e-mails, estão no lote, só contagens.

**Medido na cópia** (só contagens): 0 pares de caixa, 0 repetidos sem caixa, 0 com mais de um ativo e 0
com maiúscula, nos logins e nos e-mails -- a anonimização reescreveu as duas colunas (o D31); só a réplica
responde.

**Provado:** `LoginSemCaixaTest`, nos dois bancos, despachado em processo com o serviço de autenticação de
verdade: o login com maiúscula (e com espaços) entra e a sessão é a da pessoa; o login GRAVADO com
maiúscula entra digitado sem ela; a senha errada, o login inexistente e o desativado continuam recusados;
o par de ativos é recusado com a frase, com a senha de qualquer um dos dois, e nenhum token muda; com o
gêmeo inativo, o ativo entra, e a senha do inativo não abre a conta dele; o e-mail com maiúscula é achado
e o par de e-mails é recusado antes de gerar link; a credencial do gerente sem caixa, e nula com o par; e o
cadastro e a edição recusam o login e o e-mail de outra pessoa em outra caixa, com o controle que cadastra.
`SenhaEAcessoTest` passou a ler a lista. Por HTTP, o caso 18 de `ensaiar-sessao-do-erp.php`. Mutantes, um
de cada vez: o `LOWER` da coluna do login e o do e-mail -- vermelhos SÓ no PostgreSQL (a prova de que os
casos medem: no MySQL a collation ignora a caixa); o valor do e-mail sem o `semCaixa` -- só no PostgreSQL;
o valor do login sem o `semCaixa` -- nos dois (no MySQL, pelos espaços das pontas); e a recusa do par na
entrada, no "Esqueci a senha" e na credencial, e a unicidade que olha só o primeiro (este, até a revisão:
ver abaixo) -- vermelhos nos dois.

**O que fica** (revisto na revisão do mesmo dia, logo abaixo):

- os pares: a decisão do dono, e o índice único do D295 depois dela;
- dois cadastros simultâneos com o mesmo login ainda passam os dois (sem índice único, o D295);
- a caixa é a mesma nos dois bancos; o acento e o espaço gravado, não -- ver a revisão.

#### A revisão do mesmo dia (o USUARIOS-F0 depois da verificação)

Seis pontos da primeira versão não se sustentaram, e foram consertados:

- **A frase do par saía antes de conferir a senha.** Quem postasse um login de um par com uma senha
  qualquer ouvia que o login existe e está repetido -- antes, login inexistente e senha errada davam a
  mesma resposta. Agora a frase só sai para quem sabe a senha de um dos ativos do par
  (`UsuarioRepository::parComEstaSenha`, sem limite: o terceiro ativo também conta); com outra senha, a
  entrada diz "Dados inválidos.", como o login que não existe. O "Esqueci a senha" continua dizendo o par
  pelo e-mail -- ele já dizia se o e-mail existe.
- **A credencial do gerente dizia "Login ou senha não conferem." com a senha certa.** As oito portas que a
  conferem (o fiado, o cancelamento e a devolução de crédito no ERP e na API, o resgate nos dois) tratavam
  o nulo do par como senha errada. Agora cada uma pergunta a frase a `UsuarioRepository::recusaDaCredencial`:
  ao gerente que sabe uma das senhas, "Há mais de um cadastro ativo com este login. Peça ao administrador
  que o corrija antes de autorizar.", e na API só o login marcado (a senha conferiu); ao resto, a de
  sempre de cada porta. O resgate da API devolve a frase do par como 403 -- a credencial confere e a
  pessoa não pode autorizar --, e as outras portas da API, como 422.
- **"Meus dados" conferia a senha atual pelo login** (`autenticacao($usuario->getLogin(), ...)`): quem está
  num par de ativos ouvia "A senha informada não confere com a atual." com a senha certa. Agora confere
  na pessoa da sessão (`UsuarioRepository::confereASenhaDe`: ativo, e `password_verify` contra o bcrypt).
- **A edição de quem está num par era recusada sempre** -- e não só com dois ativos: com o gêmeo inativo
  também (desativar põe o `_old` no login e deixa o e-mail), e qualquer edição (a agenda, o PDV, o nome)
  dava "E-mail já cadastrado no sistema.", sem dizer com quem. A unicidade da edição passou a não conferir
  o valor igual ao gravado, sem a caixa: ela impede o repetido NOVO, e o que fazer com o par que já existe
  é a pergunta do dono. O cadastro confere tudo, como antes. Por isso o mutante "a unicidade olha só o
  primeiro" da primeira versão deixou de ter caso: o próprio só volta na busca quando o valor mudou e a
  collation do MySQL ignora o acento que o `semCaixa` não ignora (o ponto seguinte) -- a volta pelo resto
  da lista fica como defesa.
- **A caixa é a mesma nos dois bancos; o acento e o espaço gravado, não.** Medido com SELECT: no MySQL
  (`utf8mb3_general_ci`) "joao" acha "joão" e "joao " acha "joao" (PAD SPACE), e os dois contam como par;
  no PostgreSQL, nenhum dos dois. Depois do corte, quem digita sem o acento o login gravado com acento
  deixa de entrar. O `semCaixa` não promete mais a paridade no comentário; quantos logins têm acento só a
  réplica diz (`scripts/medir-logins-com-maiuscula.sql` os conta: 0 na cópia, que a anonimização
  reescreveu), e o que fazer foi ao lote, junto do CORTE-LOGIN-COM-MAIUSCULA.
- **Três campos de login de gerente ficaram sem o `autocapitalize`** -- o do cancelamento da venda
  (`VendaCancelarModal`) e o do resgate de pontos nas duas telas do PDV (`comanda.phtml`, `novo.phtml`) --,
  e o texto acima dizia "todos". Agora estão, e nenhum dos nove campos tinha caso.

**Provado** nos dois bancos:

- `LoginSemCaixaTest`: o par com a senha errada dá "Dados inválidos.", como o login que não existe; o
  gêmeo INATIVO de id MENOR (90000999, abaixo da pessoa: nenhuma fixture tem id entre 89000000 e
  90000999) não toma a entrada -- o "não provado" da primeira versão, que era barato medir; a recusa da
  credencial diz o par com a senha de cada um dos dois e não com a de nenhum, e marca só o login; o fiado
  do ERP responde a frase do par; a edição com o par que já existe passa -- com o "Editado com sucesso." no
  controle, e não só a ausência da recusa --, inclusive trocando só a caixa, e a que muda o valor para o
  de outra pessoa continua recusada; e os campos de login dos formulários e dos dois modais do PDV
  (a tag lida sem PHP nem comentário de HTML) têm os atributos.
- `D317A320RestosDoD316Test::testOFiadoPelaApiDizQueOGerenteEstaNumPar`: 422, a frase do par, só o login
  marcado, e nada gravado na comanda.
- `SenhaTrocadaTest::testQuemEstaNumParDeLoginsTrocaASenhaEmMeusDados`: com o gêmeo ativo, a troca passa
  com a senha atual certa e é recusada com a errada.
- No front, os três campos de login da tela nova (`CredencialDoGerente`, o fiado e o cancelamento da
  comanda) têm `autocapitalize="none"` e `spellcheck="false"`.
- O `conferir-autorizacao-privilegiada.php` deriva do código as portas da credencial -- todo método de
  `module/` que chama `->autenticacao(` -- e exige `recusaDaCredencial` em cada uma (8 hoje); vigia também
  `confereASenhaDe` e `parComEstaSenha` (o `password_verify`, sem o `getSalt`).
- Mutantes, um de cada vez: a frase do par sem conferir a senha; a busca antiga do adaptador (o
  `identity_property` pelo login, com o login já sem a caixa) -- no MySQL, só o caso do gêmeo de id menor
  a pega, e era o que faltava (no PostgreSQL, o do login gravado com maiúscula); a recusa da
  credencial sem o par; o par sem a senha; o fiado do ERP e o da API com a frase de antes; "Meus dados" de
  volta ao login; a isenção do valor que não mudou arrancada, comparando com a caixa, e valendo para
  qualquer valor; e o atributo arrancado do cancelamento, dos dois resgates, do fiado do ERP, da entrada e
  da devolução -- vermelhos; no front, o atributo arrancado de cada um dos três campos -- vermelhos. O
  portão, falsificado arrancando `recusaDaCredencial` de duas portas sem caso próprio (o cancelamento da
  API e a devolução do ERP), e deixando o nome só num comentário: vermelho. Ele é estrutural: a porta que
  chamasse e jogasse fora a frase passaria por ele.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, perguntas 8 e 9): na 8, a opção (b), "Renomear
o login do inativo de cada par, e baixar a caixa de todos" -- no passo 3 do corte, os quatro `UPDATE` de
`scripts/medir-logins-com-maiuscula.sql`; o par com dois ativos fica com o dono, e a listagem 4c nova dá
os ids antes da janela; a 4b confere que o nome novo cabe na coluna e não é de outra linha. "Par" é só
a caixa: a chave é `CAST(LOWER(login) AS BINARY)`, o minúsculo byte a byte. O ensaio está no
repositório, `php scripts/ensaiar-sql-do-passo-3.php`, com dezenove usuários inventados -- o par de um
ativo, o de dois inativos, o de dois ativos com um inativo, o de acento, o repetido exato, os pares de
e-mail e o e-mail vazio: 4, 1, 5 e 2 linhas na primeira vez, cada usuário conferido depois, nada na
segunda, no 8.4.11 e no 8.0.46; com um nome novo maior que a coluna, o modo estrito recusa o primeiro
`UPDATE` inteiro, e a 4b o acusa antes, junto com o nome novo que já é de outro. Na 9, a opção (d),
"Decidir depois da medida na réplica": a medida é a coluna `login_com_acento_ou_simbolo` do 5 de "O que
hoje bloqueia". Com zero, a pergunta some; com mais que zero, ela volta ao dono com o número e as
opções (a) a (c). O que resta: as medidas na réplica, a decisão da 9 se a medida der mais que zero, o
passo 3 na janela e o índice único do D295.

**A revisão de 06/10/2026 corrigiu duas coisas deste parágrafo.** (1) A primeira versão dava como
decidida a regra "poucos, nada no código e aviso a cada um; muitos, a comparação sem acento" -- e ela era
o texto da RECOMENDAÇÃO da pergunta 9; a resposta (d) é só "decidir depois da medida". Lida como
decisão, a medida "muitos" levaria sem pergunta à opção (c), a extensão `unaccent` e um índice de
expressão, que é mudança de esquema. (2) Os dois primeiros `UPDATE` agrupavam pela collation
(`utf8mb3_general_ci`), que ignora também o acento e o espaço do fim: no ensaio de então, o inativo 111
de um par "paulo"/"Páulo" virou "Páulo.inativo111" -- era um dos 5 renomeados. A decisão 8 é sobre a
caixa; o acento é a 9. A chave passou ao minúsculo byte a byte nas listagens 3, 4 e 4b e nos quatro
`UPDATE` (a 4c fica pela collation, que é como a entrada do MySQL vê o login), e o par de acento fica
como está -- no PostgreSQL ele nem é par: o `LOWER` de um não é o do outro. O ensaio no repositório
substitui o do rascunho, que não se refazia, e fica vermelho com a chave pela collation.

**Decidido pelo dono 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." --, e não a (a), "Está certo assim: com o número, a pergunta volta para mim", que era a
recomendação. A regra é agora a resposta do dono: com o número da coluna `login_com_acento_ou_simbolo` (o
5 de "O que hoje bloqueia"), zero não pede nada; poucos, nada no código e um aviso a cada um (a opção (a)
da pergunta 9); muitos, a comparação sem acento também no PostgreSQL (a (c) da pergunta 9, a extensão
`unaccent` e um índice de expressão, que é mudança de esquema). **O número de corte não foi dado:** a
recomendação original da pergunta 9 dizia "se der poucos, (a) com aviso a cada um; (c) só se forem
muitos", sem dizer onde fica a fronteira, e a resposta também não. Com o número na mão, a escolha entre
"poucos" e "muitos" volta ao dono só se ficar na fronteira. O procedimento (o 5 e o 6 de "O que hoje
bloqueia" e o passo 3) e o cabeçalho de `scripts/medir-logins-com-maiuscula.sql` dizem isso desde
08/10/2026. O que resta: a medida na réplica e, conforme o número, o aviso ou a comparação sem acento.

### D350 — As configurações da unidade e a chave do WhatsApp viajam copiadas na sessão e na fila: trocar em Unidades não alcança quem já entrou — REGISTRADO em 03/10/2026 — PARCIAL em 12/10/2026: a fidelidade e a tela antiga do WhatsApp leem do banco — PARCIAL em 23/10/2026: a `/adm/api/whatsapp` lê do banco; seguem copiados o menu, o `SessaoAcl` e os dias de convênio — PARCIAL em 07/11/2026: a sessão não copia mais nada da configuração da unidade (só o id, o tipo, o país, a moeda e o DDI), e os leitores leem o banco; resta a fila

Achado pelos desenhos de Unidades (U13) e do WhatsApp (W2), que o consolidado juntou como um defeito só
(`docs/f3-telas-restantes/telas-consolidado.md`, seção 4, item 15). É da família do D291.

- No login, `SessaoAcl` copia na sessão de todo usuário da unidade os dias de convênio, o aviso de
  cadastro, o "só o primeiro nome na agenda" e a chave, a instância, o provedor e o `whatsapp_ativo`
  (`module/ISConfiguracao/src/Permissions/SessaoAcl.php:44-58`) -- tenha ele o privilégio ou não.
- **Quem lê a cópia:** o PDV antigo, os clientes antigos, a grade nova da agenda, a tela antiga do
  WhatsApp (`module/ISConfiguracao/src/Controller/WhatsAppController.php:104-106`), o menu e a fidelidade
  nova, que decide e enfileira o código de resgate pela sessão. E cada linha da fila `push_app` leva a
  chave.
- **O efeito:** trocar a chave, desligar o WhatsApp ou mudar os dias de convênio em Unidades não vale para
  quem já entrou, até o próximo login; e a fila guarda a chave antiga. Não medido: é por construção.

**O conserto:** as telas novas leem do banco (a F1 do WhatsApp; a de Unidades diz "vale no próximo
login"); a fidelidade passa a ler a unidade do banco -- conserto pequeno, sem decisão (seção 5 do
`tela-whatsapp.md`).

#### PARCIAL em 12/10/2026 -- a fidelidade e a tela antiga do WhatsApp leem o WhatsApp e a chave do banco (o item WHATSAPP-F0-E-CENTRAL)

- **O código de resgate e o "resgate realizado"** (`ClienteService::gerarCodigoDeResgate` e
  `programaFidelidadeResgatarPontuacao`, que a tela nova da fidelidade e o ERP antigo -- o `ClientesController` -- chamam): a sessão só dá o id da
  unidade; o `whatsapp_ativo`, a chave, a instância e o provedor saem do banco (`unidadeQueMandaWhatsapp`), e a linha da
  fila leva os do banco (`PushApp::setCredenciaisWhatsappUnidade`). O WhatsApp ligado sem a chave ou a instância não conta
  como canal -- como o login do aplicativo já fazia --: o código cai no aplicativo, se o cliente o usa, ou em nada, e a
  tela nova diz que nada foi enviado, em vez de "código enviado pelo WhatsApp".
- **A tela antiga do WhatsApp** decide pelo banco a cada pedido (o D305, a F0).

**Provado** no `FidelidadeDaTelaNovaTest` (quatro casos novos, e os de antes passando a ligar o WhatsApp no banco, dentro
da transação), nos dois bancos: a sessão dizendo ligado e o banco desligado manda pelo aplicativo; o banco ligado e a
sessão desligada manda pelo WhatsApp, com a chave, a instância e o provedor do banco, e não os da sessão; ligado e sem
chave não é canal; e o "resgate realizado" segue o banco nos dois sentidos. Cinco mutantes, um de cada vez, vermelhos: as
duas decisões pela sessão, as duas credenciais da sessão, e o "ligado sem chave" contando como canal.

**A verificação do item (12/10/2026)** acrescentou a instância: o caso só media a chave em branco, e a checagem da
instância em `unidadeQueMandaWhatsapp` saía sem vermelho -- a unidade ligada, com a chave e sem a instância, voltaria a
contar como canal, com uma linha que falha na fila e a tela dizendo "código enviado pelo WhatsApp". O
`testOWhatsappLigadoSemChaveNaoContaComoCanal` mede agora a chave em branco, a instância em branco e a instância nula; o
mutante, vermelho.

**O que segue copiado na sessão**, e fica com as telas novas de cada um: os dias de convênio, o aviso de cadastro e o "só o
primeiro nome na agenda" (o PDV antigo, os clientes antigos, a grade nova da agenda), o menu (`SessaoJson`) e o
`SessaoAcl` copiando a chave para todo usuário da unidade. E as linhas da fila das unidades continuam levando a chave
delas -- a não ser a do código de entrada do aplicativo, que sai pelo WhatsApp central sem chave na linha (o D59), e a
linha que falhou, que perde a chave (o D302).

#### PARCIAL em 23/10/2026 -- a API nova do WhatsApp lê o WhatsApp e a credencial do banco (a fatia 30, WHATSAPP-ESTADO)

- **O `GET /adm/api/whatsapp`** relê a unidade da sessão no banco a cada pedido (`find($this->unidadeId)`): o
  `whatsapp_ativo`, a situação da unidade, a chave, a instância e o provedor. A sessão só dá o id. Desligar o WhatsApp em
  Unidades, ou fechar a unidade, vale no pedido seguinte de quem já entrou.
- **Provado** no `WhatsAppPelaApiTest`, nos dois bancos: a sessão do caso diz sempre "ligado", com uma chave e uma
  instância dela; a unidade desligada no banco dá `desligado` sem construir o cliente, e a ligada constrói o cliente com a
  credencial do banco, e não a da sessão (`testDesligadoNoBancoComASessaoDizendoLigado`,
  `testOGetSoConsultaENuncaConecta`). O mutante que decide pelo `whatsapp_ativo` da sessão, vermelho.
- **O que segue copiado**, como estava: o menu (`SessaoJson`, que decide se o item "WhatsApp" aparece), o `SessaoAcl`
  copiando a chave para todo usuário da unidade, os dias de convênio, o aviso de cadastro e o "só o primeiro nome na
  agenda". O menu entra com a tela (a fatia 34 da fila).

#### Anotado em 24/10/2026 -- a tela nova do WhatsApp não mudou o menu (a fatia 34, WHATSAPP-TELA)

A nota de 23/10 dizia que "o menu entra com a tela". Não entrou: o desenho da tela (`docs/f3-telas-restantes/tela-whatsapp.md`, a tabela "quem vê e quem escreve") manda o menu ficar como está ("igual: o menu já existe"), e a fatia é só do front. O item "WhatsApp" segue o `configuracoes/whatsapp/escrever` e o `whatsapp_ativo` copiado na sessão (`SessaoJson`). O efeito, agora com a tela nova: desligar o WhatsApp em Unidades deixa o item no menu de quem já entrou, e a tela diz "desligado", pelo banco -- nada sai errado; ligar não mostra o item a quem já entrou, até o próximo login. A marca não muda: o menu continua na lista do que segue copiado.

#### PARCIAL em 07/11/2026 -- a sessão não copia mais a configuração da unidade (a fatia 109, SESSAO-LE-O-BANCO)

- **O login (`SessaoAcl::setAcl`)** não copia mais os dias de convênio, o `clientes_atualizar`, o `agenda_primeiro_nome`, o
  `whatsapp_ativo` nem a chave, a instância e o provedor do WhatsApp: o `acl['unidade']` fica com o id, o tipo, o país, a moeda e o
  DDI. O `PushApp::setCredenciaisWhatsappSessaoUnidade`, sem chamador desde 12/10, saiu.
- **Cada leitor lê a unidade no banco, a cada pedido** (a lista saiu do grep de `getUnidade('...')`, de `$unidade['...']` e do
  arranjo da sessão passado adiante, e não de memória): o menu da tela nova (`Adm\SessaoController::verAction`, pelo
  `SessaoJson::comAUnidadeDoBanco`: o item WhatsApp e o aviso do "só o primeiro nome"); o item "WhatsApp" do menu antigo
  (`Service\WhatsAppDoMenu`, ouvinte do `render` no molde do `CategoriasDoMenu`: só consulta quando o layout vai ser desenhado e
  a pessoa tem o `configuracoes/whatsapp/escrever`); a grade nova e a lista de agendamentos (`Adm\AgendasController::gradeAction`,
  `Adm\AgendamentosController::indexAction`); a grade antiga (`AgendasController::getAgenda` entrega o `agendaPrimeiroNome` ao
  `tabela.phtml`); a busca de clientes antiga (`ClientesController::autoCompliteAction`, o prazo do cadastro, como a busca nova já
  fazia); e o convênio do dia (`ConvenioUnidadeRepository::convenioAtivo` lê os dias da unidade pelo `id` do arranjo que chega --
  o PDV antigo, os clientes antigos e a comanda nova passavam a cópia da sessão; a venda já passava os da unidade dela).
- **O custo** é um `find` da unidade por porta (o mapa de identidade o reaproveita no mesmo pedido); o menu antigo só consulta para
  quem tem o privilégio do WhatsApp. A sessão aberta antes da entrega ainda traz as chaves velhas, e ninguém mais as lê; a chave
  continua sem sair pela API (o `SessaoJson` escolhe campo a campo).
- **A tela nova de Unidades dizia o contrário** até a verificação da fatia: "O PDV antigo e a agenda leem isto no login: quem já
  está no sistema vê a mudança no próximo login" nos dias de convênio, "vale no próximo login" no "só o primeiro nome", e as duas
  frases do "salvar" (`Unidades.tsx`, `formularioDaUnidade.ts`; o desenho `tela-unidades.md`, U13). A lista dos leitores tinha
  saído do grep do servidor, e as frases da tela que descreviam a cópia ficaram de fora. Agora dizem "vale na hora, também para quem
  já está no sistema", e o desenho anota a U13 como resolvida (`UnidadesLigado.teste.tsx`, `UnidadesLigado.escrita.teste.tsx`;
  mutantes F4 e F5 vermelhos).

**Provado** no `SessaoLeOBancoTest` (8 casos), nos dois bancos: os seis casos dos leitores são "a sessão diz X, o banco diz Y" --
a sessão do caso traz a cópia velha, e o resultado segue o banco, nos dois sentidos (o menu da API, o item do menu antigo com e
sem o privilégio, a grade e a lista novas, a grade antiga, a busca antiga, os sete dias do convênio); os outros dois são o login
com só as cinco chaves (e o método da fila pela sessão ausente) e o D546. A busca antiga só provava um sentido até a verificação
(a cópia do arnês não trazia prazo nenhum); desde ela, quatro combinações -- o banco pede e a cópia não, o banco não pede e a cópia
sim, com prazo e sem prazo no banco --, e o mutante que lê a cópia quando o banco não tem prazo fica vermelho (o N8). E o item do
menu antigo passa pelo ouvinte que a APLICAÇÃO liga no `render` (achado no `EventManager` dela, um só, nenhum no despacho), e não
por um construído pelo caso: arrancar o `attach` do `Module::onBootstrap`, ou ligá-lo no despacho, fica vermelho (N6, N7). Os casos da grade antiga do `D203TextoDigitadoNasTelasDoErpTest` passaram a dar a cópia velha ao contrário do
que o controller entrega. Mutantes, um por vez, vermelhos e devolvidos pelo sha1 (M1 a M9): o menu da API pela sessão, o item do
menu antigo pela sessão, o ouvinte sem o privilégio, as duas portas da agenda nova pela sessão, o `tabela.phtml` pela sessão, a
busca antiga pela sessão, o convênio pelos dias do arranjo, e a chave de volta ao login.

**O que resta: a fila.** Cada linha de `push_app` de unidade leva a chave, a instância e o provedor do momento em que entrou na
fila (`PushApp::setCredenciaisWhatsappUnidade`, lidos do banco nessa hora); trocar a chave em Unidades não alcança o que já está
enfileirado. Ficou de fora: o conserto é o envio (a rotina de marketing, que esta fatia não roda nem muda) ler a credencial da
unidade da linha na hora de mandar, e a triagem não o pôs nesta fatia.

### D351 — Desativar uma unidade não desliga nada: as pessoas dela continuam entrando, o aplicativo a abre pelo endereço, e quatro unidades fechadas mandam WhatsApp a 19.270 clientes — REGISTRADO em 03/10/2026 — PARCIAL em 13/10/2026 — CORRIGIDO em 07/11/2026

Achado por três desenhos -- Unidades (U15), WhatsApp (W1) e o resto de Usuários (D-K) -- que o
consolidado juntou na pergunta P6, "o que uma unidade desativada deve desligar"
(`docs/f3-telas-restantes/telas-consolidado.md`).

- **A entrada** só confere o usuário (`config/autoload/doctrine.global.php:104-106`): 99 usuários ativos,
  em 24 unidades inativas, entram; 4 deles entraram nos 90 dias antes da cópia, um com 502 acessos
  (`tela-unidades.md`, R11; `tela-usuarios-resto.md`, C10). O filtro por unidade da tela de usuários não
  oferece as inativas, e ninguém os acha por ali.
- **O aplicativo** recebe a unidade pelo id sem olhar a situação (`/api/agendas/getUnidade/:id`,
  `module/ISConfiguracao/src/Entity/UnidadeRepository.php:191-203`): 7 inativas estão marcadas visíveis
  (R8).
- **As rotinas de WhatsApp** (aniversário, primeira visita, sentimos sua falta) olham só o
  `whatsapp_ativo`, e não `unidades.status`; o login do aplicativo v4 e a indicação pública, idem.
  Medido (`tela-whatsapp.md`, Q1 e Q10): 4 unidades fechadas com o WhatsApp ligado, com 19.270 clientes
  ativos com data de nascimento e o modelo de aniversário. Se a instância delas ainda conecta, só a
  produção diz.

**O que fazer é do dono** (a P6, sem resposta registrada até 03/10/2026): só as listas, como hoje;
vitrine e WhatsApp; também o login; e a cobrança (o U16 de Unidades). A recomendação é "vitrine e
WhatsApp" agora, e "também o login" depois que o dono olhar quem são os 4 que entraram. Sem decisão, já
se pode dar ao filtro do administrador, na tela de usuários, as unidades inativas.

#### O conserto — 13/10/2026 (a resposta 132 do dono, a opção (b))

O dono respondeu a pergunta 132 do lote (a P6 do consolidado), em 11/10/2026, com a opção (b), **e não a recomendada**:
**"A (a), e também o login"**. A (a), conferida no texto da pergunta: *"O aplicativo recusa a inativa também pelo
endereço, as 4 fechadas param de mandar mensagem, a fatura cobra o ciclo trabalhado e o relatório mostra as inativas"*; a
(b) acrescenta: *"Quem é lotado em unidade inativa deixa de entrar, menos o administrador."* O lado do dinheiro é o
**D367**; este registro leva o resto (o item UNIDADES-GRUPOS-ROYALTIES).

- **A entrada.** `Usuario::entraPelaUnidade()`: o administrador entra sempre; quem não é entra se não tem unidade ou se
  ela está ativa. O `credential_callable` (`config/autoload/doctrine.global.php`) pergunta isso, e por ele passam a senha
  e o cookie (`entrarCookies`). A sessão já aberta sai no pedido seguinte, pela guarda de despacho
  (`ISConfiguracao\Module::handleAuthRedirect`), como a do desativado do **D294**: a rota do ERP cai no "entrar", e a API
  responde 401 com a sessão vazia. Com a senha certa, a entrada diz por quê -- *"A sua unidade está desativada no
  sistema: só o administrador da rede entra. Fale com ele."* (`RegraDoUsuario::FRASE_DA_UNIDADE_INATIVA`); com a errada,
  "Dados inválidos.", e a unidade desativada não se anuncia a quem só adivinhou o login. Na personificação a identidade é
  a de quem entrou, o administrador, e ele continua podendo assumir quem é de unidade inativa. Na cópia, perdem a entrada
  **97 pessoas ativas não administradoras** lotadas em unidade inativa; as 2 administradoras lotadas nelas continuam. As
  fixtures (a 90001000 e as outras das unidades de teste) estão todas em unidades ativas, e o ensaio que desativa uma
  unidade (o 154 do `ensaiar-recusas-adm-api.php`) a usa pelo administrador.
- **O aplicativo.** O detalhe da unidade (`UnidadeRepository::getUnidadeColaboradoresServicos`, que serve o
  `/api/agendas/getUnidade/:id` e o da v2) e os serviços dela (`/api/agendas/getServicos/:id`) respondem à inativa como ao
  id que não existe (`sucesso: false`). Eram 7 inativas marcadas visíveis.
- **O WhatsApp.** `Unidade::mandaWhatsApp()` (ligado **e** ativa) e `Unidade::whatsappCalado()` (ligado numa fechada). As
  rotinas do parabéns, da primeira visita e do "sentimos sua falta" leem a situação da unidade na linha (`unidadeAtiva`,
  nas três consultas de `ClienteRepository`) e pulam o cliente da fechada; o NPS da venda não manda pela calada (e a
  venda fica marcada); a campanha da fechada não chama o provedor (o destinatário conta como "não recebeu"); e a
  indicação da adesão pública só manda pela unidade que manda. **Calada, e não desviada:** onde o envio cai no push do
  aplicativo quando o WhatsApp está desligado, a fechada com ele ligado não cai -- seria canal novo, que a resposta não
  pediu (5.343 clientes ativos das 4 usam o aplicativo, e as 4 têm as mensagens dele). O login do aplicativo v4 já não
  passa pela unidade: o código sai pelo WhatsApp central (a resposta 147). **Mudou em 14/10/2026** (a resposta 178 do
  dono, a (c); o **D483**): o "calada, e não desviada" acabou -- a fechada com o WhatsApp ligado cai no aplicativo, como a
  sem WhatsApp, nas três rotinas e no NPS da venda. O `whatsappCalado` saiu; a regra é só `Unidade::mandaWhatsApp` (e a
  irmã para a linha do banco, `mandaWhatsAppPelaLinha`, a mesma conta). O WhatsApp da fechada continua calado.

**Provado** em `tests/Caracterizacao/D351UnidadeDesativadaTest.php` (13 casos) e num caso novo do
`D304EtapaSeisDaAdesaoTest` (a indicação), nos dois bancos, em série: o adaptador recusa quem é de unidade inativa e
aceita o administrador; a entrada dá a frase só com a senha certa, e reativada a unidade entra; a sessão aberta sai no
pedido seguinte quando a unidade é desativada pelo serviço, e a do administrador fica; o detalhe e os serviços do
aplicativo não abrem a inativa; o parabéns, a primeira visita, o "sentimos sua falta" e o NPS saem pelo WhatsApp com a
unidade ativa e não saem -- nem pelo aplicativo -- com ela fechada (até 14/10/2026: desde a 178, os quatro casos dizem que
a fechada sai pelo aplicativo, o D483); a campanha da fechada não chama o provedor; a regra da entidade. As rotinas só
enfileiram, e quem manda a fila não é chamado; a campanha usa o WhatsApp de mentira.
**Mutantes**, um por vez, todos vermelhos: o adaptador sem a unidade, a guarda sem a unidade, o administrador sem a
exceção, a entrada sem a frase, o detalhe e os serviços do aplicativo sem a situação, cada uma das três rotinas, o NPS e a
campanha sem a fechada, e a indicação pelo interruptor só.

**O que resta, e por isso PARCIAL** (revisto em 14/10/2026, no item WHATSAPP-E-APLICATIVO: das três partes que restavam
em 13/10/2026, as duas que foram ao lote voltaram respondidas e estão feitas; fica a do balcão):

- o **push do aplicativo** das fechadas -- **decidido em 14/10/2026** (a resposta 178, a (c); o **D483**): as 24 sem
  WhatsApp continuam mandando por ele, e as 4 caladas passaram a mandar também;
- a **agenda e a marcação pelo colaborador** da unidade inativa, no aplicativo -- **corrigido em 14/10/2026** (a resposta
  179, a (b); o **D482**): o `getAgenda` (e o da v2) responde ao colaborador da unidade inativa como ao que não existe, e o
  `POST /api/v4/agendamentos` dá a ele o 422 "Informe o colaborador.";
- **o que resta de fato:** as mensagens do **balcão** -- o "cliente chegou" do check-in (`AgendaService:112`), o código e
  o "resgate realizado" da fidelidade (`ClienteService:521`), o ganhador do sorteio (`SorteiosController:235`) e as da
  venda -- a pontuação da fidelidade e o cupom (`VendaPagamentoService:609` e `:725`) -- seguem decidindo pelo interruptor
  (`getWhatsappAtivo()`). Com o login recusado, só chega a elas o administrador que troca a sessão para a unidade fechada;
  ficaram como estão. Fechar é passar a cada uma a pergunta da regra de hoje, `Unidade::mandaWhatsApp` (o
  `whatsappCalado`, que este parágrafo citava até 14/10/2026, saiu), com o caso dela: pela 178, onde a pergunta diz não, a
  fechada faz o que a unidade sem WhatsApp faz -- e não fica calada nos dois canais, que era a regra que a 178 trocou.

A tela nova de Unidades, quando vier, diz no "desativar" o que acontece agora (`docs/f3-telas-restantes/tela-unidades.md`,
a P1).

*Atualizado em 16/10/2026 (a fatia UNIDADES-ESCRITA, a tela nova de Unidades com a escrita):* veio. O "desativar" do
cabeçalho da unidade pergunta no próprio botão, e o corpo diz o que acontece pela 132 (b) e pela 178 (c): sai das listas e
do aplicativo, que a recusa até pelo endereço direto; as pessoas lotadas nela deixam de entrar, com quantas entraram nos
últimos 30 dias (o `alcance`, que só o administrador recebe), e o administrador continua; o WhatsApp dela se cala, e o
parabéns, a primeira visita e o "sentimos sua falta" saem pelo aplicativo; e a fatura do dia 16 e o relatório de royalties
seguem com ela pelo ciclo que trabalhou (o D367). O ativar não pergunta, e diz o que volta. A marca não muda: o que resta
são as mensagens do balcão, acima. Provado em `frontend/src/telas/formularioDaUnidade.teste.ts` e
`UnidadesLigado.escrita.teste.tsx`.

*Atualizado em 23/10/2026 (a verificação da fatia 30, WHATSAPP-ESTADO):* a tela antiga do WhatsApp
(`ISConfiguracao\Controller\WhatsAppController`) decidia só pelo interruptor, e o administrador -- o único que entra numa
fechada -- abria a tela de uma das 4 fechadas com o WhatsApp ligado e gerava o código, reconectando o WhatsApp de uma
unidade que não manda mensagem, enquanto a `GET /adm/api/whatsapp` nova já dizia `desligado`. Agora as duas portas
perguntam `Unidade::mandaWhatsApp`: na tela antiga, a fechada é 404 na tela, no código e no desconectar, e a consulta de 5 s
recusa, sem falar com o provedor (`WhatsAppTelaAntigaTest::testAUnidadeFechadaEDesligadaNaTelaAntigaTambem`, nos dois
bancos; o mutante que volta ao interruptor, vermelho). A marca não muda: o que resta continua o balcão.

*Fechado em 07/11/2026 (a fatia 108, WHATSAPP-DO-BALCAO):* as mensagens do balcão, o que restava, perguntam agora a regra de
hoje, `Unidade::mandaWhatsApp` (o WhatsApp ligado **e** a unidade ativa), e não o interruptor: o "cliente chegou" do check-in
(`AgendaService::checkin`), o código e o "resgate realizado" da fidelidade (`ClienteService::unidadeQueMandaWhatsapp`, o
ajudante das duas), e as da venda -- o crédito do programa usado (`VendaPagamentoService::notificarUsoCreditoProgramaFidelidade`)
e o cupom gerado (`gerarCupons`). Pela 178 (c), a fechada com o WhatsApp ligado faz o que a unidade sem WhatsApp faz: o código, o
resgate, o crédito usado e o cupom caem no aplicativo; o "cliente chegou", que vai ao colaborador e não tem canal no aplicativo,
não sai. (A 178 perguntava das rotinas e das campanhas; a mesma regra no balcão é a que este corpo escreveu em 14/10/2026.)
Quem chega a cada porta -- a frase de 13/10 acima, "só chega a elas o administrador que troca a sessão", vale para três: o
código e o resgate seguem a unidade da sessão, e o "cliente chegou", a do colaborador da agenda. A venda, não: `Venda::getUnidade`
é derivada de quem abriu o caixa (a lotação), e o PDV recusa a venda de outra unidade que não a da sessão
(`VendasPagamentosController::validarVenda`, `Adm\ComandasController::comandaDaUnidade`); o crédito usado e o cupom de uma
fechada só saem de quem é lotado nela, pela personificação (na cópia, 07/11/2026, só SELECT de contagem: nenhum administrador
ativo lotado nas 4 fechadas com o WhatsApp ligado). O ganhador do sorteio já não tinha mensagem (a 217 (c)). Conferido pelo grep de todo `setTipo("whatsapp")` e de todo
`getWhatsappAtivo()` do `module/`: os que sobram ou já perguntam `mandaWhatsApp` (as rotinas, o NPS, a campanha, a indicação, o
teste da campanha), ou mandam pelo WhatsApp central (o login do aplicativo, a 147), ou só mostram o interruptor (a ficha da
unidade, o estado do WhatsApp pela `RegraDaConexaoWhatsApp::doBanco`, que já lê a situação, e a cópia da sessão, que é o D350).
**Provado** em `tests/Caracterizacao/D351MensagensDoBalcaoTest.php` (5 casos, um por porta, na unidade 90001150 das fixtures,
que tem o WhatsApp ligado): ativa, sai pelo WhatsApp; fechada com ele ligado, sai pelo aplicativo -- ou, no "cliente chegou",
não sai --; e o código diz à tela o canal certo. Nos dois bancos, em série. **Mutantes**, um por vez, nos dois bancos, devolvidos
pelo sha1, todos vermelhos: cada uma das quatro escritas de volta ao interruptor (o check-in, o ajudante da fidelidade -- dois
casos --, o crédito usado e o cupom). Nenhuma mensagem saiu: as portas só enfileiram (`push_app`), e o rollback desfaz.

### D352 — Salvar um banner inativo o devolve ao aplicativo, e o estado vem cru do POST — REGISTRADO em 03/10/2026 — CORRIGIDO em 20/10/2026

Achado pelo desenho dos Banners (`docs/f3-telas-restantes/tela-banners.md`, Dγ). Lido, não exercitado.

- O serviço grava `$dados['status'] ?? true`
  (`module/ISConfiguracao/src/Service/AppBannerService.php:30`), e os formulários de novo e de editar não
  têm o campo de estado: toda edição de um banner inativo o põe de volta no aplicativo de todos os
  clientes. E um POST que traga `status` grava o que vier.
- **Expostos:** os 5 banners inativos. Nenhum código do repositório desativa banner -- a ação nunca
  existiu (`eaf1fca`) --, e os 5 foram desativados por fora: SQL à mão, ou uma versão antiga em produção.

**O conserto** (a fatia 1 dos Banners, sem decisão): o salvar não toca o estado, e o cadastro nasce
ativo; o estado muda por ativação e desativação explícitas, por POST, como na lista antiga de usuários
desde o D294.

#### CORRIGIDO em 20/10/2026 -- a fatia 21 da fila das telas que faltam, BANNERS-SERVIDOR

O salvar não toca mais o estado: `AppBannerService::alterar` grava a descrição e a imagem, e nunca o `status`; o cadastro
nasce ativo pelo construtor da entidade (`AppBannerService::cadastrar`). O campo `status` saiu do formulário
(`Form/AppBanner.php`), que as telas nunca mostravam. O estado muda só pelo `ativarRemover` novo da lista antiga, por POST e
com o estado pedido (o D105), e pelos dois endereços da API nova (`/adm/api/banners/:id/ativacao` e `.../desativacao`, com 409).

**Provado** nos dois bancos: editar um banner inativo, com `status=1` posto à mão no POST, o mantém inativo
(`TelasDosBannersDoErpTest::testEditarOInativoOMantemInativo`); o `PUT` da API com `ativo`, `status` ou `estado` é 422 e o
estado fica (`BannersPelaApiTest::testOPutAlteraSoADescricao`). Mutante: o `setStatus(true)` de volta no `alterar` --
vermelho.

### D353 — A troca da imagem do banner acontece fora de ordem: o envio que falha apaga o banner que existia, e o arquivo antigo fica no disco — REGISTRADO em 03/10/2026 — PARCIAL em 20/10/2026: a ordem no código; restam os 4 órfãos que já estão no disco

Achado pelo desenho dos Banners (`docs/f3-telas-restantes/tela-banners.md`, Dδ).

- **A ordem de hoje:** o serviço grava a linha, troca o nome e grava de novo, e só então move o arquivo;
  se mover falha, `em->remove($banner)` apaga a linha
  (`module/ISConfiguracao/src/Service/AppBannerService.php:33-48`). Na edição, é a linha que já existia
  que some.
- **Ninguém lê o `error` do envio:** com um arquivo acima do `upload_max_filesize` e abaixo do
  `post_max_size`, o `tmp_name` chega vazio e a conferência passa. Em dev os dois limites são 64M; os de
  produção não estão no repositório.
- **A troca que dá certo deixa o arquivo antigo:** comparando `public/banners` com a coluna `arquivo`,
  há 4 arquivos sem linha, com 766.481 bytes.
- O cadastro passa por `arquivo='temp'`, já ativo, entre dois `flush`: por um instante o aplicativo
  receberia `.../banners/temp`.

**O conserto** (a fatia 1 dos Banners, sem decisão): "guarda o novo, grava a linha, só então apaga o
antigo", a ordem que o consolidado propõe para todo envio de arquivo (seção 4, item 1), com o `error` do
envio lido.

#### PARCIAL em 20/10/2026 -- a fatia 21 da fila das telas que faltam, BANNERS-SERVIDOR

**A ordem é a da seção 4.1 da fila**, nos dois caminhos (a tela antiga e a `/adm/api/banners`), num serviço só: guarda o
arquivo novo com o nome aleatório (`RegraDoBanner::nomeFisicoNovo`, `<hexadecimal>.jpeg`), grava a linha, e só então apaga o
antigo -- e só se ele tem a forma de um nome gerado pelo servidor. Guardar que falha não muda nada (antes, `em->remove` apagava
a linha que existia); gravar que falha apaga o arquivo novo e deixa o antigo. O cadastro guarda a imagem antes de a linha
nascer, e a linha nasce com o nome final num `flush` só: o `arquivo='temp'` ativo deixou de existir. O `error` do envio é lido
(`RegraDoBanner::medirImagem`): o código que não é 0 é recusa com frase, e o temporário vazio não passa mais.

**Provado:** a ordem anotada por um duble do armazenamento, com o banco olhado no instante em que o antigo sai (a linha já
tem o nome novo); a falha ao guardar, com a linha inteira como estava; a falha ao gravar, com o gerenciador fechado no meio
(o novo sai, o antigo fica, nenhuma linha nasce); e, pelo HTTP, o arquivo novo em `public/banners`, servido pelo nginx, e o
antigo fora do disco. `RegraDoBannerTest` (15 casos), `TelasDosBannersDoErpTest` (15), `BannersPelaApiTest` (18) e a linha dos banners do
`D199AtivarRemoverPorPostTest`, nos dois bancos, em série; e `scripts/ensaiar-banners-adm-api.php`, pelo HTTP, 26 de 26
nos dois bancos de contrato.

Mutantes, cada um vermelho: o cadastro gravando antes de guardar (com o `temp`); o antigo saindo antes do `flush`; o antigo
ficando no disco; a troca que falha apagando a linha; a gravação que falha deixando o novo; o cadastro que falha ao gravar
deixando a imagem; e o `error` do envio ignorado.

**Resta:** os 4 arquivos sem linha que já estão em `public/banners` (766.481 bytes na cópia de 02/10/2026). Apagá-los é
operação com arquivos na produção, e o desenho os pôs na lista do corte, para a autorização do dono lá, com os nomes conferidos
contra o banco de produção do dia (`tela-banners.md`, "O que decidi para seguir"). Nada aqui os apaga.

### D354 — A versão do aplicativo se publica como 0.0.0 com o campo vazio, e um byte inválido apaga o arquivo: nos dois casos a tela diz sucesso — REGISTRADO em 03/10/2026 — CORRIGIDO em 20/10/2026

Achado pelo desenho da Versão do aplicativo (`docs/f3-telas-restantes/tela-versao-app.md`, Dnovo-1 e
Dnovo-2 -- não confundir com os Dnovo dos Arquivos, que são outra numeração; o Dnovo-1 dos Arquivos é o
D355).

- **O vazio:** a versão são três números num arquivo de 77 bytes, `public/app.json`, que o aplicativo de
  cada cliente lê ao abrir. O controller grava o POST cru
  (`module/ISConfiguracao/src/Controller/AppConfiguracoesController.php:53`): o campo de texto não tem
  validador nem é obrigatório, apesar do asterisco. Medido com `php -r`: "" vira 0, "2.5" vira 2, "-1"
  fica -1. A máscara da tela barra letras e sinal, e deixa passar o vazio e o decimal: salvar em branco
  publica 0.0.0 para todo o parque, com "Editado com sucesso".
- **O byte inválido:** o gravador abre o arquivo com `w+` -- truncando -- antes de saber se o
  `json_encode` deu certo (`module/ISConfiguracao/src/Service/AppConfiguracoesService.php:7-18`; o do
  Push, logo abaixo, é igual). Um byte inválido faz o `json_encode` devolver falso (medido: "Malformed
  UTF-8 characters"): o arquivo fica vazio, e o serviço devolve `true`. Com o arquivo vazio, a rota do
  aplicativo responde 0.0.0 e um Warning (lido).
- **Quem alcança:** os 10 administradores. O que o aplicativo faz com o número é a pergunta 1 do
  desenho, que só quem mantém o aplicativo responde.

**O conserto** (a fatia 1 da Versão, sem decisão): uma regra pura nas duas portas; `json_encode` com
`JSON_THROW_ON_ERROR` antes de tocar o arquivo; e um gravador só, para a versão e o Push, que trava,
relê dentro da trava e grava num temporário com `rename` (o Dnovo-3 do desenho).

#### CORRIGIDO em 20/10/2026 -- a fatia 23 da fila das telas que faltam, VERSAO-DO-APLICATIVO

**O conserto:** a `RegraDaVersao` (`module/ISConfiguracao/src/Regra/RegraDaVersao.php`) confere os três números nas duas portas --
só algarismos, de 0 a 99999 (o `maxLength` 5 de hoje), gravados como texto sem zero à esquerda, como o arquivo de hoje --: o vazio, o
decimal, o negativo, o "1e3", a lista e o espaço voltam ao campo com a frase na tela antiga, e são 422 na nova. E um gravador só,
`ISConfiguracao\Service\RegistroDoAplicativo`, para a versão e o Push: trava (`TravaDeExecucao`, esperando até 3 s), relê o arquivo
dentro da trava, troca só o próprio campo, codifica com `JSON_THROW_ON_ERROR` antes de tocar o disco, abre o histórico, grava num
temporário do mesmo diretório e faz `rename`. A falha é `RegistroDoAplicativoFalhou`: "Erro ao editar." nas telas antigas, 500 com "A
versão não pôde ser gravada; nada foi alterado." na nova -- e o arquivo fica como estava. O Push salvo durante uma mudança de versão
não a desfaz mais (o Dnovo-3). O status do Push continua sem lista fechada (o D377) e o liga-desliga, como está (a resposta 76 (c)).

**O que o aplicativo lê não mudou:** os bytes são os de hoje (`[{"push":"inativo","versao_major":"2",...}]`), e o
`/api/versao/app` não foi tocado; o arquivo vazio, que nenhuma tela produz mais, continua dando 0.0.0 lá, como o desenho decidiu.

**Provado** nos dois bancos: `RegraDaVersaoTest` (os valores medidos pelo desenho, e os casos de
`frontend/src/telas/formularioDaVersao.casos.json`, rodados também pela tela), `RegistroDoAplicativoTest` (os bytes, o push
preservado, o byte inválido com o sha1 intacto, o arquivo ausente e o malformado, a trava ocupada, a releitura dentro da trava com
um SEGUNDO PROCESSO segurando a trava e gravando no meio, o `rename` com o modo do arquivo), `AppConfiguracoesPelosControllersTest`
(as duas portas antigas em processo) e `VersaoDoAplicativoPelaApiTest` (a porta nova, e o leitor do `/api/versao/app` despachado
sobre o que ela publica). Nenhum caso toca o `public/app.json` do dev: todos gravam num diretório temporário, e os `tearDown`
conferem o sha1 dele. Mutantes, cada um sozinho, vermelhos: o byte inválido sem o `JSON_THROW_ON_ERROR` nas duas codificações (a do arquivo e a da linha do histórico; sem só a do arquivo, o mutante sobrevive e é equivalente: a linha do histórico codifica o mesmo valor novo, e lança antes de o arquivo mudar), a escrita no lugar em vez do `rename`, a leitura fora da trava, a gravação sem a trava, a versão regravada sem o `push`, a gravação quando nada muda, o Push sem arquivo criando um, o vazio aceito, o decimal aceito (inteiro e texto), a rebaixa contada como subida, a regra fora da porta antiga, o Push sem o `catch` e a versão sem arquivo deixando de ser 404. Sobreviveu também, equivalente, o teto de 99999 sem a comparação do valor: o tamanho de cinco algarismos já o impõe.

### D355 — O nginx entrega os 109 arquivos da biblioteca sem sessão, pelo nome, e desativar um deles não o tira do ar — REGISTRADO em 03/10/2026 — PREPARADO em 22/10/2026: fechado aqui e no CI, sem caixa; a produção no passo 8 da etapa 1, e o BVIP na próxima entrega, pelo default.conf da pilha

Achado pelo desenho de Arquivos (`docs/f3-telas-restantes/tela-arquivos.md`, Dnovo-1 -- o dos Arquivos,
e não o da Versão, que está no D354). O D296 e o D326 já o citam de passagem; não tinha número.

- O `location /` do nginx serve o arquivo existente antes do PHP (`docker/nginx/default.conf:165`), e a
  regra dos diretórios de upload só recusa `.php` ali (`:97`). **Medido** no nginx local (consulta C10):
  `curl` sem cookie em `/arquivos/<nome>` dá 200 `application/pdf`.
- Desativar só muda a coluna, e trocar o arquivo por link deixa o antigo no disco. A tela nunca mostrou o
  nome físico; o risco é de quem adivinha -- os 8 Manuais têm nome com 5 caracteres aleatórios (C13) --
  ou tem o nome por outro caminho: backup, cópia do banco, log.
- O nginx de produção não está no repositório (o D12): lá não foi medido.

**O conserto** (a F0 de Arquivos, sem decisão): um `location ^~ /arquivos/` que recusa -- só a aplicação
lê ali, pelo `Armazenamento`; a troca para link apaga o antigo; o
`scripts/conferir-protecao-do-webroot.php` ganha a pergunta "diretório que só a aplicação lê está
fechado inteiro?"; e um passo no `docs/procedimento-de-corte.md`. O que "desativar" quer dizer (guardar
ou apagar) é a pergunta 2 do desenho.


#### PREPARADO em 22/10/2026 -- a fatia 27 da fila das telas que faltam, ARQUIVOS-SERVIDOR (a F0 de `tela-arquivos.md`)

**Aqui, fechado.** O `docker/nginx/default.conf` ganhou o "Bloco de segurança 0": `location ^~ /arquivos/ { return
404; }`, antes das `location ~` (o `^~` vence as expressões regulares, e o `.php` dali também cai no 404). O servidor de
desenvolvimento do PHP (`php -S`, o do CI) recusa o mesmo prefixo em `public/index.php` (`$fechadosInteiros`). O link que
substitui um arquivo tira o arquivo do disco, depois do `flush` (`ArquivoService::salvar`). E o
`scripts/conferir-protecao-do-webroot.php` ganhou a pergunta 4 -- "o diretório que só a aplicação lê está fechado
inteiro?": o bloco com o `return 404;` no `default.conf`, a mesma lista no `public/index.php`, e nenhum
`Armazenamento::padrao()->url('arquivos/...')` no código (com o diretório fechado, essa URL seria um link morto).

Quem lê o arquivo agora é só a aplicação, com sessão e com `configuracoes/arquivosExibir/ler`: a
`GET /adm/api/arquivos/:id/conteudo` e o "abrir"/"baixar" do ERP, pela mesma entrega (`Service\EntregaDoArquivo`, a regra
10 de `docs/convencao-api.md`). O inativo e o de categoria inativa são 404 para quem só lê -- desativar tira do ar.

**Medido** no nginx local: antes do `nginx -s reload`, `/arquivos/<um nome de verdade>` sem cookie dava 200
`application/pdf`; depois, 404 `text/html`; `/banners/` continua 403 e o login 200. **Provado** pelo
`scripts/ensaiar-arquivos-adm-api.php` (no CI, pelo `php -S`; aqui, pelo nginx): três arquivos com os nomes das fixtures
postos no disco, `/arquivos/<nome>` e `/arquivos/` dão 404 sem sessão e o conteúdo não sai; com sessão e o privilégio, a
entrega dá os bytes do disco, pela API e pelo ERP; os três apagados no fim, com a contagem do diretório conferida (110
antes e depois). Os mutantes do portão, cada um sozinho: tirar o bloco do `default.conf`, tirar o prefixo do
`public/index.php` e pedir `Armazenamento::padrao()->url('arquivos/...')` no código -- os três saem 1. O do nginx em si
(tirar o bloco e recarregar) não foi rodado: a medida de antes do `reload` é o vermelho dele.

**Resta a produção e o BVIP**, que não estão no repositório (o D12): o mesmo bloco no vhost novo do passo 8 da etapa 1
(`docs/procedimento-de-corte-etapa-1.md`, e o item 5 de "pronto" para a janela), conferido lendo o `nginx -T`, e
conferido de novo na etapa 2 (`docs/procedimento-de-corte.md`, o item 5 de "Preparação"); o do BVIP, na próxima entrega
de lá, pelo dono. O que "desativar" quer dizer (guardar ou apagar) e os órfãos continuam a pergunta 2 do desenho.

#### A verificação da fatia, 22/10/2026 -- o bloco sem caixa, o `php -S` pelo caminho de verdade, e o BVIP

**O `^~ /arquivos/` comparava a caixa.** `/ARQUIVOS/<nome>`, `/Arquivos/<nome>`, `/%41RQUIVOS/<nome>` e
`//ARQUIVOS/<nome>` não casavam com ele e caíam no `location /`; o disco montado do Windows, que não diferencia a caixa,
achava o PDF, e o nginx o servia com 200 `application/pdf` sem sessão (medido com `curl -I` sem cookie, antes do
reload). O bloco virou `location ~* ^/arquivos/ { return 404; }`, a PRIMEIRA regex do `server {}` (as regex valem na
ordem do arquivo; o `.php` dali também cai no 404). Depois do `nginx -t` e do `nginx -s reload`, as nove variantes
medidas dão 404 (minúsculas, maiúsculas, mistas, `%41`, barra dobrada, `/x/../`, o diretório, `.php`) e o login
continua 200. Na produção, com disco Linux, o `^~` provavelmente bastaria; o bloco sem caixa não depende disso.

**O `php -S` do CI deixava passar `/ARQUIVOS/` e `/./arquivos/`:** o `str_starts_with($caminho, '/arquivos/')` do
`public/index.php` só via o prefixo escrito, e o `is_file` logo abaixo achava o arquivo (medido num `php -S` dentro do
contêiner: 200 `application/pdf` nos dois). Agora a recusa olha o `realpath` do que seria servido, sem caixa, contra o
`realpath` do diretório fechado (a mesma lista `$fechadosInteiros`): as quatro variantes dão 404, e os estáticos de
`/layout/` continuam 200.

**O portão e o ensaio** passaram a pegar isso. O `conferir-protecao-do-webroot.php` lê o `default.conf` sem os
comentários, exige o `~*` como a primeira regex e recusa o `^~ /arquivos/` (os mutantes: voltar ao `^~`, pôr o bloco
depois do bloco 1, e deixar o bloco só num comentário -- os três saem 1). O `ensaiar-arquivos-adm-api.php` pede, sem
sessão, `/ARQUIVOS/`, `/Arquivos/`, `//arquivos/`, `/./arquivos/`, `/%61rquivos/` e `/x/../ARQUIVOS/` com o nome de um
arquivo posto no disco, e espera 404 sem o conteúdo (pelo `CURLOPT_PATH_AS_IS`, o caminho vai como escrito).

**O BVIP fecha na próxima entrega, sem tocar o molde do vhost dele.** O título de antes ("a produção e o BVIP no passo
8 da etapa 1") estava errado: o passo 8 é o vhost novo da produção. No BVIP, o vhost do host
(`docker/nginx/teste-host.conf.modelo`, gerado pelo `scripts/deploy-teste.sh`) não serve arquivo nenhum do código: o
`location /` dele é um `proxy_pass` para a porta da pilha, e quem serve `/arquivos/` lá é o nginx da pilha, que monta
o `docker/nginx/default.conf` do repositório (`docker-compose.yml`, o volume do serviço `nginx`; o `deploy-teste.sh`
o religa com o `up` no fim e diz que ele "monta o default.conf de novo"). O `default.conf` está nas `CAMADAS` do
deploy. Então o bloco chega ao BVIP com o código desta fatia, na próxima entrega, pelo dono; o molde do vhost do host
não precisa dele, e a carga do `deploy-teste.sh` não muda. **O passo** está no `docs/procedimento-de-corte-etapa-1.md`
(o passo 8, "O BVIP"): depois da próxima entrega, a conferência é ler a configuração do nginx da pilha, e não um `curl`
com um nome de verdade.

**O link que substitui um arquivo só o tira do disco depois de gravar:** a ordem não era provada; agora
`ArquivosTravessiaTest::testOLinkSoTiraOArquivoDepoisDeGravar` lê a linha na hora da remoção (já é o link), e o mutante
que remove antes do `flush` fica vermelho.

**Resta** a produção (o passo 8 da etapa 1, conferido de novo no item 5 de "Preparação" do `procedimento-de-corte.md`)
e o BVIP na próxima entrega. Os órfãos e o que "desativar" quer dizer continuam a pergunta 2 do desenho.

### D356 — O mural de avisos grava o POST cru e o imprime sem escape no topo da agenda de cerca de 364 pessoas — CORRIGIDO em 04/10/2026

Achado pelo desenho de Mensagens (`docs/f3-telas-restantes/tela-mensagens.md`, M1 e M2); o consolidado
o põe entre os casos mais graves de escape (seção 4, item 7).

- O cadastro e a edição passam `getPost()->toArray()` ao serviço
  (`module/ISConfiguracao/src/Controller/MensagensController.php:88` e `:120`), e não o `getData()`: o
  `StripTags` do filtro não vale no que se grava. O título e o texto saem crus no topo da agenda antiga
  (`module/ISCadastro/view/is-cadastro/agendas/index.phtml:54-55`) e na lista dos avisos.
- **A cor** é lista fechada só na tela (`disable_inarray_validator`) e vai crua para dentro do atributo
  `class` (`agendas/index.phtml:53`): o levantamento mediu `x" onclick="y()` aceito pelo formulário.
  Acima de 20 caracteres, 500 (a coluna tem 20; a entidade diz 100).
- **Alcance** ("Os números" do desenho): só os 10 administradores escrevem, e cerca de 364 pessoas leem o
  mural -- é a tela mais aberta do ERP. Hoje 0 avisos têm `<`. O texto dos avisos não é anonimizado na
  cópia.

**O conserto** (a F0 de Mensagens, sem decisão): o `getData()` campo a campo pela regra; `escapeHtml` nas
quatro saídas (o texto como `nl2br` do escapado); a cor pela lista fechada; o mural num partial próprio,
para o teste o renderizar sozinho.


#### CORRIGIDO em 04/10/2026 -- o item MARKETING-CONSERTOS

**O conserto:** o cadastro e a edição passam ao serviço o `getData()` do formulário, campo a campo, pela
`RegraDaMensagem::doFormularioDoErp` (`module/ISConfiguracao/src/Regra/RegraDaMensagem.php`): o
`id`, o `status`, o token e qualquer chave inventada não chegam aos setters. A cor é uma das três no filtro (`InArray` estrito; o `disable_inarray_validator` saiu). O mural
saiu para um partial próprio (`module/ISCadastro/view/is-cadastro/agendas/mural-de-avisos.phtml`), com o
título e o texto escapados e a cor pela lista fechada (`RegraDaMensagem::corDoMural`): a linha antiga
gravada crua sai como a neutra, e nunca chega ao atributo. O título e o texto já eram escapados desde o
D203, na agenda e na lista.

**Provado** nos dois bancos: `TelasDasMensagensDoErpTest` (o partial renderizado com o título, o texto e a
cor hostis; e a agenda do dia despachada em processo, com um aviso de hoje gravado com `x" onclick=...`
na cor), `MensagensPeloControllerTest` (o cadastro e a edição gravam o aparado; a cor de fora recusada no
campo) e `RegraDaMensagemTest`. Mutantes: cada saída do partial crua, a agenda de volta
ao laço próprio, a cor do mural sem a lista, e o POST cru no cadastro e na edição -- vermelhos. A cor
fora da lista tem DUAS guardas no formulário -- o `InArray` estrito do filtro e o do próprio `<select>`,
que voltou a valer sem o `disable_inarray_validator` --: arrancar uma só deixa a suíte verde (os dois
mutantes sobreviveram), e arrancar as duas fica vermelho.

**A revisão do mesmo dia** achou duas coisas. O `StripTags` do filtro, que passou a valer com o
`getData()`, cortava calado o aviso que tem `<` -- "Estoque < 5 unidades, pedir reposição" virava
"Estoque", com "Inserida com sucesso." --, e a decisão 7 do desenho é "sem proibir `<` e `>`: escapa-se na
saída": o `StripTags` saiu do título e do texto, e o aviso grava inteiro (as saídas já escapam). E o título
nas duas ramas da lista (`mensagens/index.phtml`, o link de quem escreve e o texto de quem só lê) não
tinha caso hostil: trocar um dos dois escapes por `echo` cru deixava a suíte verde. Provado em
`MensagensPeloControllerTest` ("Estoque < 5" e "Meta: 3<5 e 7>2 hoje" gravados inteiros) e em
`TelasDasMensagensDoErpTest` (a lista renderizada para quem escreve e para quem só lê, com o título
hostil); mutantes: o `StripTags` de volta no título e no texto, e cada um dos dois escapes da lista
arrancado -- vermelhos. O POST cru no lugar da regra, no cadastro e na edição, continua vermelho; já a
regra recebendo o POST cru no lugar do `getData()` agora sobrevive, e é equivalente: sem o `StripTags`, o
filtro só apara, a regra apara e lê só as chaves que conhece, e a validação do formulário continua
valendo antes dela.

### D357 — O filtro "abertas" da lista de despesas devolve também as quitadas, na API e no ERP — CORRIGIDO em 04/10/2026

Achado pelo desenho de Contas a Pagar (`docs/f3-telas-restantes/tela-saidas.md`, débito 1).

- A API passa `false` para "abertas" (`module/ISFinanceiro/src/Controller/Adm/SaidasController.php:107`),
  e a consulta só filtra com `!empty($data['situacao'])`
  (`module/ISFinanceiro/src/Entity/SaidaRepository.php:27`): `empty(false)` é verdadeiro, e o filtro cai.
  O `0` do ERP também, e o link da página o descarta. Quem pede abertas recebe as quitadas junto.
- A documentação (`docs/f2-fatia-saidas.md:170-178`) e o comentário da ação dizem o contrário, e nenhum
  teste cobre.
- **Medido** (P1 do desenho, `php -r` no contêiner): `empty(false)` e `empty('0')` são verdadeiros.

**O conserto** (a S1 de Contas a Pagar, sem decisão): a consulta filtra quando a situação é booleano, ou
`0` e `1` do ERP, e liga booleano de verdade -- o PostgreSQL recusa inteiro em coluna booleana.

#### CORRIGIDO em 04/10/2026 -- o item FINANCEIRO-S1

**O conserto:** `SaidaRepository::situacao()` traduz o que chega -- o `true`/`false` da API e o
`'1'`/`'0'` do `<select>` do ERP -- e o resto é "sem filtro", como o vazio sempre foi; a consulta liga
o booleano de verdade. O link da paginação do ERP passou a sair dos filtros já conferidos, por
`http_build_query`, e leva o `situacao=0`. O comentário da ação da API, que dizia que `abertas`
filtrava, e o `docs/f2-fatia-saidas.md` ganharam a correção.

**Provado:** `SaidaApiEscritasTest` (a API despachada em processo: "abertas" devolve só a 90001001,
"quitadas" só a 90001000, sem situação as duas) e `SaidasDoErpTest` (o ERP: `0` filtra e vai no link,
`1` filtra, "Selecione" não), nos dois bancos. Mutantes: a consulta de volta ao `!empty` -- vermelhos
o caso da API e o do ERP; o link de volta ao `!empty` -- vermelho o do link.

### D358 — A API de Contas a Pagar diz que falta dinheiro na parcela quitada, esconde os 126 estornos e aceita pagar de novo — CORRIGIDO em 04/10/2026

Achado pelo desenho de Contas a Pagar (`docs/f3-telas-restantes/tela-saidas.md`, débitos 2, 3 e 6). A API
existe (`docs/f2-fatia-saidas.md`), nenhuma tela a usa ainda, e nenhum caso despacha uma escrita dela.

- **A falta na quitada:** `getValorFaltante` não olha a quitação
  (`module/ISFinanceiro/src/Entity/SaidaParcela.php:121-129`), nem o total de falta da resposta
  (`module/ISFinanceiro/src/Api/SaidaJson.php:116` e `:164`). **Medido** (M5): 674 parcelas quitadas, de
  423 despesas e 6 unidades, somam R$ 160.306,47 "faltando" -- 335 pagas a menor com a caixa "quitar" e
  339 quitadas sem pagamento.
- **Os estornos somem:** a resposta percorre `getPagamentos()` (`SaidaJson.php:140`), que só devolve os
  ativos (`SaidaParcela.php:173-179`); `estornado` nunca é verdadeiro, ao contrário do que os
  comentários prometem, e o único teste afirma o falso. Os 126 estornos (M1) não aparecem em lugar
  nenhum.
- **Paga parcela quitada e altera parcela com pagamento,** o que a tela antiga esconde. As 470 parcelas
  pagas acima do previsto (M5) são de 2 unidades e de antes de 10/2024; nada impede a próxima.

**O conserto** (a S1, sem decisão): falta zero na quitada, e um campo com o que foi dispensado (o
previsto menos o pago); o extrato percorre a coleção inteira, com `estornado`; 409 no serviço para pagar
parcela quitada e alterar parcela com pagamento.

#### CORRIGIDO em 04/10/2026 -- o item FINANCEIRO-S1

**O conserto, nas três partes:**

- **O extrato:** `SaidaParcela::getExtrato()` devolve a coleção inteira, em ordem de data e de id, e
  `SaidaJson::parcela()` a percorre: o estornado sai com `estornado: true`. Os totais não mudam -- o
  pago vem da parcela. O extrato da tela antiga continua sem os estornados (a decisão 10 do desenho).
- **A falta:** `getValorFaltante()` é zero na parcela quitada, e `getValorDispensado()` é o previsto
  menos o pago nela -- o campo `dispensado` do recurso, que a tela nova mostra como "R$ X a menos".
  `totais.faltante` soma a falta das parcelas listadas, e não mais `previsto - pago` da despesa
  inteira: a quitada a menor deixou de contar como dívida, e o pago a maior de uma parcela deixou de
  abater a falta de outra. A tela antiga só lê a falta da parcela aberta, e não muda.
- **O 409 no serviço:** `parcelaQuitar()` recusa a parcela quitada e `editarParcela()` recusa a
  parcela com pagamento (`temPagamento()`, as três perguntas do D135), por onde as duas portas
  passam -- a decisão 2 do desenho; desfazer é tirar as três linhas de cada um. A API diz qual das
  recusas foi ("já está quitada", "tem pagamento registrado", "foi excluída"), lendo o estado que o
  serviço releu; o ERP acrescentou o caso à frase dele. A tela antiga já escondia os dois botões.

**Provado:** `SaidaApiTest` (o estorno no extrato, marcado e em ordem, com o pago da parcela; a quitada
a menor com falta zero e R$ 500,00 dispensados, e `totais.faltante` com só a aberta),
`SaidaFinanceiroTest` (pagar a quitada e editar a quitada e a de pagamento parcial, recusados; o
controle, a aberta editável) e `SaidaApiEscritasTest` (as duas 409 pela API, e o 201 da aberta), nos
dois bancos. Mutantes, cada um vermelho pelo seu caso: o JSON de volta a `getPagamentos()`; a falta
sem olhar a quitação; o `dispensado` zerado; `totais.faltante` de volta à conta da despesa inteira; e
cada uma das duas recusas do serviço arrancada.

**A revisão do item (04/10/2026):** a 409 de pagar a quitada dizia *"Para pagar de novo, estorne
antes."* também na quitada **sem pagamento** -- as 339 do "Quitar" sem dinheiro --, que não tem o que
estornar: o estorno é o único caminho que reabre uma parcela, e exige um pagamento ativo. A frase agora
só diz o caminho quando há pagamento ativo; sem ele, só *"A parcela já está quitada, e não recebe outro
pagamento."*. O comentário do serviço dizia o mesmo, e foi corrigido. Reabrir a quitada sem pagamento é
a P1 do desenho, e não mudou. `SaidaApiEscritasTest`, os dois casos, com um mutante para cada lado.

### D359 — O aluno da VIP Class abre, conclui e baixa o certificado de qualquer curso pelo id, sem pré-requisito, grupo nem situação — REGISTRADO em 03/10/2026 — PARCIAL em 05/11/2026: a tela antiga do aluno; resta a API (a fatia 80) — CORRIGIDO em 05/11/2026: a API do aluno, pela mesma regra

Achado pelo desenho da VIP Class (`docs/f3-telas-restantes/tela-vipclass.md`, DV-1).

- As aulas, o checkpoint, o finalizar e o certificado acham o curso por `find` do id
  (`module/ISCadastro/src/Controller/VipClassEduController.php:45`, `:68`, `:94` e `:126`), e nenhum
  confere a publicação, a situação, o grupo nem o pré-requisito -- o pré-requisito só existe como cadeado
  no cartão. O hash conferido no finalizar vem impresso na própria página.
- **Medido** (M9): 3 conclusões de 2 pessoas sem o pré-requisito, de 22/10 a 05/11/2024, e 4 de 2
  pessoas fora do grupo atual.
- **E o curso 17 é a chave do visagismo** (M12): o visagismo libera quem tem uma conclusão dele, sem olhar
  como ela veio. Pela URL, a cadeia de sete cursos (1, 2, 3, 4, 5, 6 e 17) não segura ninguém (lido, não
  exercitado).

**O conserto** (a F0 da VIP Class, sem decisão): a regra da lista nas quatro ações e na API nova; o
certificado de quem já concluiu continua saindo.

#### PARCIAL em 05/11/2026 -- a tela antiga do aluno (a fatia 79, VIPCLASS-ALUNO-SERVIDOR; a F0 do desenho)

As aulas, o checkpoint e o envio dele (`VipClassEduController`) passam por `VipClassRepository::cursoDoAluno`, que
pede a regra da lista dos cartoes, escrita uma vez em `RegraDoCursoVipClass` (`publicadoPara`, `ativo`,
`paraOAluno`): publicado para a unidade e o grupo DA SESSAO, com o vinculo vazio valendo para todos; ativo
(`status` 1, a coluna inteira); e o requisito concluido. Fora da publicacao ou inativo, a pagina responde 404 e o
envio o "ID Invalido" do id que nao existe; com o requisito pendente, a pagina 404 e o envio a frase do modal, com
o nome escapado, e nada e gravado. O certificado NAO passa pela regra, de proposito (a decisao 2 do desenho: e de
quem concluiu). Provado no `VipClassAlunoPeloErpTest` e no `RegraDoCursoVipClassTest`, nos dois bancos, com a
fixture nova (os cursos 90079001 a 90079003); os mutantes de cada condicao, vermelhos. **Resta** a API do aluno
(`GET /adm/api/meus-cursos...`, a F1 do desenho, a fatia 80), que chama o mesmo `cursoDoAluno`.

**Na verificacao (05/11/2026):** a lista dos cartoes (`listagemIndexEdu`) ainda tinha o proprio filtro em DQL e o
proprio cadeado -- a regra escrita duas vezes, iguais so pelo teste. Agora a lista e o curso pelo id passam pelo
mesmo `VipClassRepository::situacaoParaOAluno` (a decisao tecnica 1 do desenho: as telas antigas e a API chamam a
mesma); o mutante que deixa o `FORA` na lista fica vermelho no `testAListaDosCartoesEOIdDizemOMesmo`. Sem grupo na
sessao, o curso com grupo agora fica fora tambem do cartao (a lista antiga o mostrava): na copia, 0 ativos sem grupo.

#### CORRIGIDO em 05/11/2026 -- a API do aluno (a fatia 80, VIPCLASS-ALUNO-TELA; a F1 do desenho)

A `GET /adm/api/meus-cursos` e os filhos (`Adm\MeusCursosController`) chamam a mesma regra da tela antiga, e nao uma
copia: os cartoes sao os do `VipClassRepository::listagemIndexEdu` e o curso pelo id e o do `cursoDoAluno`, com a
unidade e o grupo DA SESSAO. O curso fora da publicacao, inativo ou que nao existe e 404 (como o que nao existe); o
requisito pendente e 409 `requisito_pendente`, com o id e o nome dele; o checkpoint, a conclusao e o material passam
pela mesma guarda (`cursoDaRota`). O certificado continua fora da regra (a decisao 2: e de quem concluiu). Provado no
`MeusCursosApiTest` (31 casos) e no `scripts/ensaiar-vipclass-adm-api.php` (28 casos, nos dois bancos de contrato,
conferidos contra um SELECT); os mutantes da regra trocada pelo `find`, do cadeado ignorado, do grupo nulo, da unidade
zerada e do certificado pela regra, vermelhos nos dois bancos; as tres entradas novas no
`conferir-recorte-por-unidade.php`. Com a tela antiga (a 79) e a API (a 80), as quatro portas e as cinco da API passam
pela regra. (O hash do formulario antigo continua impresso e conferido, e nao protege nada: sai com a tela antiga.)

### D360 — Nada impede o ambiente local de chamar o provedor de WhatsApp, e duas unidades de fixture têm o canal ligado com chave — REGISTRADO em 03/10/2026 — PARCIAL em 05/10/2026: o loopback feito; falta a fixture sem chave — PARCIAL em 06/10/2026: o corte do BVIP vale no próximo up; falta a fixture sem chave — PARCIAL em 12/10/2026: a fixture sem chave entrou; resta o próximo up do BVIP

Achado pelo desenho do WhatsApp (`docs/f3-telas-restantes/tela-whatsapp.md`, W10); o consolidado o chama
de armadilha (seção 4, item 14).

- O deploy de teste resolve os endereços dos provedores para o loopback (`docker-compose.teste.yml:30-48`,
  com os dois do WhatsApp); o `docker-compose.yml` local não tem `extra_hosts` nenhum.
- As fixtures 90001150 e 90001200 têm o `whatsapp_ativo` ligado e uma chave de mentira
  (`tests/Caracterizacao/fixtures.sql`, a partir de `:1228`), e no banco de contrato há 2 unidades assim
  e 1 linha da fila com chave (Q2b). Abrir a tela antiga do WhatsApp numa sessão delas -- o GET chama o
  provedor, o D305 --, ou rodar o `marketingPush` no contrato, sai da máquina para o provedor de verdade.
- **Até o conserto:** nenhum ensaio nem conferência no navegador nessas duas unidades.

**O conserto** (sem decisão; só com a suíte parada, porque recria o contêiner e a fixture é de todos): os
endereços do provedor no loopback do `docker-compose.yml` local, como no de teste; e uma fixture com o
WhatsApp ligado e sem chave.

#### PARCIAL em 05/10/2026 -- os provedores no loopback do desenvolvimento (o item WHATSAPP-W10)

**Onde, e por que não no `docker-compose.yml`.** Ele é a base dos três ambientes: a homologação do BVIP o
lê com o `docker-compose.homologacao.yml` (o `COMPOSE_FILE` do `.env.homologacao.example`, e o `-f` da
`docs/ambiente-homologacao.md`), e o teste com esses dois e o `docker-compose.teste.yml`. Um `extra_hosts`
nele cortaria os provedores da homologação no próximo deploy -- isso é do dono, e a pergunta foi ao lote --
e mudaria uma das `CAMADAS` do `deploy-teste.sh`. Nem o `docker-compose.override.yml`: o compose o lê
sozinho sempre que não há `COMPOSE_FILE` nem `-f`, e um `.env` de servidor sem a linha o levaria para lá.
O corte mora num arquivo novo, `docker-compose.desenvolvimento.yml`, que só entra pelo `COMPOSE_FILE` do
`.env.example` (e do `.env` desta máquina, que não é versionado), com `COMPOSE_PATH_SEPARATOR=:` -- o
compose do Windows espera `;` e recusou o caminho com `:` (medido). Pelo `docker compose config`: o
desenvolvimento ganha os 18 `extra_hosts`; a homologação (pelo `COMPOSE_FILE` do modelo e pelo `-f` da
documentação) e o laboratório (`-p franquiabv-lab -f docker-compose.yml`) continuam sem nenhum; o teste,
com os seus. O `setup-dev.sh` avisa quando o `.env` existente não carrega o arquivo, e não o acrescenta.

**A lista** é a de `HOSTS_MORTOS` (nove nomes, cada um no IPv4 e no IPv6), e o ViaCEP fica alcançável. O
grep pelo código não achou provedor que ela não tenha: o código escreve treze hosts em URL -- sete da lista,
o ViaCEP, e cinco que o PHP não chama (o link do WhatsApp que o navegador abre, o script do Zapier, o autor
dos PDFs e um exemplo da documentação) --, e o SMTP do `global.php` é o `mail.barbearia.vip`, da lista. Ela
está escrita no arquivo, porque o compose não lê o roteiro; quem a segura é o portão novo,
`scripts/conferir-provedores-no-loopback.php`, duas vezes no `conferir-tudo.sh`: nos arquivos (as duas
listas iguais a `HOSTS_MORTOS`, os dois endereços e nada mais, o ViaCEP fora, todo host de URL do código
classificado, o `docker-compose.yml` sem `extra_hosts`, só o `.env.example` e o `.env` daqui carregando o
arquivo) e, com `--conteiner`, de dentro do app (as quatro perguntas do getent do `deploy-teste.sh
conferir`, por nome).

**Provado.** Antes de recriar, o `--conteiner` ficou vermelho nos nove nomes (o `api.asaas.com` em quatro
endereços reais, o `onesignal.com` também pelo IPv6). Recriado o app com a suíte parada (o `local.php`
seguiu em `franquiabv_dev`, o primeiro pedido respondeu 200), o `getent hosts` devolve `::1` e o
`ahostsv4`, `127.0.0.1`, para os nove; o ViaCEP resolve para fora. O `curl` do PHP no contêiner dá "Could
not connect to server" para Evolution, Asaas e OneSignal, e o ViaCEP responde; o `instanciaStatus()` do
`EvolutionApi` e do `WahaApi`, com as chaves de mentira das duas fixtures, volta "Erro de conexão" -- medido
na classe que a tela usa, e não na tela aberta no navegador. Os vizinhos que tocam nos
provedores por dublê (66 testes, `EstadoPorPostNoMarketingTest`, `EnderecoPorCepTest`,
`SegredosForaDoCodigoTest`, `UnidadeApiTest`, `SessaoApiTest` e `CampanhaCanceladaNaRodadaTest`), verdes
nos dois bancos. Doze mutantes no portão, um de cada vez e restaurados por conteúdo, todos vermelhos: tirar
o `::1` de um nome, comentar a linha do IPv4, trocar o `::1` por um endereço real, um nome a mais só no
desenvolvimento, o ViaCEP no loopback, `extra_hosts` no `docker-compose.yml`, o `.env.example` sem o
`COMPOSE_FILE` ou sem o separador, a homologação carregando o arquivo, o `.env` daqui sem a linha, o teste
sem o `::1` de um nome, e um provedor novo num literal de `module/`; o mesmo provedor só num comentário
segue verde.

**Resta** a segunda parte do conserto: a fixture com o WhatsApp ligado e sem chave. Ficou de fora porque
hoje nenhum caminho a distingue -- sem chave, a tela antiga chama o provedor do mesmo jeito
(`WhatsAppController::criarClienteWhatsAppUnidade` passa `''` ao cliente), e o "sem credencial" que recusa
antes é a F0 do WhatsApp. O primeiro a usá-la é o ensaio dela (o controle `sem_credencial`), que também diz
qual usuário entra. Entra com a F0: o `fixtures.sql` é de todos, e linha sem leitor não se prova.

**O corte depende de onde se roda o compose** (achado da revisão, medido com `docker compose config`). Da
raiz, o app tem os 18 `extra_hosts`. De `frontend/`, de `scripts/` ou de um worktree (os três de
`.claude/worktrees/` não têm `.env`), o compose sobe pela árvore, acha o `docker-compose.yml` e dá o mesmo
projeto, `franquiabv`, mas não lê o `.env` da raiz, e o app sai com zero: um `docker compose up` dali
recria o `franquiabv-app-1` sem o corte (deduzido do compose, não rodado). Nenhum roteiro do repositório
faz isso -- o `setup-dev.sh` vai à raiz antes do `up` --; o risco é o comando digitado. O modo de arquivos
do portão não vê; o `--conteiner` vê, e só quando roda. O conserto foi escrever "da raiz" nos três lugares
que ensinam o corte (o cabeçalho do `docker-compose.desenvolvimento.yml`, o `.env.example` e a
`docs/ambiente-desenvolvimento.md`) e amarrar a restrição abaixo ao `--conteiner`.

**A troca com o `docker-compose.override.yml`,** que o compose acha ao lado do `docker-compose.yml` mesmo de
subpasta, e que por isso cobriria esse caso aqui. Medido num rascunho (05/10/2026): sem `.env`, a raiz e a
subpasta leem o override; com `COMPOSE_FILE=docker-compose.yml` no `.env` da raiz, a raiz não o lê e a
subpasta lê. Num servidor, então, um `docker compose up` dado de uma subpasta, ou um `.env` sem
`COMPOSE_FILE`, poria o corte na homologação do BVIP -- que é a pergunta do lote. Fica o arquivo nomeado:
o risco dele cai na máquina de quem programa, onde o portão mede; o do override cairia no servidor, onde
nada mede.

**E a restrição de cima** cai nesta máquina só com o `--conteiner` verde, medido logo antes:
`docker compose exec -T app php scripts/conferir-provedores-no-loopback.php --conteiner`, saída 0, antes de
qualquer ensaio ou conferência no navegador nas unidades 90001150 e 90001200. Vermelho, ou sem medir, ela
vale como antes. Vale sempre onde o corte não está ou não foi medido -- a homologação e o laboratório do
BVIP (a homologação, pela documentação, carrega o `fixtures.sql`) até o `--conteiner` sair 0 lá: o corte
deles está escrito desde 06/10/2026 e vale no próximo up de cada um (abaixo) --, e o CI.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 4): a opção (b), "Cortar na
homologação e no laboratório" -- os mesmos nomes no `docker-compose.homologacao.yml`, e o comando do
laboratório (`docs/ambiente-homologacao.md`) ganha o arquivo do corte; não toca no arquivo que a
produção talvez use. A implementação está na seção de baixo (06/10/2026). O portão
`conferir-provedores-no-loopback.php` ganha a lista da homologação junto. Continua também a fixture sem
chave.

#### PARCIAL em 06/10/2026 -- o corte da homologação e do laboratório do BVIP escrito no repositório, para valer no próximo up de cada um (o item PROVEDORES-HOMOLOGACAO)

**O defeito, medido antes** com `docker compose config` (só leitura), com o `.env` de cada modelo: a
homologação saía com zero `extra_hosts` pelos dois caminhos (o `COMPOSE_FILE` do `.env.homologacao.example`
e o `-f` da `docs/ambiente-homologacao.md`), e o laboratório (`-p franquiabv-lab -f docker-compose.yml`)
também -- com `-f`, o compose não lê o `COMPOSE_FILE` do `.env`. O teste já saía com os 18.

**O conserto.**

- `docker-compose.homologacao.yml`: os 18 `extra_hosts` no `app` -- a lista de `HOSTS_MORTOS`, nome por
  nome, no IPv4 e no IPv6, sem o ViaCEP --, com o porquê e o que rodar para aplicá-la. O
  `docker-compose.yml` segue sem `extra_hosts`, como o dono pediu.
- O comando do laboratório na `docs/ambiente-homologacao.md` ganhou `-f docker-compose.desenvolvimento.yml`,
  e não o arquivo da homologação: este traria também os tetos e o `restart: always`, que mudariam o
  laboratório além do pedido. Como o arquivo do desenvolvimento passa a subir num servidor de IP público, o
  cabeçalho dele diz isso, e o portão reprova qualquer linha dele que não seja do `extra_hosts` do app (uma
  porta posta ali seria publicada no BVIP, e o Docker passa por cima do ufw).
- A `docs/ambiente-homologacao.md` ganhou "Os provedores de produção ficam fora de alcance": o que o dono
  roda no BVIP para aplicar (`docker compose up -d app` e `docker compose restart nginx`), como conferir lá
  dentro (`docker compose exec -T app php scripts/conferir-provedores-no-loopback.php --conteiner`) e o que
  cada saída quer dizer; e o mesmo para o laboratório, com o `-p` e os dois `-f`.
- O portão `conferir-provedores-no-loopback.php` compara também a lista da homologação com `HOSTS_MORTOS`;
  exige, linha por linha, que todo `docker compose -p franquiabv-lab` da documentação carregue `-f
  docker-compose.yml` e, depois, `-f docker-compose.desenvolvimento.yml` (o comentário do shell no fim da
  linha não conta; sem nenhum `up` do laboratório, sai 2); e que o arquivo do desenvolvimento só corte. O
  `.env.homologacao.example` e o `.env.teste.example` seguem sem o arquivo do desenvolvimento, e o portão
  continua exigindo isso.

**Provado.**

- `docker compose config`, depois: a homologação (pelos dois caminhos), o teste e o laboratório saem com 18
  entradas, os nove nomes com `127.0.0.1` e `::1`, nenhum nome repetido nem a mais, o ViaCEP fora, e o
  compose sai 0 -- a junção da lista da homologação com a do teste não repete entrada nem dá erro. Que os
  arquivos certos entraram, mede-se pelo que só eles trazem: o teto de 4 CPUs e o `restart: always` do app
  na homologação e no teste, o projeto `franquiabv-teste` no teste, e o perfil `fase1` no laboratório.
- A configuração inteira do teste é a mesma de antes (mesmo sha256, com o `docker-compose.homologacao.yml`
  do HEAD e com o de agora), e o hash de configuração do app também: a cópia do teste que está subindo no
  BVIP não é recriada por isto. Na homologação muda só o hash do app (o do nginx é o mesmo): o próximo `up`
  recria o app.
- O ambiente local não muda: a configuração com o `docker-compose.desenvolvimento.yml` do HEAD e com o de
  agora tem o mesmo sha256, e o hash do app que o compose calcula é o do rótulo do contêiner de pé, que não
  foi recriado (o `--conteiner` daqui sai 0; o `local.php` seguiu em `franquiabv_dev`).
- Num app descartável (`docker compose run --rm --no-deps`, projeto próprio, sem porta nem banco, a imagem
  do desenvolvimento), o `--conteiner` saiu 0 com a configuração da homologação e com a do laboratório; com
  só o `docker-compose.yml`, o controle, saiu 1 nos nove nomes, com os endereços reais (o `onesignal.com`
  também pelo IPv6). As redes e os volumes descartáveis foram apagados pelo nome.
- Dezenove mutantes, um de cada vez e restaurados por sha256, com a saída esperada em todos: na
  homologação, tirar o `::1` de um nome, comentar o IPv4 de outro, trocar um `::1` por endereço real,
  arrancar o bloco, um nome a mais e o ViaCEP no loopback (saída 1); no laboratório, o `up` de volta a só
  `-f docker-compose.yml`, o `-f` do corte antes do da base, o `exec` da conferência sem ele e o `-f` só num
  comentário da linha (1), e nenhum comando com `-p franquiabv-lab` (2); no desenvolvimento, uma porta, um
  serviço e um volume a mais (1), e o `ports:` só num comentário (segue 0); `extra_hosts` no
  `docker-compose.yml` (1). E no código do portão: sem a homologação na lista dele, sem a falha da
  checagem do laboratório ou sem a do arquivo do corte, o mutante correspondente passa verde -- cada uma é
  quem o pega.
- Os 66 vizinhos do D360 (`EstadoPorPostNoMarketingTest`, `EnderecoPorCepTest`, `SegredosForaDoCodigoTest`,
  `UnidadeApiTest`, `SessaoApiTest` e `CampanhaCanceladaNaRodadaTest`), verdes nos dois bancos -- o item não
  toca PHP que roda, e eles só mostram que nada mais mudou.

**O que não foi provado:** a homologação e o laboratório de pé no BVIP -- nada roda lá daqui. Até o
`--conteiner` sair 0 lá, a restrição de cima vale na homologação: nenhum ensaio nem conferência no navegador
nas unidades 90001150 e 90001200. E o `voltar` do `deploy-teste.sh` para um commit de antes desta data vai
pedir confirmação, porque o `docker-compose.homologacao.yml` está nas `CAMADAS`: é esperado, e o
`deploy-teste.sh` não mudou.

**A revisão (06/10/2026)** achou sete pontos, que se juntam em cinco, todos no portão ou em texto:

- **O portão lia o `extra_hosts` do arquivo inteiro**, sem olhar o serviço. O bloco movido do `app` para o
  `nginx` da homologação dava o mesmo mapa, e o portão saía 0 com o app sem corte -- num espelho do
  repositório no rascunho, o `docker compose config` desse mutante deu o app com 0 entradas e o nginx com
  18. Agora as três listas são lidas do serviço `app`, e só dele (`blocoDoServico`).
- **A checagem 6 não exigia o arquivo da homologação** no modelo do BVIP: só reprovava o modelo sem
  `COMPOSE_FILE` ou com o arquivo do desenvolvimento, e `COMPOSE_FILE=docker-compose.yml` no
  `.env.homologacao.example` passava -- a homologação nova subiria sem o corte. Agora o `COMPOSE_FILE` de
  cada modelo é a lista exata e na ordem: a base e o `docker-compose.homologacao.yml`; no teste, esses dois
  e o `docker-compose.teste.yml`.
- **O título dizia "o BVIP cortado"**, e nada mudou no BVIP: a marca agora diz que o corte vale no próximo
  `up`, a restrição de cima diz que vale na homologação e no laboratório até o `--conteiner` sair 0 lá, e o
  "a implementação está na fila" apontou para esta seção.
- **O cabeçalho do `docker-compose.desenvolvimento.yml`** mandava pôr o arquivo só da máquina de quem
  programa no `COMPOSE_FILE` do `.env.example`, que a checagem 6 exige com os dois, exatos: agora manda
  pô-lo no `.env` daquela máquina, que a checagem 7 aceita (medido com os dois casos no espelho).
- **Três textos ainda diziam que a homologação não corta**, ou que era decisão pendente: a
  `docs/ambiente-desenvolvimento.md`, o cabeçalho do `docker-compose.teste.yml` e o `.env.teste.example`.
  Reescritos; só comentário -- a configuração do teste, a da homologação, a do laboratório e a local têm o
  mesmo sha256 antes e depois, e o hash do app de cada uma também (o local, o do contêiner de pé).

Os mutantes da revisão, no espelho (o repositório não foi mutado), com o portão antes e depois do
conserto: o bloco movido para o `nginx` na homologação e no teste, 0 antes e 1 depois; o `COMPOSE_FILE` do
modelo da homologação só com a base ou com a ordem trocada, e o do teste sem o `docker-compose.teste.yml`
ou sem o da homologação, 0 antes e 1 depois. O bloco COPIADO também para o `nginx` (o app segue com a
lista) saía 1 antes, por juntar as entradas dos dois serviços, e sai 0 depois, que é o certo. E no código
do conserto: sem o `blocoDoServico`, os dois blocos movidos voltam a passar; sem a lista exata dos modelos,
os quatro `COMPOSE_FILE` errados voltam a passar -- cada parte é quem pega o seu.

**Achado no caminho, registrado à parte:** o laboratório, como a documentação o ensina, divide com a
homologação as portas do `.env` e o `local.php`, e os comandos seguintes da seção vão à homologação (D431).

**Resta** a fixture com o WhatsApp ligado e sem chave, que entra com a F0 do WhatsApp (acima).

#### PARCIAL em 12/10/2026 -- a fixture com o WhatsApp ligado e sem chave (o item WHATSAPP-F0-E-CENTRAL)

**A unidade 90001170, "Unidade WhatsApp Sem Chave"**, em `tests/Caracterizacao/fixtures.sql`: ativa, com o
`whatsapp_ativo` ligado e a chave e a instância NULAS -- como a cópia anonimizada deixa as unidades reais --, invisível no
aplicativo (o snapshot de `/api/agendas/getUnidades` não a vê) e sem usuário. A marca das fixtures foi regravada
(`scripts/marcar-fixtures.php`). **Quem a lê é a F0 da tela antiga** (o D305), que, sem credencial, não fala com o
provedor em nenhuma das quatro ações: o `WhatsAppTelaAntigaTest::testSemCredencialNadaFalaComOProvedor` confere a linha
da fixture e as chamadas (zero), nos dois bancos; tirar o filtro da credencial deixa o caso vermelho. Ensaio e conferência
no navegador do WhatsApp passam a ser nesta unidade; as 90001150 e 90001200, com a chave de mentira, continuam fora deles
até o `--conteiner` sair 0 onde se rodar (a restrição de cima).

**Resta** só o que não é do repositório: o corte da homologação e do laboratório do BVIP vale no próximo up de cada um,
pela mão do dono (a seção de 06/10/2026), e até lá a restrição de cima vale lá.

### D361 — Oito listas antigas mandam o parâmetro da URL cru à consulta, e no PostgreSQL uma letra onde se espera inteiro ou booleano dá erro 500 — PARCIAL em 04/10/2026: as linhas de Contas a pagar, de Grupos, de Pesquisas e de Mensagens — PARCIAL em 19/10/2026: a linha das Etiquetas — PARCIAL em 20/10/2026: a linha dos Banners — PARCIAL em 22/10/2026: a linha dos Arquivos — CORRIGIDO em 05/11/2026: a linha da VIP Class (a fatia 81)

Achado por oito desenhos e juntado pelo consolidado (`docs/f3-telas-restantes/telas-consolidado.md`, seção
4, item 6). É a família do D279, consertada nos cupons, e do D278, o link da página.

| tela | no desenho | onde, e o que se mediu |
|---|---|---|
| Banners | Dα (o dos Banners; o dos Assistentes é o D343) | o filtro de situação (`AppBannersController.php:44`): `status='x'` dá 22P02 no PostgreSQL e devolve as 5 inativas no MySQL |
| Pesquisas | DP-11 | o `status` (`PesquisasController.php:49`); no PostgreSQL a coluna é `smallint` |
| Mensagens | M6 | o dia vai cru a `DATE(:data)`: no MySQL acha 0; no PostgreSQL "13/07/2026" dá 500 e "02/10/2026" vira 10 de fevereiro (medido) |
| Arquivos | Dnovo-5 (o dos Arquivos) | `status`, `link` e `categoria` nas duas listas: 22P02 medido (C11); a paginação repete os filtros sem codificar |
| Grupos e privilégios | D-ACL-6 | o `status` dos grupos, e o `grupo` e a `funcionalidade` dos moldes (lido) |
| Etiquetas | DT-E | o `status` (22P02 medido, M18); no Gerenciar, um `status` fora da lista deixa o parâmetro sem valor: 500 nos dois bancos (lido) |
| Contas a pagar | débito 5 | `mes`, `ano` e `tipo`, na API e no ERP (lido) |
| VIP Class | DV-13 | o `?status=0` do relatório mostra os cursos inativos a quem não é administrador |

Os portões de booleano (`scripts/conferir-booleanos-em-consulta.php` e
`scripts/conferir-booleano-em-coluna-inteira.php`) não veem esta forma: o valor chega por variável, o
limite que eles mesmos declaram.

**O conserto,** o mesmo nas oito: lista fechada, `ctype_digit` e `http_build_query`; fora da lista, o
filtro é ignorado ou recusado com frase, nunca 500. Cada F0 leva o da sua tela.

#### PARCIAL em 04/10/2026 -- a linha de Contas a pagar (o item FINANCEIRO-S1)

`mes`, `ano` e `tipo` passam por `RegraDaSaida` (mês de 1 a 12, ano de quatro algarismos, tipo em
algarismos) e o tipo é procurado no banco antes de filtrar: na API, o que não serve responde **422**
com o campo; no ERP, é **ignorado** -- vira "Selecione" no formulário, e o link da paginação sai dos
filtros conferidos, por `http_build_query` (a busca com `&` não vira outro parâmetro). A `pagina` do
ERP também deixou de ir crua à conta.

**Provado:** `SaidaApiEscritasTest` (sete filtros ruins, 422 com o campo; os bons filtram) e
`SaidasDoErpTest` (os ruins ignorados, o link só com o que serve e codificado), nos dois bancos. O
mutante que devolve o mês cru ao ERP ficou vermelho no MySQL (a lista vazia, calada) e **com erro no
PostgreSQL** -- o 500 que este débito descreve, medido. Os outros três mutantes (a API sem o 422, o
tipo inexistente aceito, o link sem codificar), vermelhos.

**Resta:** as outras sete linhas da tabela, cada uma com a F0 da sua tela.

**E a mesma forma no POST** (a revisão do item, 04/10/2026): o lote de exclusão de parcelas mandava o
id cru ao `find()`, e no PostgreSQL `'abc'` derrubava o lote com 500 (22P02, medido); consertado lá (o
D199). As ações irmãs da tela antiga -- `parcelaQuitar`, `quitarSemPagamento`, `parcelaEditar` e
`pagamentoEstornar` -- ainda buscam o `id` do POST cru, e o grep acha 35 `find($request[...])` em 16
arquivos do ERP. Lido, não medido um a um: com a lista das telas, cada F0 leva o seu.

#### PARCIAL em 04/10/2026 -- a linha de Grupos e privilégios (o item GRUPOS-F0)

`GruposController` só aceita a situação "0" ou "1" (o resto vale "ativos"), e `PrivilegiosController`
só o grupo e a funcionalidade que estão nas listas do próprio formulário (o resto é ignorado, e vira
"Selecione"); os dois repositórios convertem para inteiro, e o link da paginação sai dos filtros
conferidos, por `http_build_query` -- o `&` de um nome não vira outro parâmetro. Do mesmo D-ACL-6, o
formulário do molde voltou a conferir os quatro campos contra as opções (estava desligado, com
`setDisableInArrayValidator`): `ler="2"`, `escrever="sim"` e o grupo ou a funcionalidade que não existe
são recusados, e nada chega ao banco.

**A revisão (04/10/2026): `ler` e `escrever` só "0" ou "1", estritos.** Ligar o `InArray` do `Select` não
bastava: ele compara com `==`, e no PHP 8 dois textos numéricos se comparam como números -- medido no
contêiner com o formulário de verdade, `"00"`, `"0.0"`, `"0e5"` e `"-0"` passavam como "Não", e
`PrivilegioService::nivel` os gravava **verdadeiros** (`(bool) "00"`); `"1.0"`, `"01"` e `"+1"` passavam
como "Sim". O valor conferido e o gravado discordavam (só o administrador, com o token, chegava ali --
e ele podia escolher "Sim" direto: sem escalada). O `PrivilegioFilter` ganhou, nos dois campos, um
`InArray` estrito com `['0', '1']`.

**Provado** nos dois bancos (`PrivilegiosDoErpTest`): `?grupo=abc`, `?funcionalidade=1 OR 1=1` e
`?status=x` dão a lista inteira (ou a dos ativos), com o link vazio; o grupo da lista filtra; os campos
fora da lista não gravam, no novo e no editar; e os sete números disfarçados, em `ler` e em `escrever`,
no novo e no editar, são recusados no campo, sem gravar. Mutantes: o filtro cru no controller e no
repositório -- vermelho no MySQL (a lista vazia, calada) e **com erro no PostgreSQL**, o 500 que este
débito descreve; a situação crua, idem; `ler` e o grupo aceitando fora da lista; e o `InArray` estrito
arrancado de `ler`, e depois de `escrever` -- vermelhos.

**Resta:** as outras seis linhas da tabela, cada uma com a F0 da sua tela.


#### PARCIAL em 04/10/2026 -- as linhas de Pesquisas e de Mensagens (o item MARKETING-CONSERTOS)

**Pesquisas** (DP-11): a situação só "0" ou "1" -- o resto vale "ativas", a de sempre --, o repositório
converte para inteiro, e o link da paginação e o redirecionamento do ativar saem dos filtros conferidos,
por `http_build_query`. **Mensagens** (M6): o dia do calendário (`dd/mm/aaaa`) vai a `DATE(:data)` em
`AAAA-MM-DD`, e o dia que não existe é ignorado (`RegraDaMensagem::diaDoErp`, sem o "31/02 vira 03/03");
a situação "0" ou "1"; a unidade e o grupo só os da lista do formulário; o link codificado.

**Provado** nos dois bancos (`PesquisasPelosControllersTest`, `MensagensPeloControllerTest`): o filtro por
dia acha o aviso de daqui a cinco dias, e não o do dia seguinte; `?status=x`, os dias que não existem, o
grupo "abc" e a unidade de fora são ignorados, e não vão ao link; o `&` do título não vira outro
parâmetro. Mutantes: a situação crua no controller (vermelho nos dois bancos: a lista das inativas,
calada) e, com o repositório cru também, no PostgreSQL, o `22P02` que este débito descreve; o dia cru
(vermelho nos dois -- no PostgreSQL, o `22008` do "31/02/2026"); o grupo cru no controller e os dois
links sem codificar -- vermelhos. O repositório dos avisos sem converter o grupo, com o controller
conferindo, sobreviveu, como se esperava: a conferência do controller basta.

**O dia que não existe é IGNORADO, e não recusado com 422** -- a escolha, escrita na revisão do mesmo
dia: o levantamento resumiu o item como "de 500 para 422", e o M6 do desenho só pede a conversão e os
filtros conferidos. Numa lista HTML do ERP, o 422 trocaria a lista por uma página de erro, e o conserto
deste débito aceita "ignorado ou recusado com frase, nunca 500". O que se vê: `?data=31/02/2026` devolve a
lista sem o filtro, com 200, e o dia não volta ao link (`testOFiltroQueNaoServeEIgnorado`). A API da tela
nova (a F1 do desenho) recusa com 422 e o nome do campo.

**Resta:** as outras quatro linhas da tabela -- Banners, Arquivos, Etiquetas e VIP Class --, cada uma com a
F0 da sua tela.

#### PARCIAL em 19/10/2026 -- a linha das Etiquetas (a fatia ETIQUETAS-ERP; o DT-E)

**Na lista**, a situação só pode ser "0" ou "1"; o resto vale "ativas", a de sempre. Ela ia crua a `pro.status = :status`, e
no PostgreSQL `abc`, `2` e o vazio davam "invalid input syntax for type boolean". O repositório recebe o booleano.

**No Gerenciar**, a situação e a etiqueta definida só são aceitas da lista do formulário. Antes, um `status` fora da lista
deixava `:status` sem valor na DQL, e dava 500 nos dois bancos. A etiqueta e a unidade só valem como id (`ctype_digit`, até o
maior inteiro da coluna): o texto ia cru contra coluna inteira e dava 500 no PostgreSQL. A página começa em 1.

O que não serve é ignorado e não volta ao link. O link da paginação e o redirecionamento do desativar saem dos filtros
conferidos, por `http_build_query`: "Corte & Barba" voltava cortado. A regra é a `RegraDaEtiqueta::situacaoDaLista`,
`filtrosDoGerenciar` e `noLink`.

**Provado** nos dois bancos:

- `TelasDasEtiquetasDoErpTest`: `status=abc`, vazio, `2` e `0 OR 1=1` na lista; `status=x`, vazio, `unidade=abc`,
  `unidade=99999999999`, `tag=abc` e `pagina=abc` no Gerenciar. Todos dão a lista sem o filtro. A metade que filtra também
  está lá: as ativas, as inativas e a etiqueta. E o link sai codificado.
- `RegraDaEtiquetaTest`: as mesmas fronteiras, na regra.

Mutantes:

- a situação da lista crua: vermelho, e no PostgreSQL com o 500 que este débito descreve;
- a situação do Gerenciar fora da lista, com o repositório de antes: vermelho nos dois bancos, "too few parameters";
- a unidade e a etiqueta cruas: vermelho no PostgreSQL;
- o link sem codificar: vermelho.

**Resta:** as outras três linhas da tabela -- Banners, Arquivos e VIP Class --, cada uma com a F0 da sua tela.

#### PARCIAL em 20/10/2026 -- a linha dos Banners (a fatia 21, BANNERS-SERVIDOR; o Dα de `tela-banners.md`)

A situação da lista antiga só pode ser "0" ou "1"; o resto vale "ativos", a de sempre (`RegraDoBanner::situacaoDaLista`). Ia
crua ao parâmetro booleano: no PostgreSQL `status=x` dava 22P02 (500), e no MySQL devolvia as 5 inativas. O repositório recebe
o booleano; a busca só como texto, a página de 1 em diante, e o link da paginação sai dos filtros conferidos, por
`http_build_query` (`RegraDoBanner::noLink`). Na API nova, o estado fora da lista (`ativo`, `inativo`, `todos`) é 422 com o
campo.

**Provado** nos dois bancos (`TelasDosBannersDoErpTest`): `status=x`, vazio, `2`, `0 OR 1=1` e `abc` dão a lista dos ativos,
sem os inativos; o "0" dá os inativos; o "Corte & Barba" volta codificado, e o que não serve não vai ao link. Mutantes: a
situação crua no controller -- vermelho no MySQL (o vazio virava inativos) --; a situação crua no controller e no repositório
-- vermelho no PostgreSQL, com o erro do banco que este débito descreve --; o link sem codificar -- vermelho.

**Resta (em 20/10/2026):** as outras duas linhas da tabela -- Arquivos e VIP Class --, cada uma com a F0 da sua tela.


#### PARCIAL em 22/10/2026 -- a linha dos Arquivos (a fatia 27, ARQUIVOS-SERVIDOR; o Dnovo-5 de `tela-arquivos.md`)

As duas listas antigas -- o cadastro (`ArquivosController::indexAction`) e a biblioteca
(`ArquivosUsuariosController::indexAction`) -- passam a query pela `RegraDoArquivo::filtrosDaLista`: a situação só "0" ou
"1" (o resto vale "ativos"), o tipo só "0", "1" ou nenhum, a categoria só em algarismos e até o `int` da coluna, a
subcategoria e o nome só como texto e até a coluna, a página de 1 em diante. Fora disso o filtro é IGNORADO, como nas
outras linhas. O repositório recebe o booleano. O link da paginação -- e o do redirecionamento do ativar e do desativar,
que vem da sessão -- sai dos filtros conferidos, por `http_build_query` (`RegraDoArquivo::noLink`). Na API nova, o filtro
que não serve é 422 com o campo.

**Provado** nos dois bancos (`TelasDosArquivosDoErpTest`): `status=x`, `link=abc`, `categoria=abc`, `pagina=x` e os
textos em arranjo dão a lista dos ativos, sem erro, e nada vai ao link; o "0" dá os inativos; o "Corte & Barba" e o
"a&b" voltam codificados. E a `RegraDoArquivoTest`, a regra pura. Os mutantes: a categoria crua (vermelho nos
dois -- no MySQL a lista vazia, calada; no PostgreSQL, 22P02, o erro que este débito descreve), a situação crua, e o
link sem codificar.

**Resta:** a linha da VIP Class (o DV-13), com a F0 da tela dela.

**A linha da VIP Class, em 05/11/2026 (VIPCLASS-QUEM-FEZ, a fatia 81 e a correção dela; o DV-13, o D716).** A lista do
relatório do menu Relatórios (`VipClassRelatorioController::indexAction`) e a irmã, a lista da administração
(`VipClassAdminController::indexAction`), passam a query pela `RegraDoCursoVipClass::filtrosDaLista`: a situação só "0" (os
inativos, e só para o administrador -- a letra, o vazio, o arranjo e o "0" de quem não é administrador valem os ativos); o `grupo`
e a `unidade` só em algarismos e até o `integer` da coluna (fora disso, IGNORADOS: o grupo some, a unidade volta à da sessão; o
vazio da unidade continua "todas", para quem escolhe); a busca só texto e até a coluna (`varchar(100)`; o arranjo derrubava o
`explode`); a página de 1 em diante. Quem não é administrador continua na unidade da sessão no relatório (o D237). O link da
paginação -- e, na administração, o da volta do ativar e do remover, pela sessão -- sai dos filtros conferidos, por
`http_build_query` (`RegraDoCursoVipClass::noLink`): a busca com `&` não vira outro parâmetro, e a unidade vai sempre, vazia
quando são todas (sem ela, a página 2 voltava à unidade da sessão). A primeira passada da fatia 81 conferiu só o `?status=`; a
verificação achou o `grupo`, a `unidade` e o link ainda crus, e a correção os fechou. **Provado** nos dois bancos
(`ConclusoesDoCursoApiTest::testAsTelasAntigasComORecorteNovo` e `::testOsFiltrosCrusNaoChegamAConsulta`,
`RegraDoCursoVipClassTest::testASituacaoDaLista`, `::testOsFiltrosDaLista` e `::testONoLink`, e os do
`D237AgendaRecortePorUnidadeTest`): `status=x`, `grupo=x`, `unidade=x`, `pagina=x` e a busca em arranjo dão a lista, sem erro,
com o curso restrito; nada disso vai ao link; o "a&b" volta codificado. Os mutantes: o `?status=` cru de volta no relatório
(vermelho nos dois), o "0" a quem não é administrador (vermelho nos dois), o cru na administração, o `grupo` cru e a `unidade`
crua -- vermelhos no MySQL pela asserção (a lista sem o curso restrito, calada) e no PostgreSQL pelo próprio defeito deste débito
(o 22P02) --, e o link sem codificar (vermelho nos dois). As oito linhas estão fechadas.

### D362 — O portão das remoções por GET não lê o JavaScript, os links diretos nem a ação que nenhuma tela chama — PARCIAL em 04/10/2026: o excluirSaida — PARCIAL em 05/11/2026: o JavaScript servido — CORRIGIDO em 08/11/2026: os links de estado e a ação que grava sem conferir o verbo

Achado por três desenhos -- VIP Class (DV-4), Pesquisas (DP-6) e Contas a Pagar (débito 9) -- e juntado
pelo consolidado (`docs/f3-telas-restantes/telas-consolidado.md`, seção 4, item 2).

`scripts/conferir-remocao-por-post.php` lê só as chamadas `modal_remover` e `modalRemoverPorPost` dos
`onclick` das telas (`:192`). Passam por ele:

- **o JavaScript:** os dois 404 da pergunta da VIP Class -- o editar e o remover pergunta, chamados de
  `public/layout/scripts/mod-configuracoes.js`, não existem no controller -- passaram sem vermelho (DV-4);
- **o link direto:** o "ativar" das pesquisas é um `href` para uma ação que alterna por GET (DP-6).
  Converter só o "desativar" deixaria o "ativar" como botão morto, com o portão verde;
- **a ação sem botão:** o `excluirSaida` aceita GET e nenhuma tela o chama (Contas a Pagar, débito 9; o
  defeito já está no D205, sem conserto).

A trava está em 13 (`:54`), e 9 das 13 chamadas são das telas destes desenhos; as fatias se somam, e
cada commit desce um a partir do valor do dia -- "de 13 para 4", e não "de 13 para 12", como oito
desenhos escreveram.

**O conserto** (sem decisão): o portão passa a ler o JavaScript servido e os `href` das ações de estado,
e a ação que muda estado sem conferir o verbo entra na conta mesmo sem tela; falsificado devolvendo o
GET a uma das três.

#### PARCIAL em 04/10/2026 -- a ação sem botão de Contas a pagar (o item FINANCEIRO-S1)

O `excluirSaida` exige POST (o resto do D205), e o portão passou a aceitar, em `ACOES_SO_POST`, uma
ação **sem tela** (`null` no lugar dela): confere a forma da guarda e que ela seja o primeiro comando,
e que **nenhuma tela** cite a ação -- lida sem os comentários de PHP. A primeira tela que passar a
chamá-la deixa o portão vermelho até entrar na linha, com o formulário POST. **Falsificado** quatro
vezes, cada uma restaurada por sha1: a guarda arrancada e a guarda com o sinal trocado, vermelhas
("não começa recusando o GET"); um link para a ação em `identificacao.phtml`, vermelho ("ação SEM
TELA"); e o controle, a ação citada só num comentário da tela, verde.

**Resta**, como estava escrito: o portão ler o JavaScript servido e os `href` das ações de estado, e
contar sozinho a ação que muda estado sem conferir o verbo -- hoje ela só entra quando alguém a põe
na lista.

#### PARCIAL em 05/11/2026 -- o JavaScript servido (a fatia 82, VIPCLASS-ADMIN-SERVIDOR; o DV-4)

As duas ações que o `mod-configuracoes.js` chamava e não existiam nasceram em `VipClassAdminController`:
`checkpointPerguntaSelecionar` (devolve só o id, a ordem e o texto da pergunta ativa) e `checkpointPerguntaRemover` (desativa a
pergunta, a decisão 8 do desenho); as duas só por POST, com o `vipclass/admin`. E o lápis punha a ordem num `#ordem` que o formulário
não tem: agora o `#ordenacao` (`mod-configuracoes.js?cache=171`), com o caso no `frontend/src/telas/vipClassDaTelaAntiga.teste.ts`.
**O portão passou a ler o JavaScript** (a conferência 6 do `conferir-remocao-por-post.php`): todo texto `"/mod-<modulo>/<controller>/
<acao>` dos `*.js` legíveis de `public/layout/scripts/` é resolvido pelo prefixo da rota, pelo alias do `Module.php` e pelo método
`<acao>Action`; o que não existe é vermelho, com um piso de leitura (136 endereços medidos, piso 120; abaixo, saída 2).
**Falsificado:** com o controller do HEAD (sem as duas ações), vermelho nas duas chamadas; devolvido pelo sha1, verde. **Resta**,
como estava escrito: os `href` das ações de estado, e contar sozinho a ação que muda estado sem conferir o verbo; o endereço montado
por concatenação no JavaScript também não é lido.

#### CORRIGIDO em 08/11/2026 -- os `href` e a ação que grava pelo GET (a fatia 126, PORTOES-E-COBERTURA)

O `conferir-remocao-por-post.php` ganhou as conferências 7 e 8.

**7. A ação que grava, achada sozinha, por dois leitores.** Toda `<acao>Action` de `module/<modulo>/src/Controller/*.php` (a
`/adm/api` fica de fora: o verbo dela é da rota) é lida sem os comentários: grava quem chama um escritor do Doctrine ou da
conexão, ou um método de serviço cujo nome diz escrita; e o inventário (`docs/inventario-acoes.csv`, `muta = sim` e
`recebe_post` diferente de `sim`), que segue a chamada até o serviço e o ajudante (e vê o `VendasController::novo`, que o
primeiro leitor não vê), entra junto. A que grava e não separa o GET antes tem de estar em `ACOES_QUE_GRAVAM_PELO_GET`, com o
motivo e os lugares que a chamam por link -- ou o controller em `CONTROLLERS_DE_ROTINA` (o `CronJobController`, com o token).
Medido em 08/11/2026: 304 ações lidas, 98 que gravam, e **oito que gravam pelo GET**, todas já conhecidas e cada uma com o
motivo: o `clienteSelecionar` do aplicativo, as duas mensagens da agenda, o `caixas/gerenciar` (o GET com o caixa aberto
recalcula e grava o total do caixa sob a trava, o D228 -- a correção abaixo), o `indexVendas` (o D428), o `vendas/novo`, o `comandaAbrirAgenda` e
o `vendas/comanda` (o D316). A lista tem catraca: a linha que nenhum dos dois leitores vê mais gravando pelo GET, e o lugar
nomeado que perdeu o link, são vermelhos até sair. O "confere o verbo" aceita, além das duas formas da `formaDaGuardaPost`, a
variável com outro nome (`$req->isPost()`, a blacklist da agenda), o `!isPost()` num lado de um `||` (o `agendaSalvarV3`) e o
bloco que volta com outra resposta que não o 404 (o visagismo) -- a gêmea das três cópias não mudou.

**8. O link, de toda tela e do JavaScript.** Todo `href` de toda tela (e não mais só das que chamam o modal) com
`'controller' => ..., 'action' => ...`, e todo endereço do JavaScript servido, vai até a ação (407 links lidos): o que recusa o
GET é botão morto; a ação de estado pelo nome (`ativar`, `remover`, `alternar`, ...) sem guarda é link que muda estado; e a
ação que grava pelo GET só pode ser chamada dos lugares da linha dela. O endereço montado por partes no JavaScript (o
`"/mod-vendas/" + x`) continua sem leitura -- e, medido no mesmo dia, nenhum `"/mod-` dos `.js` servidos fica fora da forma
inteira que o portão lê.

**Falsificado**, cada um devolvido pelo sha1 (e, onde diz, o portão do HEAD rodado sobre a mesma árvore): R1, a guarda arrancada
do `blacklistAgenda/remover` -- vermelho ("GRAVA e não confere o verbo"; o do HEAD, verde); R2, o "Ativar" das pesquisas como
`href` no `editar.phtml`, que não chama o modal -- vermelho ("botão morto"; o do HEAD, verde); R3, a guarda arrancada do
`pesquisas/ativarRemover` -- vermelho pela 7 e pela 1; R8, o DP-6 inteiro (R2 e R3 juntos) -- vermelho pela regra do nome ("link
que muda estado"); R4, um link novo para o `comandaAbrirAgenda` numa tela que a linha não nomeia -- vermelho (o do HEAD, verde);
R5, a tela nomeada sem o link -- vermelho ("tire-o da linha"; o do HEAD, verde); R7, um `flush()` no `pesquisas/index` --
vermelho (o do HEAD, verde); R9, a guarda posta no `clienteNovoMensagem` -- vermelho pelo link que ficou morto, e o
`conferir-inventario-atual.sh` vermelho junto; e o controle R6, o link só num comentário de PHP -- verde.

#### A correção depois da verificação (08/11/2026, a mesma fatia)

A verificação em três lentes achou quatro furos no portão, todos consertados nele:

- **O endereço montado por partes, o resto de 05/11, passa a ser lido -- e acusado.** Todo texto entre aspas (ou crase) que
  começa por `/mod-` e não é o endereço inteiro (`/mod-<modulo>/<controller>/<acao>`) é vermelho, no JavaScript servido e nas
  telas: o portão não sabe aonde ele leva, e quem precisar montar escreve o endereço inteiro e põe só o id por partes. Medido no
  dia: zero (os 10 com `+` são inteiros, com o id depois). Com isso o "Resta" de 05/11 acabou, e a marca CORRIGIDO vale; fica
  fora só o `url:` vindo de variável, que o portão diz desde 05/11 que não lê (o endereço nunca é texto no arquivo).
- **O endereço escrito por extenso nas telas.** A conferência 8 lia só o `href` com `url('rota', [... 'controller', 'action'])`;
  passa a ler também o `href="/mod-..."` (a comanda e o PDV: o histórico, a pontuação e os pacotes do cliente; os serviços
  agendados) e o `url: "/mod-..."` de um `<script>` da tela (o status do WhatsApp) -- 9 endereços, com piso 8. O do `href` é
  link (a ação que recusa o GET é botão morto); o do `<script>` é lido como o JavaScript servido (ele pode mandar POST). O
  `$this->url(...)` fora de `href` foi medido: só no `action` de formulário POST, no `onclick` do modal e na própria tela do
  WhatsApp; o portão diz isso em "O QUE ELE NAO LE".
- **O `caixas/gerenciar` grava pelo GET de fato.** O motivo da linha dizia "o GET só mostra"; o `fechar()` chama o
  `recalcularSobTrava` antes do `isPost()`, e ele grava o `valor_final` com a linha do caixa travada (o D228). O inventário
  estava certo (`muta = sim`); o texto do portão e o deste débito mentiam. O motivo diz agora o que o GET faz (valor derivado,
  recálculo idempotente).
- **A isenção da rotina é por ação, e não pelo `CronJobController` inteiro.** As três `/api/unidade/vendas*` moram nele e não
  pedem o token: o sistema das unidades as chama com o id e o hash na query, e a `unidadeAutorizada` grava pelo GET o uso do hash
  anterior. Agora só fica fora a ação que COMEÇA recusando o pedido sem o token (`if (!$this->tokenConfere()) { return ... }`):
  24 hoje. As três entraram em `ACOES_QUE_GRAVAM_PELO_GET` com o motivo (mudar o verbo muda o contrato com as unidades, o D742,
  que espera o dono) -- **onze** ações que gravam pelo GET, declaradas. A linha de `CONTROLLERS_DE_ROTINA` sem nenhuma ação de
  rotina é vermelha.

**Falsificado de novo**, cada um devolvido pelo sha1: R10, o `href` literal da comanda trocado para o `pesquisas/ativarRemover`
-- vermelho ("botão morto"); R11, o `href` literal dos serviços agendados trocado para o `vendas/comanda` -- vermelho ("grava pelo
GET"); R12, um `"/mod-vendas/" + "vendas/novo"` no `mod-vendas.js` -- vermelho ("monta um endereço por partes"); R14, o mesmo por
`.` do PHP numa tela -- vermelho; R16, a guarda do token arrancada do `comandasCancelar` -- vermelho ("grava e não confere o
verbo"); R17, uma ação nova no `CronJobController` que grava sem o token -- vermelho (antes, a isenção do controller inteiro a
calava); e os controles R13 (o `url:` do `<script>` do WhatsApp trocado para o `desconectar`, que recusa o GET: o script pode
mandar POST) e R15 (o link para a comanda só num comentário de HTML), verdes, como devem.

### D363 — O convênio lançado pela agenda vai sem a marca de convênio, e o PDV novo deixa somar outro desconto por cima: o desconto pode sair em dobro — REGISTRADO em 03/10/2026 — CORRIGIDO em 15/10/2026 (a resposta 90)

Achado pela especificação dos Descontos (`docs/f3-relatorios/spec-descontos.md`, seção 4, D-a). Lido no
código; não executado nem visto no navegador.

- O caminho da agenda grava o desconto do convênio sem motivo
  (`module/ISVenda/src/Service/VendaService.php:146-151`, chamado pelo abrir a comanda pela agenda).
- A guarda do D214 no PDV novo só reconhece o convênio pelo prefixo do motivo (`descontoDoConvenio`,
  `frontend/src/telas/ajusteDaLinha.ts:27-29`): a linha da agenda não tem motivo, o botão "desconto"
  aparece nela e abre preenchido com o desconto do convênio, e o "aplicar" com um motivo faz o servidor
  recalcular o convênio **e** somar o valor digitado
  (`module/ISVenda/src/Service/VendaProdutoService.php:114-136`). Mudar a quantidade dá "tem desconto sem
  motivo" -- e isso leva o operador justamente a esse caminho.
- **A escala** (medida em 02/10/2026): 927 itens na unidade 1 em julho de 2026, e 39.690 de 01/2025 a
  08/2026 na rede, todos em venda de convênio. São os itens expostos; quantos saíram em dobro a cópia não
  separa.
- E corrige a leitura do D212: os itens "sem motivo" que ele conta como desconto manual escapado da tela
  velha são desconto de convênio vindo da agenda (0 em venda sem convênio). A especificação manda
  acrescentar isso ao D212 na fatia dela (o D-b).

**O conserto proposto:** o caminho da agenda grava o motivo com o prefixo do convênio e o nome, como o PDV
já faz. Se isso mudar o que as lojas gravam -- o critério do D214 --, vai ao dono junto com o D214.

#### CORRIGIDO em 15/10/2026 -- a resposta 90 (a fatia 4 da fila das telas que faltam, RESPOSTAS-DO-BALCAO)

**Decidido pelo dono em 12/10/2026 (a resposta 90, opção "b"):** *"Só a agenda grava 'Convênio X' -- fecha o desconto
em dobro; o motivo digitado continua sumindo, e o cupom do cliente de convênio continua sem se gastar."* O motivo
digitado no item do convênio voltou depois, pela 177 (c) (o D477); o cupom é a 187 (c), da fatia 5d, e a agenda não
lança cupom.

**O conserto:** a comanda aberta pela agenda grava no item do convênio o motivo do lançamento,
`RegraDoItemDaComanda::motivoDoConvenio` ("Convênio X", cortado nos 100 da coluna), que o
`VendaProdutoService::calcularLinha` passou a usar também -- uma conta só para as duas portas. **Para quem usa:** na
comanda nova, a linha que veio da agenda é a do convênio -- o "desconto" do balcão some dela (o `descontoDoConvenio` do
`ajusteDaLinha.ts`, pelo prefixo), e o "+" e o "−" recalculam o convênio sobre a quantidade nova, em vez de recusar por
"desconto sem motivo". O relatório de descontos segue contando a linha como convênio (agora pelo motivo; antes, pelo
"sem motivo em venda de convênio"). A 177 (c) vale na linha da agenda: o desconto do balcão nela leva os dois motivos, e
a linha volta marcada (o "+" e o "−" recusam). O comentário do `ajusteDaLinha.ts` que dava os itens "sem motivo" como
da tela antiga foi corrigido, como a `spec-descontos.md` pedia junto.

**A linha gravada antes da troca (a verificação da fatia, 15/10/2026).** A primeira escrita deste bloco dava a linha
que a agenda gravou antes da troca, numa comanda ainda aberta nessa hora, como resto de outro débito, e só citava a
recusa do "+". Era deste: nela a tela nova seguia mostrando o "desconto" preenchido com o do convênio, e o "aplicar" o
somava ao que o servidor recalcula -- o desconto em dobro do título --, e a frase do "+" ("Informe o motivo em
'desconto'") mandava o operador justamente para lá. Agora a comanda a marca (`convenioSemMotivo`, de
`RegraDoItemDaComanda::eDoConvenioSemOMotivo`: desconto de fato e sem motivo, fora da sessão do pacote, no serviço, e o
convênio do cliente ligado), e a tela nova a trata como a do convênio: sem o "desconto", e o "+" e o "−" recalculam o
convênio e gravam o motivo. Com o desconto acima da parte do convênio de hoje (a porcentagem que baixou depois), ela
vem marcada como a do balcão (`temODescontoDoBalcaoNoConvenio`, que passou a aceitar a linha sem motivo), e o "+"
recusa. Fora da marca ficam a do convênio desligado depois, a do cliente que perdeu o convênio e a do produto ou da
lavagem: nas três o servidor não recalcula convênio nenhum, e o "desconto" guarda o valor inteiro, sem dobrar. Na
cópia: 1 comanda aberta, e nenhuma linha assim; na produção, são as comandas da agenda abertas na hora do corte.

**O que fica, e não é o dobro:** a linha marcada numa comanda que atravessa para um dia em que a unidade não dá o
convênio perde o desconto no "+", como qualquer linha do convênio. E a agenda dá o convênio também na
lavagem e no produto marcados, que o caixa não dá: é o **D496**, no lote; até a resposta, a comanda marca essa linha
(`convenioQueOCaixaNaoDa`) e o "+" e o "−" recusam, em vez de tirar o desconto calado. *(Decidido em 15/10/2026, a
resposta 227 (a): a agenda só dá o convênio no serviço, como o caixa; a marca fica para o que já está gravado. O D496.)*

**Provado** (a verificação), nos dois bancos: os 4 casos da linha de antes no `D363D465ComandaPelaAgendaComoOPdvTest`
-- marcada, e o "+" que grava o motivo (143,84 e 35,96, e não o dobro); acima da parte de hoje, marcada como a do
balcão; com o convênio desligado, sem convênio no cliente e na pomada, sem a marca --; e no front
(`ComandasLigada.teste.tsx`), sem o "desconto" e o "+" só com a quantidade. **Mutantes**, cada um sozinho: a marca
sempre falsa (3 vermelhos); o valor ignorado na linha sem motivo (1); sem o tipo (1); com o convênio desligado (1); no
front, sem a marca (1).

**Na cópia** (só contagens, 15/10/2026): 40.681 itens com desconto e sem motivo em venda de convênio desde 01/2025, fora
as sessões de pacote, todos do tipo `ser`.

**Provado** nos dois bancos: os 8 casos do motivo no `D363D465ComandaPelaAgendaComoOPdvTest` -- o motivo e os valores
(71,92 e 17,98), o mesmo item que o PDV grava, o nome de 100 caracteres cortado, sem convênio e com o convênio fora do
dia nem desconto nem motivo, a sessão do pacote com o nome dele, pela API (`ComandaJson::completo`) a linha sem a marca
do balcão e o "+" recalculando (143,84 e 35,96), e o desconto do balcão nela com os dois motivos e a marca.
**Mutantes**, cada um sozinho no MySQL: a agenda sem o motivo (4 vermelhos); o motivo sem o corte (o MySQL estrito
derruba a comanda: o primeiro erro é o caso do nome longo, o da agenda e o do `ConvenioDescontoTest`, cada arquivo
rodado sozinho; o resto é a cascata do `EntityManager is closed`).

### D364 — Dois dias de retrato de royalties ficaram zerados sem alarme, e R$ 7.438,12 de royalty nunca foram faturados — REGISTRADO em 03/10/2026 — PARCIAL em 07/11/2026 — PREPARADO em 07/11/2026

Achado pela especificação dos Royalties (`docs/f3-relatorios/spec-royalties.md`, seção 4, débito 1; a
consulta V12 do desenho dela).

- O retrato diário ficou zerado em 05/09/2025 (30 unidades, base de R$ 90.906,69) e em 10/01/2026 (27
  unidades, R$ 85.572,30): R$ 7.438,12 de royalty, pela alíquota de hoje e antes do abatimento, que
  nenhuma fatura cobrou.
- **Nada confere o retrato do dia.** A rota da rotina respondia `sucesso: true` com o token errado; desde
  o D4 (03/10/2026) responde 401 -- mas o crontab de produção não lê a resposta (o D331), e um retrato que
  roda e grava zero continua sem alarme nenhum.
- A tela nova do relatório os mostra (`retratoZerado`, na fatia R1). Cobrar ou não os dois dias é decisão
  de cobrança, do dono -- o ponto P-R3 da especificação --; o relatório só mostra, e nenhuma rotina de
  cobrança roda.

**O que falta, sem decisão:** uma conferência do retrato que avise quando a base do dia vier zerada.

**O aviso entrou em 07/11/2026 (a fatia 105, ROYALTIES-E-TRAVAS-DO-CRON), e o débito fica PARCIAL** até a linha do crontab
o levar ao log do sistema. No fim de `FranquiaService::royaltiesAtualizar` a rotina faz **duas contas**:

- a base de **ontem** — o dia que a execução fecha; a de hoje, às 05:25, é zero quase sempre e é refeita amanhã — somou zero
  com alguma unidade ativa (`RegraDosRoyalties::avisoDoRetratoZerado`): "AVISO: o retrato de royalties de DD/MM/AAAA somou
  base zero com N unidades ativas…". Um feriado com a rede inteira fechada também avisa — é para conferir, e não um erro;
- um dos **sete dias antes de ontem** ficou, no retrato **gravado** (`RoyaltiesRepository::retratoDosDias`), com base zero e
  **sem a rodada do dia seguinte para refazê-lo** — a última escrita das linhas é do próprio dia —, ou sem linha nenhuma
  (`RegraDosRoyalties::avisoDosDiasQueFicaramZerados`): "AVISO: o retrato de royalties de DD/MM/AAAA ficou com base zero sem a
  rodada do dia seguinte para refazê-lo…".

Havendo aviso, a linha vai ao log das rotinas (`data/logs/log_scripts.txt`, "relatoriosRoyaltiesAtualizar - AVISO: …") e a
rota `/api/cronjobs/relatoriosRoyaltiesAtualizar` responde `{"sucesso": true, "aviso": "…"}`. O retrato é gravado como antes:
o aviso não para nada.

**A segunda conta veio da verificação** (a correção da fatia 105): a primeira versão só olhava ontem, e **não teria avisado
em nenhum dos dois incidentes deste débito**. Lido na cópia (só `SELECT`): as linhas de 05/09/2025 têm base zero e foram
criadas e alteradas às 05:00:54 do **próprio** dia, e as de 06/09/2025 só nasceram em 07/09; as de 10/01/2026, às 05:25:15
do próprio dia, e as de 11/01 só em 12/01. Nos dois, o zero é o que a rodada da madrugada gravou para **hoje**, e a do dia
seguinte — a única que o refaria como "ontem" — não rodou: a de 07/09 conferia 06/09 (com venda) e nunca mais via o 05/09.
Medido de 01/01/2025 a 16/08/2026, os dias com base zero e sem a rodada seguinte são **só** os dois do débito e o 16/08/2026
(o dia em que a cópia foi tirada); depois dele, só as rodadas à mão da cópia local. Os feriados zerados (01/01/2025,
25/12/2025, 01/01/2026) foram refeitos no dia seguinte e não entram na segunda conta — deles a primeira avisa uma vez. O dia
não refeito **insiste** no aviso enquanto estiver na janela (sete dias), e duas rodadas puladas seguidas também aparecem (o
dia sem linha nenhuma).

**A suíte não escreve mais no log da cópia** (a correção da fatia 105): a rotina rodada pelos casos, num dia sem venda,
avisava, e a linha ia ao `data/logs/log_scripts.txt` do checkout montado — 44 linhas "AVISO" falsas na manhã de 07/11/2026,
justamente a linha que este débito quer que alguém leia. A escrita passou a um método próprio
(`FranquiaService::escreverNoLogDasRotinas`), e os casos que rodam a rotina até o fim (`RoyaltiesTest`,
`D367RoyaltiesDaUnidadeInativaTest`) usam a apuração de verdade com a linha guardada (o trait `ApuracaoQueGuardaOLog`); o
método de verdade é provado num diretório de trabalho descartável. As 44 linhas de antes ficam no log da cópia (fora do git):
são do teste, com dias de 2001 e de 06/11/2026.

**O que falta:** a linha do crontab ler o `aviso` da resposta e mandá-lo ao log do sistema (a resposta 128 (b) do dono, "sem
e-mail, o aviso vai ao log do sistema"): é o item 4 de "O que falta" da etapa 1 do corte, no **D331** (a fatia 113). Até lá o
aviso fica no log das rotinas e na resposta, que o crontab de hoje descarta. **Cobrar ou não os dois dias** continua sendo o
P-R3, do dono, fora deste débito.

**Provado** (nos dois bancos, em série): a regra pura (`RegrasDosRoyaltiesTest::testOAvisoDoRetratoZerado`: o dia, a contagem
das ativas, o singular, o meio centavo, a base da inativa na soma, a rede sem ativa; e
`testOsDiasQueARodadaSeguinteNaoRefez`: o 05/09/2025 não refeito avisa, o feriado refeito não, o não refeito com base não, o
dia sem linha avisa, o instante do PostgreSQL com o fuso, o plural e o singular); a rotina num dia de 2001 sem venda
(`RoyaltiesTest`: o aviso com o dia de ontem e com os sete dias sem linha no serviço, e a mesma frase na linha guardada do
log, e o retrato gravado); a forma do incidente deslocada para hoje (`testODiaQueARodadaSeguinteNaoRefezAvisa`: a véspera
gravada zerada na madrugada dela, a rodada de hoje pulada, ontem com venda — avisa a véspera, e só ela); o par com venda
ontem e a véspera zerada mas refeita (nenhum aviso); a desistência pela trava que apaga o aviso da execução anterior
(`testADesistenciaPelaTravaApagaOAvisoAnterior`); a linha de verdade no log das rotinas, num diretório descartável; e a rota
com um serviço de mentira (com e sem aviso, e a trava detida). A rotina rodou só no banco da suite,
dentro da transação do caso, e o dia passou a ser argumento (`$hoje`, como na das faturas). **Mutantes**, vermelhos: o aviso
arrancado, a linha do log arrancada, o aviso calculado no dia de hoje em vez de ontem, a rota sem o `aviso`, e a regra sem a
pergunta das ativas.
Os da correção, também vermelhos nos dois bancos e devolvidos pelo sha1: a segunda conta tirada do aviso (o caso de 2001 e o
do incidente); a regra sem a pergunta "foi refeito?" (a regra e o par do feriado refeito); a regra sem o dia sem linha (a
regra e o caso de 2001); a janela sem a véspera (o caso do incidente); o aviso sem voltar a nulo no começo (a desistência
pela trava); a chamada da linha do log arrancada (três casos); o corpo de `escreverNoLogDasRotinas` vazio (a linha de
verdade, no MySQL); e o `retratoDosDias` lendo só a criação, sem a alteração (o par do feriado refeito).

**A linha do crontab, escrita em 07/11/2026 (a fatia 113; o D331) — PREPARADO.** No crontab novo do passo 9 do
procedimento, a linha da apuração (as 05:25) guarda a resposta e, quando ela traz `aviso`, manda a resposta inteira ao log
do sistema com a marca `rotinas` (`R=$(curl ...) || logger -t rotinas "falhou (curl $?): ..."; case $R in *aviso*)
logger -t rotinas "/api/cronjobs/relatoriosRoyaltiesAtualizar: $R" ;; esac`), e a conferência do segundo bloco só instala
com uma linha dos royalties nessa forma. Ensaiado no `scripts/ensaiar-crontab-do-passo-9.sh`: a resposta com `aviso` vai ao
log, sem `aviso` nada, e o tempo esgotado escreve só a falha. **O que resta:** a janela da etapa 1, que instala o crontab
novo; e o P-R3 (cobrar ou não os dois dias), do dono, fora deste débito.

### D365 — A guarda da retirada do PDV antigo pede o privilégio do catálogo, e não o do PDV: 9 operadores que fizeram 80% das retiradas de julho seriam recusados — REGISTRADO em 03/10/2026 — CORRIGIDO em 11/10/2026

Achado pela especificação da Retirada de produtos (`docs/f3-relatorios/spec-retirada.md`, seção 4, D-r1).

- A retirada confere o `getAcesso()` do controller de produtos
  (`module/ISConfiguracao/src/Controller/ProdutosController.php:412`), isto é,
  `configuracoes/produtos/escrever` -- a guarda posta pelo D152 em 09/09/2026 --, e não o privilégio do
  PDV, contrariando o D138.
- **Medido** na cópia, com os privilégios de 16/08: 101 das 126 retiradas de julho de 2026 foram lançadas
  por 9 pessoas do grupo 2 sem o catálogo. Com o código de hoje, elas seriam recusadas -- e com o aviso
  em branco, porque a recusa sai com mensagem vazia (o D-r2 da especificação).
- A produção está congelada desde 23/08, então o código do D152 não deve estar lá, e a regressão entra
  no corte. Se estiver (não foi lido), o "uso interno" de produção está parado desde 09/09.

**O conserto** (a F2 da Retirada, sem decisão): a guarda do PDV, e a recusa com frase. A decisão 9 do
dono (01/10/2026) leva o uso interno para a comanda nova, com o privilégio do PDV.

#### CORRIGIDO em 11/10/2026 -- o item RESPOSTAS-SEM-RODADA

**O conserto** (a guarda e a frase da parte do PDV antigo da F2 da Retirada -- o quarto item dela, a
conferência de quem pediu, ficou de fora e é o **D468**, pela verificação abaixo; a decisão 9 o sustenta): as duas guardas da
retirada do PDV antigo pedem o privilégio do PDV, `vendas/nova/escrever`
(`$this->getAcessoDe('vendas', 'nova')`, o molde do `autoCompliteAction` ao lado), e não mais o do
catálogo. São `ProdutosController::retiradaAction`, a do modal "Retirada de produtos" das duas telas do
PDV antigo (`novo.phtml` e `comanda.phtml`, ambas guardadas por `vendas/nova`), e a irmã
`retiradaPdvAction`, que fica com a mesma guarda e continua morta: sem chamador, e chamando
`ProdutoService::retiradaPdv`, que não existe (o D-r5 da especificação; removê-la segue com o dono, D152).
**A recusa tem frase:** sem POST ou sem o privilégio, as duas respondem
`{sucesso: false, mensagem: "Sem permissão para retirar produto do estoque."}`
(`ProdutosController::RETIRADA_SEM_PERMISSAO`), e não mais a mensagem vazia que o PDV mostrava como aviso
em branco. Para quem usa: quem tem o PDV e não tem o catálogo -- os 9 operadores de julho -- segue
retirando quando o código de hoje for ao ar; quem tem só o catálogo deixa de retirar por esta rota, mas
pela tela não perde nada, porque sem o PDV as duas telas do modal já não abriam para ele; e a recusa diz
por quê.

**Provado** em `tests/Caracterizacao/D365RetiradaPeloPrivilegioDoPdvTest.php`, 6 casos despachados em
processo (`ControllerEmProcesso`, com uma sessão dupla que responde por módulo, funcionalidade e
privilégio -- a do arnês responde o mesmo a todos, e não separaria o catálogo do PDV): com o PDV e sem o
catálogo, a retirada passa, o saldo baixa de 5 para 3 e a linha nasce com quem pediu e quem lançou; só
com o catálogo, sem privilégio nenhum e por GET, a recusa tem a frase e nada é gravado; e a irmã morta
recusa com a frase sem o PDV e, com ele, passa da guarda (a recusa que sai é a dos campos, antes do
método que não existe). **Os 6 vermelhos antes do conserto**, no MySQL, cada um pelo motivo do registro.
Depois, com os vizinhos que despacham o mesmo controller (`EstoqueSobTravaTest`, `D201`, `D199`, `D202`,
`RetiradasNoRelatorioTest`): **132 casos, 461 asserções, 3 pulados de propósito (os do D199), nos dois
bancos**. **Mutantes**, um de cada vez no MySQL, restaurados e conferidos por sha1: `retiradaAction` de
volta a `getAcesso()` (2 vermelhos: o do PDV sem o catálogo e o do só catálogo); sem a frase nela (3); a
irmã de volta a `getAcesso()` (2); sem a frase nela (1); a guarda sem o `isPost()` (1, o do GET); e a
constante vazia (4). **Portões** verdes (saída 0): `conferir-guarda-do-erp`, `conferir-recorte-por-unidade`,
`conferir-guarda-do-caixa`, `conferir-acl-da-api`, `conferir-acl-como-booleano`,
`conferir-autorizacao-privilegiada`, `conferir-barreira-de-origem`, `conferir-rotas-sem-login`,
`conferir-remocao-por-post`, `conferir-relogio-nos-testes`, `conferir-cobertura-por-par`,
`conferir-injecao-dql` e `conferir-bytes-de-controle`.

**Não provado:** no navegador (o aviso do PDV com a frase); e a medida dos 9 operadores não foi refeita
-- é a de 03/10, com os privilégios de 16/08. **Fica fora deste débito** o resto da F2 da especificação: a
retirada na comanda nova (a ação da API, a tela e a regra de quem pediu), e a irmã com o mesmo `else` no
nulo (o D-r3 e o D-r5).

#### A verificação do mesmo dia (11/10/2026)

Quatro achados, todos conferidos no código antes de mexer:

- **O registro dizia "a parte do PDV antigo da F2", e faltava um dos quatro itens dela.** A especificação
  (`docs/f3-relatorios/spec-retirada.md:927-931`) põe em `retiradaAction` a guarda, a frase, o produto nulo
  (feito no D387) e o solicitante, pela `RegraDaRetiradaDeEstoque::solicitanteAceito` -- o D-r4, que a seção
  4 dá como "CORRIGIDO na F2, nas duas entradas" e que nunca ganhou número. O conserto não o fez, e com este
  débito fechado nenhum outro o guardava. **Ganhou o D468.** O parágrafo "O conserto" acima foi corrigido.
- **O docblock novo enganava o portão da guarda.** O `conferir-guarda-do-erp.php` corta o corpo de cada ação
  da linha `function` até a seguinte, e por isso levava o docblock da ação de baixo; o da `retiradaAction`
  citava a guarda antiga pelo nome do método, e caía na fatia do `autoCompliteAction`. Arrancada a guarda
  DESTE, o portão do HEAD ficava verde (rodado: saída 0), e só o literal no comentário o segurava. Agora o
  comentário diz "a do catálogo", e o portão lê o código sem os comentários (`token_get_all`), como os
  irmãos que já o faziam: com isso saiu da lista a `VisagismoController::processarImagemAction`, um método
  comentado que ele contava como ação (eram 296 ações e 34 sem guarda; são 295 e 33, e nenhuma ação de
  verdade mudou de lado). **Mutantes, no portão:** sem a guarda do `autoCompliteAction`, saída 1 nos dois
  portões; sem a guarda e com o literal de volta no docblock, saída 1 no novo e **0 no do HEAD**.
- **O ramo sem POST da irmã morta não tinha caso:** os dois dela eram POST, e arrancar o `isPost()` só dela
  deixava os seis verdes. Ganhou o `testARetiradaMortaPorGetERecusadaComAFrase` (o GET de quem tem o PDV
  recebe a frase); são **7 casos**.
- **O vermelho antes do conserto e os mutantes só tinham rodado no MySQL.** Rodados agora nos dois bancos,
  com os 7 casos: o controller do HEAD inteiro, **7 de 7 vermelhos no MySQL e no PostgreSQL**; e os mutantes,
  um de cada vez e restaurados pelo sha1, **com os mesmos vermelhos nos dois bancos**: `retiradaAction` de
  volta a `getAcesso()` (2), sem a frase nela (3), a irmã de volta a `getAcesso()` (2), sem a frase nela (2,
  com o caso novo), a guarda sem o `isPost()` (1), a constante vazia (5, com o caso novo) e o novo, a irmã
  sem o `isPost()` (1, o caso novo). Os números de antes, no parágrafo "Provado", eram de 6 casos.

Depois, em série e iguais nos dois bancos: os 7 casos com os vizinhos (`EstoqueSobTravaTest`, `D201`, `D199`,
`D202`, `RetiradasNoRelatorioTest`, num `--filter` só; o do parágrafo "Provado" não ficou escrito, e o deste
pega oito casos a mais que os 132 + 1), **141 casos, 479 asserções, 3 pulados de propósito**; e os arquivos dos comentários do D31 e do D428 com os que
despacham o mesmo código (`LoginSemCaixaTest`, `RegraDoUsuarioTest`, `SenhaEAcessoTest`,
`D204EdicaoDeUsuarioNoErpTest`, `UsuarioHorarioVigenciaTest`, `D428NpsDaListaDeVendasTest`,
`ListagemDeVendasTest`), **83 casos, 423 asserções**.

### D366 — O faturamento da unidade corrigido no ERP nunca chega ao Asaas: o cliente de cobrança é criado uma vez, e o `clienteEditar` não tem chamador — REGISTRADO em 03/10/2026 — DECIDIDO em 13/10/2026

Achado pelo desenho de Unidades (`docs/f3-telas-restantes/tela-unidades.md`, U14, na seção "Registrar,
sem conserto agora"), e reconferido no código em 03/10/2026 -- depois do `fda2952`, que andou as linhas.

- O cliente de cobrança no Asaas nasce na primeira fatura da unidade: `gerarFatura` só chama
  `clienteNovo` quando a unidade não tem `asaas_id`
  (`module/ISRelatorio/src/Service/FranquiaService.php:262-266`). Depois disso, nada manda ao Asaas o
  faturamento editado -- nome, documento, e-mail, endereço --: `Asaas::clienteEditar`
  (`module/ISBase/src/Util/Asaas.php:80`) não tem chamador em `module/`, `scripts/` nem `tests/`.
- **O efeito:** quem corrige o faturamento no ERP vê a correção na tela, e o boleto continua saindo com
  os dados de quando a primeira fatura saiu.
- **Medido** (consultas R9 e R12 do desenho): nenhuma unidade ativa está sem documento; são 890
  faturas, com vencimentos de 29/04/2024 a 29/08/2026. Quantas unidades têm cliente no Asaas a cópia
  não diz: a anonimização apaga o `asaas_id` (`scripts/anonimizar.sql.modelo:135`).

**O conserto espera o dono:** é a P4 do desenho ("o faturamento editado deve valer para a cobrança?"),
que o consolidado deixou para depois com a recomendação "editável, com a frase 'o boleto continua com
os dados da primeira fatura'" (`docs/f3-telas-restantes/telas-consolidado.md`, seção 1). Mandar a
edição ao Asaas pede a autorização dele para o ambiente de testes do provedor; é a F5 de Unidades.

**Decidido em 11/10/2026** (a pergunta 133 do lote, a P4 de Unidades): a opção (a), **"Editável, com o aviso"** -- *"O
boleto continua com os dados de quando a primeira fatura saiu."* Nada vai ao Asaas: o `clienteEditar` segue sem chamador,
e a (b) -- gravar também atualiza o cliente no Asaas -- espera a conta de testes do Asaas, que só o dono abre.

**Feito em 13/10/2026** (o item UNIDADES-GRUPOS-ROYALTIES): a tela antiga da unidade, que já editava o faturamento, mostra
o aviso logo abaixo de "Dados para faturamento e contrato" (`module/ISConfiguracao/view/is-configuracao/unidades/editar.phtml`);
o `PUT /adm/api/unidades/:id` já gravava o faturamento, e continua. A tela nova de Unidades leva a mesma frase quando vier.
Provado no `D368VigenciaDaTaxaDeRoyaltyTest` (o template lido sem os comentários), nos dois bancos; mutante: a frase
virada comentário deixa o caso vermelho.

*Atualizado em 16/10/2026 (a fatia UNIDADES-ESCRITA):* a tela nova de Unidades edita o faturamento, na seção dele (as dez
chaves; o CPF do sócio administrador continua só na tela antiga, o D369), com a mesma frase no formulário e no aviso de
salvo: "O boleto continua com os dados de quando a primeira fatura saiu." Nada vai ao Asaas, como a (a) decidiu. A marca
não muda: o `clienteEditar` segue sem chamador, e a (b) espera a conta de testes do Asaas, que só o dono abre. Provado em
`frontend/src/telas/UnidadesLigado.escrita.teste.tsx` (o corpo do `PUT` só com a chave que mudou, e as frases).

### D367 — O relatório de royalties esconde a unidade inativa mesmo pedida pelo id, e a fatura do dia 16 pula a que for desativada antes dele — REGISTRADO em 03/10/2026 — CORRIGIDO em 13/10/2026

Achado pelo desenho de Unidades (`docs/f3-telas-restantes/tela-unidades.md`, U16), e reconferido no
código em 03/10/2026. É o lado do dinheiro do D351 (desativar não desliga nada): o consolidado o põe na
quarta opção da P6, "E a cobrança continua".

- **A lista:** `selecionarUnidadesRelatorioFranquias` filtra `u.status = true` também quando a unidade
  vem pelo id (`module/ISConfiguracao/src/Entity/UnidadeRepository.php:87-97`). Por ela passam o
  cálculo dos royalties e o do cache (`module/ISRelatorio/src/Service/FranquiaService.php:158` e
  `:226`), o retrato das 05:25 (`royaltiesAtualizar`, `:69`) e a fatura do dia 16
  (`relatoriosRoyaltiesFaturasGerar`, `:113-118`).
- **O efeito:** desativada, a unidade some do relatório com a história inteira; e a desativada antes do
  dia 16 não é cobrada pelo ciclo em que trabalhou.
- **Medido** (R12 e R13 do desenho): 10 unidades inativas têm 5.323 dias de retrato, e 8 têm 121
  faturas, que o relatório não mostra; 0 inativas com dia de retrato depois do último ciclo faturado --
  desativar no meio do ciclo não aconteceu na cópia. É risco, ainda não dano.

**O conserto espera o dono:** é a P6 do consolidado (o que a unidade desativada desliga), e cabe na
decisão 13 dos relatórios, as faturas "dos dois lados" (seção 6 de `docs/f3-relatorios.md`). Quem
conserta é a F5 de Unidades.

#### O conserto — 13/10/2026 (a resposta 132 do dono)

A pergunta 132 do lote veio com a opção (b), que inclui a (a): *"a fatura cobra o ciclo trabalhado e o relatório mostra
as inativas"* (ver o **D351**). O item UNIDADES-GRUPOS-ROYALTIES:

- `UnidadeRepository::selecionarUnidadesRelatorioFranquias` ganhou `$comInativas` -- sem o filtro de situação, também pelo
  id -- e a linha diz a situação (`ativa`). Pedem assim os royalties: o relatório, o retrato das 05:25 e a fatura do dia
  16. As outras rotinas de `CronJobService` e o relatório de potenciais continuam só com as ativas.
- **O relatório**, nos dois caminhos (o do retrato, de 2024 em diante, e o recalculado das vendas), mostra a inativa com
  movimento no período -- faturamento, royalty ou crédito do programa -- e a pedida pelo id; a sem movimento fica de fora
  da lista da rede, para as 28 fechadas não entrarem em toda consulta com uma linha de zeros. A lista suspensa do relatório
  oferece as inativas, com "(inativa)" no nome (`popularCombobox(..., true)`); as outras listas, não.
- **O retrato das 05:25** guarda a inativa com movimento no dia, ou com a linha do dia já gravada
  (`RegraDosRoyalties::entraNoRetrato`): desativada hoje à tarde, o retrato de amanhã refaz o dia de hoje com as vendas
  dela, e a fatura do dia 16 as cobra. Sem movimento e sem linha, não nasce linha.
- **A fatura do dia 16** percorre as unidades com as inativas, e quem decide é o retrato do ciclo: sem `ret_total`, não há
  fatura, como para a ativa parada.

**Provado** em `tests/Caracterizacao/D367RoyaltiesDaUnidadeInativaTest.php` (6 casos, nos dois bancos): o relatório pelo
retrato mostra a inativa que trabalhou e não a parada, e a parada pedida pelo id aparece; o recalculado mostra a inativa
com vendas; a lista oferece a inativa, e o combo da tela do relatório (o `royaltiesAction` despachado em processo) também; a regra do retrato; e a fatura do dia 16 -- com o dia passado à rotina e o Asaas
dublado, como no D44 -- nasce para a inativa que trabalhou no ciclo de 16/02 a 15/03/2026 (300,00, com o vencimento de
29/03/2026 e o dia 16/03 fora dela) e é emitida pelo dublê, e a parada não ganha fatura. **Mutantes**, todos vermelhos: a
consulta sem as inativas, o relatório sem tirar a parada, a fatura sem as inativas, o retrato sem a linha que já existe,
e a lista do relatório sem as inativas.

**O retrato das 05:25 na prova (a revisão, 13/10/2026).** Na primeira rodada ele não rodava em caso novo, e a chamada da
regra dentro dele era só lida: a verificação mostrou que a linha do retrato -- o centro do "a fatura cobra o ciclo
trabalhado" -- podia virar "só a ativa", ou sair, sem nenhum caso vermelho, e que o `RoyaltiesTest` já chamava
`royaltiesAtualizar()` em processo, no banco da suíte e sem Asaas (a rotina não fala com ele). O caso novo `testORetratoDas0525UsaARegraComAsInativasDeHoje`
a chama do mesmo jeito, com três inativas no dia de hoje: a que vendeu ganha a linha do dia com o royalty da venda (89,90 e
8,99), a que já tinha a linha a vê refeita (zerada, uma só), e a parada não ganha linha, nem hoje nem ontem. Nada roda
fora da suíte, e o banco é o dela, na transação que o caso desfaz. **Mutantes:** o retrato só com a ativa e o retrato sem
a regra, os dois vermelhos.

### D368 — A taxa de royalty não tem vigência nem autor: o retrato recalcula ontem e hoje com a taxa nova, e 9 de 38 unidades já mudaram de taxa — REGISTRADO em 03/10/2026 — PREPARADO em 13/10/2026

Achado pelo desenho de Unidades (`docs/f3-telas-restantes/tela-unidades.md`, U19), e reconferido no
código em 03/10/2026.

- O cálculo de um período lê as quatro taxas do cadastro de hoje (`royaltiesCalcular`,
  `module/ISRelatorio/src/Service/FranquiaService.php:154-220`, a partir de `royltes_ser` e das outras
  três), e o retrato das 05:25 refaz ontem e hoje com elas (`royaltiesAtualizar`, `:55-69`). Nada guarda
  quando a taxa mudou nem quem a mudou: o formulário grava por cima.
- **O efeito:** o aumento dado no dia 15 reescreve o retrato de ontem, e a quinzena já paga não tem
  como explicar a diferença.
- **Medido** (R14 do desenho): das 38 unidades com retrato, 9 tiveram mais de uma taxa de serviço desde
  01/2024, e uma delas mais de duas (retrato de 01/01/2024 a 25/09/2026, 32.688 linhas).

**O conserto espera o dono:** é a P9 do consolidado (a vigência da taxa e a do percentual de
cota-parte), que pede a P8 (tabela nova nas cópias locais). A recomendação é a vigência gravada, no
molde da F4.1 (a F5 de Unidades; a `VigenciaDaAliquota` de `docs/f3-relatorios/spec-royalties.md`).
Até a resposta, a tela nova mostra as taxas só para leitura, e a edição fica na antiga.

*Atualizado em 12/10/2026 (o item ESQUEMA-NAS-COPIAS):* as duas perguntas vieram. A P9 do consolidado é a
pergunta 134 do lote, respondida em 11/10/2026 com a opção (b), "Só a do royalty": a vigência gravada vale
para a taxa de royalty, e a cota-parte segue recalculando com o percentual de hoje. A P8 é a pergunta 83,
respondida em 12/10/2026 com a opção (a): a tabela nova da vigência entra nas duas cópias locais com a sua
migração no passo 0, sem autorização à parte. **O conserto não foi feito**: vai na rodada da 134, e nada foi
aplicado por este item.

#### O conserto — 13/10/2026 (a resposta 134 do dono e a 83)

A taxa de royalty passou a ter vigência (a opção (b) da 134, "Só a do royalty"; a cota-parte segue recalculando com o
percentual de hoje, e não passa por aqui). O item UNIDADES-GRUPOS-ROYALTIES:

- **A tabela `royalties_aliquotas`** (`scripts/migracao-royalties-aliquotas.sql`, o `mysql.sql` e o `postgres/schema.sql`
  do gerador, com o inquilino e o RLS; o roteiro da carga regerado): a unidade, o `ciclo_inicial` -- `AAAA-MM` do mês em
  que começa o primeiro ciclo (o da fatura, de 16 a 15) em que a linha vale, e **nulo para "desde sempre"** --, as quatro
  taxas, o autor (`gravado_por` e `em_nome_de`, o `CrudController::autorDaEscrita`, como a vigência do horário) e
  `data_criacao`. Só ganha linhas; duas no mesmo ciclo valem pela de maior `id`. **Nasce vazia, sem semente:** sem linha da
  unidade, quem lê usa o cadastro, como antes; uma semente diria "desde o dia da migração", e esse não é o dia em que a
  taxa começou. No passo 0 do corte (a 12ª), nas duas cópias locais (pela 83, a opção (a)) e no laço do ensaio geral.
- **Quem grava** é `UnidadeService::update`, a porta das duas telas que mudam a taxa (o formulário antigo e o `PUT
  /adm/api/unidades/:id`), as duas mandando o autor: lê as taxas de antes com a linha travada, grava a unidade e chama
  `ISRelatorio\Service\VigenciaDaAliquota::registrar` na mesma transação. A taxa nova vale do **ciclo seguinte** ao de hoje
  (mudar em 13/10/2026 vale de 16/10); a primeira mudança da unidade grava antes a linha "desde sempre" com as taxas de
  antes, sem autor. Gravar a unidade sem mudar a taxa não grava nada.
- **Quem lê** é a apuração diária (`FranquiaService::apurarRoyalties`, a do retrato das 05:25): cada dia com a taxa do
  ciclo dele (`RegraDosRoyalties::aliquotaDoCiclo`), e a do cadastro só sem vigência nenhuma. O aumento dado no dia 15 não
  reescreve mais o dia 14 nem o 15; a fatura do dia 16 soma o retrato. O caminho de período do relatório de antes de 2024
  continua com o cadastro.
- **A tela antiga diz** "A taxa alterada vale a partir do próximo dia 16: o ciclo em andamento e a fatura dele continuam
  com a taxa de antes." -- e a tela nova de Unidades pode deixar de mostrar as taxas só em leitura.
- **Sem a tabela no banco** -- a base do teste do BVIP até a próxima cópia exportada, ou a produção se o passo 0 a
  esquecer -- nada quebra: o código pergunta (`ColunaNoBanco`), grava a taxa como antes e o retrato lê o cadastro. A frase
  da tela antiga só aparece com a tabela no banco (a revisão, abaixo): sem ela, a taxa vale no mesmo dia, e a tela não
  promete o ciclo seguinte.

**Provado** em `tests/Caracterizacao/D368VigenciaDaTaxaDeRoyaltyTest.php` (7 casos, nos dois bancos): o ciclo do dia e o
seguinte; a vigência do ciclo (a "desde sempre" antes de todas, o desempate pelo maior id, a ordem da lista indiferente);
mudar pelo serviço grava a de antes e a nova, com o autor e a data; gravar sem mudar não grava, e voltar atrás grava
outra; o `PUT` da API grava com o autor da sessão; **mudar a taxa hoje não muda o dia de hoje** (89,90 de serviço: 8,99
com a taxa de antes) e o primeiro dia do ciclo seguinte sai com a nova (17,98); e as frases da tela antiga. O
`ColunasForaDoMapeamentoTest` passou a exigir `royalties_aliquotas.ciclo_inicial`. **Mutantes**, todos vermelhos: o
desempate pelo menor id, a nova valendo do ciclo corrente, a primeira mudança sem a linha "desde sempre", o serviço sem
gravar a vigência, a API sem o autor e a apuração com a taxa do cadastro.

**A revisão (13/10/2026)** achou quatro lacunas, e as quatro foram fechadas:

- **Uma troca de colunas passava em todos os casos.** As fixtures têm 5/5/5 nas taxas de produto e de Lava VIP, e os casos
  só mudavam o serviço: trocar a barbearia com o empório (na apuração, na regra ou na gravação), tirar o Lava VIP da
  apuração, ou fazer `RegraDosRoyalties::iguais` comparar só o serviço ficava verde. O caso
  `testCadaTaxaDoCicloVaiParaASuaColuna` muda as três sem mudar o serviço (10, 3, 4 e 7), vende um item de cada tipo hoje e
  no primeiro dia do ciclo seguinte, e confere a vigência coluna a coluna e os quatro royalties de cada dia.
- **O autor do formulário antigo não era medido** -- e é por ele que as taxas mudam hoje, sem tela nova de Unidades. O caso
  `testOFormularioAntigoGravaAVigenciaComQuemMudou` despacha o `editarAction` com o POST inteiro do formulário (o que o GET
  põe na tela e o token do CSRF): grava, redireciona, e a vigência nova leva o autor da sessão.
- **O "sem a tabela, nada quebra" não tinha caso.** `testSemATabelaNoBancoNadaQuebraEATaxaValeDoCadastro` finge o banco
  sem `royalties_aliquotas` (a `ColunaNoBanco`, no molde do `CupomSemAsColunasTest`), com uma linha na tabela que o código
  não pode ler: a leitura do retrato, o "antes" e a gravação -- esta chamada direto, porque pelo serviço o vazio do "antes"
  a esconde -- respondem cada uma sozinha; mudar a taxa grava o cadastro sem erro e sem vigência; e a apuração do dia sai
  com o cadastro, e a taxa nova vale no mesmo dia.
- **A frase da tela antiga aparecia também sem a tabela.** O `editarAction` passa `taxaValeDoCicloSeguinte` (a mesma
  pergunta à `ColunaNoBanco`), e a view só mostra a frase da taxa com ela; o aviso do boleto (o D366) não depende dela.
  `testAEdicaoDaUnidadeDizOQueGravarFaz` renderiza a view de verdade (o GET despachado e o `ViewRenderer`), com e sem a
  tabela, no lugar da leitura do template.

**Mutantes da revisão**, um por vez, no MySQL, todos vermelhos: a barbearia e o empório trocados na apuração, a apuração
sem o Lava VIP, `iguais` só pelo serviço, as colunas da regra trocadas, a gravação com as colunas trocadas, o formulário
antigo sem o autor, cada uma das três guardas da tabela arrancada, a view sem a condição e o controller sem a pergunta.

**Não provado:** o salvar pelo navegador não foi aberto (o formulário foi despachado em processo). Por isso PREPARADO: o
código e as duas cópias estão prontos, e a produção recebe a tabela no passo 0. O retrato das 05:25 roda em processo no
`RoyaltiesTest` e no caso novo do D367; a vigência dele é a do caminho diário, medida acima.

*Atualizado em 16/10/2026 (a fatia UNIDADES-LEITURA, a tela nova de Unidades em leitura):* o cadastro é a taxa NOVA, e uma
tela que o mostrasse diria "serviço 3,5%" de um ciclo que a fatura cobra a 3%. O recurso da unidade ganhou
`royaltiesDoCiclo` (o dia 16 em que o ciclo começou e as quatro taxas que ele usa, pela `aliquotaDoCiclo` sobre as linhas da
unidade -- `VigenciaDaAliquota::linhasDaUnidade` --, e o cadastro quando ela não tem linha ou a tabela não existe), e a tela
nova mostra a taxa do ciclo, com "passa a 3,5% em 16/11/2026" na que muda. **Provado** no
`D368VigenciaDaTaxaDeRoyaltyTest` (o `GET` e o `PUT` antes e depois de mudar, e sem a tabela; os dois lados do dia 16), nos
dois bancos. A marca não muda: a edição das taxas na tela nova é a fatia 8.

*Atualizado em 16/10/2026 (a fatia 8, UNIDADES-ESCRITA):* a tela nova edita as taxas, na seção dos royalties, com os campos
do cadastro e a frase "A taxa alterada vale a partir de 16/MM/AAAA: o ciclo em andamento e a fatura dele continuam com a taxa
de antes." -- SÓ COM A VIGÊNCIA NO BANCO, como a tela antiga (a revisão de 13/10/2026, acima). O recurso ganhou
`royaltiesDoCiclo.mudancaValeDe` (`UnidadeJson::mudancaDaTaxaValeDe`): o dia 16 do ciclo seguinte com a tabela, e `null`
sem ela (a base do teste do BVIP), onde a taxa nova entra no retrato no mesmo dia e a tela não promete nada. O `PUT` da seção
leva só a taxa que mudou ("6,00" é o 6% de antes), e a gravação da vigência, com o autor da sessão, é a de sempre
(`UnidadeService::update`). **Provado** no `D368VigenciaDaTaxaDeRoyaltyTest::testORecursoDizATaxaDoCicloEmAndamento` (com e
sem a tabela), no `UnidadeEscritaApiTest::testORecursoDizDesdeQuandoValeATaxaMudada`, nos dois bancos, e nos casos do front
(`UnidadesLigado.escrita.teste.tsx`). A marca não muda: o salvar das taxas pelo navegador ainda não foi aberto -- é do ciclo.

### D369 — O CPF do sócio administrador é obrigatório no cadastro da franquia e nada além da tela o lê; o sócio desativado guarda CPF, nascimento e endereço — REGISTRADO em 03/10/2026

Achado pelo desenho de Unidades (`docs/f3-telas-restantes/tela-unidades.md`, U17, "LGPD, débito
futuro, só registrar"), e reconferido no código em 03/10/2026.

- O CPF do sócio administrador é obrigatório no faturamento da unidade
  (`module/ISConfiguracao/src/Form/UnidadeFilter.php:350-358`, `faturamento_socio_adm_cpf`), e nada
  além da tela o lê: o grep acha o formulário, o filtro, a entidade e as duas views. A cobrança manda
  ao Asaas o documento do faturamento, e não este (`module/ISBase/src/Util/Asaas.php:179-187`); a API
  nova já não o devolve (`module/ISConfiguracao/src/Api/UnidadeJson.php:234`).
- Remover um sócio só muda a situação (`module/ISConfiguracao/src/Service/UnidadeService.php:85-93`):
  CPF, nascimento e endereço ficam.
- **Medido** (Q4 do levantamento, citado no desenho): há 5 sócios reais, com CPF, nascimento e
  endereço, e 30 das 32 unidades ativas não têm sócio.

**O conserto** é da LGPD, que é débito futuro: deixar de exigir o dado que ninguém usa, e decidir o que
remover quer dizer. Nenhuma fatia das telas o faz; a tela nova de Unidades não pede nem mostra o CPF
(`tela-unidades.md`, "Decisões que tomei para seguir", item 5).

*Atualizado em 17/10/2026 (a verificação da fatia UNIDADES-ESCRITA):* a tela nova cadastra unidade, e pede o resto do que
o `UnidadeFilter` exige -- o DDI, a inauguração e o horário de segunda a sábado, além do que já pedia --, para a unidade
criada ali salvar na edição antiga, onde se ligam o WhatsApp e os sócios. O CPF continua de fora, e por isso a edição antiga
o pede ao primeiro salvar: a nota do cadastro e o aviso de cadastrada dizem isso ("que pede também o CPF do sócio
administrador ao salvar"). A marca não muda: exigir o CPF continua sendo do ERP.

### D370 — O PDV pode gravar dois cupons da mesma venda no mesmo sorteio, e nenhum dos dois bancos tem índice único que o impeça — REGISTRADO em 03/10/2026 — PARCIAL em 15/10/2026 — PARCIAL em 21/10/2026: a chave (sorteio, venda, numero) nas cópias locais; a produção, no passo 3

Achado pelo desenho de Sorteios (`docs/f3-telas-restantes/tela-sorteios.md`, DS-3), e reconferido no
código em 03/10/2026.

- `salvarSorteios` cria um cupom por sorteio ativo sem conferir se a venda já tem um nele
  (`module/ISVenda/src/Service/VendaPagamentoService.php:349-364`), e nenhum dos bancos tem índice
  único em (`sorteio`, `venda`): `mysql.sql:1523-1534`; `postgres/schema.sql:989-997`, com os índices
  simples de `:1603-1604`.
- **Medido** (consulta C4): 4 vendas têm dois cupons -- 1 no sorteio #1, 1 no #3 e 2 no #4 --, gravados
  com 5 s, 582 s, 635 s e 1.419 s de intervalo: o padrão do clique repetido do D321. O D316 fechou o
  clique repetido no PDV antigo; a corrida de dois cliques segue aberta (a pergunta 2 do D321).
- **O efeito:** a planilha dos participantes lista cada cupom, e dá chance dupla a essas vendas no
  sorteio feito fora do sistema. O sorteio interno conta cada venda uma vez só
  (`module/ISMarketing/src/Entity/SorteioVendaRepository.php:44-52`).

**O conserto:** o `salvarSorteios` confere antes de criar -- está na F3 de Sorteios, que espera as
perguntas do cadastro (P2, P3 e P5 do desenho). O índice único fica para a migração final, depois de
limpar os 4 pares; apagar linha não entra na autorização em bloco da P8 do consolidado.

#### A corrida fechou no finalizar — 15/10/2026, a resposta 85 (a) do dono (a fatia 5c)

Os quatro pares da cópia vieram do finalizar repetido (o padrão do D321), e o repetido não grava mais nada: a primeira
escrita do finalizar é o `UPDATE` condicional da venda que só um de dois pedidos vence (`VendaRepository::fecharParaOFinalizar`),
e `salvarSorteios` só roda no que venceu. Provado em `D321FinalizarUmaVezSoTest`, com um sorteio ativo e o cupom do
checkout aberto no cenário: o finalizar que vence grava o cupom do sorteio, o cupom e o ponto (o caso de controle confere
os três), e quem chega depois -- na comanda, na venda em andamento, e pelas duas portas na corrida -- não grava nenhum. Os
mutantes que fazem o finalizar recusado gravar o cupom do sorteio, ou o cupom, ficam vermelhos nos dois bancos. *Na
primeira versão o cenário não tinha sorteio ativo (os das fixtures estão finalizados) nem cupom aberto (o da fixture tem a
data limite no passado), e a conferência comparava zero com zero -- a verificação da fatia achou, 15/10/2026.* **O que
fica, na fatia 26 (Sorteios, cadastro):** a conferência de `salvarSorteios` antes de criar
(DS-3) e o índice único em (`sorteio`, `venda`) -- o índice pede limpar os quatro pares, e isso espera a Q13 (a limpeza nas
cópias) e a etapa 2 na produção (a 124 a).

#### A regra do banco, trocada — 21/10/2026, a fatia 26 (SORTEIOS-CADASTRO), pelo pedido do dono (o D561)

O dono pediu, nesta fatia, o cupom por valor e por serviço, e escolheu "trocar a regra do banco": a venda pode ter VÁRIOS
cupons no mesmo sorteio, numerados (`sorteios_vendas.numero`, 1, 2, 3...), e o repetido passa a ser o MESMO NÚMERO duas
vezes. Por isso a chave única não é mais (`sorteio`, `venda`), e sim (`sorteio`, `venda`, `numero`).

- **A conferência antes de criar (o DS-3):** `SorteioService::darCupom` dá só os números que a venda AINDA NÃO TEM no
  sorteio (`SorteioVendaRepository::numerosDaVenda`), sob a trava de leitura da linha do sorteio (o D560): a venda
  finalizada duas vezes não ganha o dobro, e a que já tem o 1 e o 2 de 3 ganha só o 3. Provado em
  `CuponsPelaRegraDoSorteioTest` (`testPorVendaUmCupom`, `testPorValorUmACadaX`, `testDaSoOQueFalta`) e em
  `SorteioSobTravaTest` (a venda que já tem cupom não ganha outro), nos dois bancos.
- **A limpeza e a chave:** `scripts/migracao-sorteios-vendas-unicos.sql`, no passo 3 da janela (a resposta 124 (a)) --
  o segundo cupom de cada par do mesmo número sai (fica o que o sorteio aponta como ganhador, ou o mais antigo, com a
  marca do par), a chave de antes (sorteio, venda) sai se existir, e a de três colunas entra. Ensaiada em
  `scripts/ensaiar-sql-do-passo-3.php`, num banco descartável com pares inventados: o par, o de três, o par cujo segundo é
  o ganhador, o par com a marca no segundo, os números diferentes da mesma venda e os cupons sem venda; a segunda vez não
  muda nada; a chave de antes sai e o cupom 2 entra. **Nas duas cópias locais em 21/10/2026** (a resposta 218 (a)): o
  implementador anterior desta fatia já tinha tirado os 4 pares e criado a chave (sorteio, venda) no MySQL e no
  PostgreSQL; ela foi trocada pela de três colunas, depois de a coluna `numero` entrar (o laço do passo 0,
  `migracao-sorteios-regra-do-cupom.sql`). No PostgreSQL, a chave que o gerador emite, com o inquilino na frente.
- **Na produção:** os 4 pares continuam até o passo 3; até lá o código conta cada cupom (venda e número) uma vez no
  sorteio, na planilha e nos números, e mostra o repetido à parte (`CuponsPelaRegraDoSorteioTest::testORepetidoContaUmaVez`,
  na tabela sem a chave). Por isso o PARCIAL: o que resta é o passo 3 da janela.

### D371 — O sorteio cadastrado depois do início não dá cupom às vendas já finalizadas da janela, sem aviso: 728 e 115 vendas ficaram de fora — REGISTRADO em 03/10/2026 — ANOTADO em 08/10/2026: Sorteios fica na ordem das telas, por decisão do dono — PARCIAL em 21/10/2026: a tela nova avisa; a regra do início espera a F3 — PARCIAL em 21/10/2026: o cadastro avisa antes de gravar; a regra do início foi ao lote — DECIDIDO em 21/10/2026 (246 c)

Achado pelo desenho de Sorteios (`docs/f3-telas-restantes/tela-sorteios.md`, DS-9), e reconferido no
código em 03/10/2026.

- O cupom só nasce quando a venda é finalizada (`module/ISVenda/src/Service/VendaPagamentoService.php:255`,
  que chama `salvarSorteios` com os sorteios ativos no dia), e o cadastro aceita início no passado: a
  `data_inicio` só tem `NotEmpty` (`module/ISMarketing/src/Form/SorteioFilter.php:38-48`). As vendas da
  janela finalizadas antes do cadastro ficam sem cupom, e a tela não avisa.
- **Medido** (consulta C9; é um teto, porque o programa considerado é o de hoje): o #1 perdeu as 728
  vendas de 01 a 04/10/2023, nenhuma com cupom; o #3, as 115 de 07/10/2024 feitas antes das 17:38. No
  #4, em 01/10/2025, 249 das 270 vendas do primeiro dia tiveram cupom.
- **Agora:** é o custo da opção "Sim, criar hoje" da P1 do consolidado, a única pergunta com prazo -- os
  quatro sorteios reais começaram entre 01 e 07/10. Um sorteio de 2026 cadastrado depois do início
  perde os dias anteriores; por isso a recomendação é criá-lo com o início no dia da criação.

**O conserto:** o aviso na tela entra na F1 de Sorteios, sem decisão; a regra espera a P5 do desenho (o
início antes do cadastro), que o consolidado deixou para depois com "não: o início é hoje ou depois", e
que a F3 aplica.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 1): a opção (c), "Sim, pela tela
nova", com a observação dele: *"Não. Espera a fatia F3 de Sorteios (2 a 3 h, depois das F0 a F2)."* O
cadastro do sorteio de Dia das Crianças de 2026 espera a F3 de Sorteios, e nada é criado na produção,
nem pela tela antiga. Com a F3, o início é o dia do cadastro ou depois (a P5 do desenho, que a F3
aplica), e nenhuma venda anterior fica sem cupom; o custo é o da campanha, porque os sorteios reais
começaram entre 01 e 07/10. O aviso na tela continua na F1 de Sorteios.

**Decidido pelo dono em 07/10/2026** (pergunta 36 da página do lote): a opção (c), "Não agora: Sorteios
fica na ordem das telas". As fatias F0 a F3 de Sorteios não serão construídas agora, nem antes do Dia
das Crianças: Sorteios segue na ordem das telas da Fase 3, e com ela o aviso na tela (a F1) e a regra do
início (a P5 do desenho, na F3). Pela pergunta 1, nada se cria na produção pela tela antiga. O defeito
fica de pé até lá, adiado por decisão do dono -- daí o ANOTADO no título.

**PARCIAL em 21/10/2026 (a fatia 24 da fila das telas que faltam, SORTEIOS-LEITURA; a F1 do desenho), na
vez de Sorteios na ordem das telas (a resposta 36 (c)).** O aviso entrou: o sorteio aberto na tela nova diz,
quando foi criado depois do começo do primeiro dia, "Criado 3 dias depois do início, em 04/10/2023 às
23:52: as vendas finalizadas de 01/10/2023 até esse momento não ganharam cupom." -- e, no próprio dia, "Criado
no próprio dia do início, às 17:38: as vendas finalizadas nesse dia antes disso não ganharam cupom." (o #3).
O fato vem do servidor (`RegraDoSorteio::criadoDepoisDoInicio` e `::diasDeAtraso`, pelo instante da criação
contra a meia-noite do início; a meia-noite exata não é "depois"), e a frase da tela
(`frontend/src/telas/situacaoDoSorteio.ts`, `avisoDaCriacaoTardia`). **O que resta é a regra:** o início no
dia do cadastro ou depois (a P5 do desenho), que a F3 aplica no cadastro novo, pela resposta 1 (c); até lá,
nada se cria pela tela antiga.

**PARCIAL de novo em 21/10/2026 (a fatia 26, SORTEIOS-CADASTRO; a F3).** O aviso entrou ANTES de gravar, nas três portas:
o formulário da tela nova, junto da data de início ("As vendas finalizadas antes deste cadastro não ganham cupom: com o
início antes de hoje, os dias até agora ficam de fora." -- a tela não sabe que dia é hoje, e diz sempre), e os dois
formulários da tela antiga (o novo, com a mensagem de sucesso dizendo a data quando o início é antes de hoje, e o editar).
**A regra do início NÃO entrou:** o início antes de hoje continua aceito, como hoje. A ordem desta fatia foi "fica como
hoje, pode, com o aviso, e pergunte" -- a P5 do desenho nunca foi ao dono --, e a pergunta foi ao lote ("Os sorteios --
o sorteio pode começar antes do dia do cadastro?"). **Atenção a uma discordância:** a tabela "O que ficou para depois" do
`telas-consolidado.md` (a linha da P5) dizia que a tela seguia com "não: o início é hoje ou depois", e o parágrafo acima
deste débito também; esta fatia seguiu a ordem dela, e a pergunta diz as duas coisas. O fim antes de hoje, sim, é recusado:
o sorteio nasceria encerrado.

**A resposta (21/10/2026, a 246 (c) da página): "Pode, com o aviso (como está)"** -- quem cadastra vê, antes de gravar, que as
vendas finalizadas antes do cadastro ficam de fora, e decide. A fatia 26 já faz isso, nas duas telas.

### D372 — Concluir um curso da VIP Class grava uma linha a cada envio aprovado: 191 conclusões repetidas, e nenhum índice único — REGISTRADO em 03/10/2026 — PARCIAL em 05/11/2026: a recusa sob a trava; o índice e a limpeza na fatia 85 — PARCIAL em 05/11/2026: o 409 da API; o índice e a limpeza na fatia 85 — PARCIAL em 05/11/2026: o relatório conta a primeira; o índice e a limpeza na fatia 85 — PREPARADO em 06/11/2026: o índice e a limpeza no passo 3 do corte; as cópias locais esperam a limpeza que a 213 (a) autoriza — PREPARADO em 06/11/2026: as duas cópias locais limpas e com a chave; resta a produção, no passo 3 do corte

Achado pelo desenho da VIP Class (`docs/f3-telas-restantes/tela-vipclass.md`, DV-2), e reconferido no
código em 03/10/2026.

- Cada envio aprovado do checkpoint grava uma conclusão nova
  (`module/ISCadastro/src/Service/VipClassEduService.php:33-42`), e não há índice único em (`vipclass`,
  `usuario`): `mysql.sql:2153-2164`; `postgres/schema.sql:1435-1443`. A tela antiga esconde o
  checkpoint de quem já concluiu (`module/ISCadastro/view/is-cadastro/vip-class-edu/aulas.phtml:41-48`),
  mas o servidor aceita o envio.
- **Medido** (M3 e M16 do desenho): 167 pares pessoa e curso com mais de uma conclusão, 191 linhas a
  mais; 132 delas a menos de 60 s da anterior, e 184 a menos de 10 minutos -- o duplo envio.
- **O efeito:** quem conta conclusões conta as repetidas, e o "quem fez" da VIP Class (a F2 do desenho)
  lê essa tabela.

**O conserto** (a F0 da VIP Class, sem decisão): o finalizar confere a conclusão que já existe e
responde 409 a quem já concluiu, sob trava. O índice único e a limpeza das 191 linhas ficam para o
corte (a F5 do desenho), pela regra da pergunta 3 (uma conclusão ou o histórico) e com a autorização do
dono para as cópias locais; apagar linha não entra na autorização em bloco da P8 do consolidado.

#### PARCIAL em 05/11/2026 -- a recusa sob a trava (a fatia 79, VIPCLASS-ALUNO-SERVIDOR; a F0 do desenho)

O envio do checkpoint (`VipClassEduService::checkpointFinalizar`) trava a linha da pessoa em `usuarios`
(`SELECT ... FOR UPDATE`) e rele a conclusao depois da trava (`VipClassCheckpointUsuarioRepository::concluiu`):
quem ja concluiu ouve "Você já concluiu este curso", volta a pagina do curso (onde esta o certificado), e nada e
gravado -- a resposta 213 (a) do dono, "concluir de novo e recusado". O checkpoint digitado por quem ja concluiu
tambem volta ao curso. Na tela antiga a recusa segue o contrato dela (200 com `sucesso: false` e o endereco do
curso; o 409 e o da API, na F1). Provado no `VipClassAlunoPeloErpTest` (o envio repetido deixa uma linha; a trava de
verdade, com outra conexao segurando a pessoa), nos dois bancos; o mutante sem a trava e o sem a recusa, vermelhos.

**Na verificacao (05/11/2026), dois buracos fechados.** (1) A FRASE NAO CHEGAVA A TELA: com o endereco preenchido, o
`vipClassEduCheckpointFinalizar` so redireciona, e a pagina do curso (`aulas.phtml`) nao desenhava o flash -- a
pessoa voltava calada. Agora a pagina desenha o `partials/notificacoes`, e o `testQuemJaConcluiuOuveARecusaNaPaginaDoCurso`
manda o envio e desenha a pagina seguinte com a frase (o mutante sem o partial, vermelho). (2) A TRAVA SO ERA PROVADA
DENTRO DA TRANSACAO DO TESTE, que segurava a trava sozinha: arrancar o `beginTransaction`/`commit` do servico, ou
reler a conclusao antes da trava, ficava verde. Dois casos novos rodam fora dela, com um gancho no `SQLLogger` que
age por outra conexao no instante certo: no `INSERT` da conclusao, a pessoa tem de estar presa (`FOR UPDATE NOWAIT`
da outra conexao recusado); e uma conclusao gravada por outra conexao quando o `FOR UPDATE` sai tem de ser vista, e o
envio ouvir a recusa. Os dois mutantes, vermelhos nos dois bancos. **Resta** o indice unico (vipclass, usuario) e a limpeza das 191 linhas a mais: apagam
linha, pedem a etapa 2 do corte (a 192 a) e sao a fatia 85 (a F5 do desenho), pela 213 (a).

#### PARCIAL em 05/11/2026 -- o 409 da API do aluno (a fatia 80, VIPCLASS-ALUNO-TELA; a F1 do desenho)

A `POST /adm/api/meus-cursos/:id/conclusao` responde 409 `ja_concluiu` a quem ja concluiu, antes de corrigir, e de novo
depois da trava, pelo mesmo `VipClassEduService::checkpointFinalizar` (o envio que perdeu a corrida tambem ouve o 409, e
nao o 200 de quem nao passou: o `testACorridaPerdidaE409`, com o servico dublado). O `GET .../checkpoint` de quem ja
concluiu tambem e 409. A tela nova trava o "Concluir" enquanto o pedido esta no ar (o duplo clique manda um pedido so).
Os cartoes e o curso mostram a PRIMEIRA conclusao, pela 213 (a) (`VipClassCheckpointUsuarioRepository::primeirasDoAluno`);
o relatorio antigo e o certificado continuam lendo a mais recente (o D72) ate a F2 e a F5. **Resta** o indice unico e a
limpeza das 191 linhas a mais, na fatia 85.


**O relatório, em 05/11/2026 (VIPCLASS-QUEM-FEZ, a fatia 81; o D716).** "Quem fez" (a API nova e as duas telas antigas, pela
`VipClassRepository::equipe`) conta cada pessoa uma vez, com a data e a nota da PRIMEIRA conclusão
(`RegraDoCursoVipClass::primeira`, a resposta 213 (a)); antes, a lista mostrava a mais recente (o D72) e não repetia a pessoa, mas o
"N de M" da tela nova contaria linhas se lesse direto. Provado com um par repetido (uma tentativa depois, com outra nota, e uma no
mesmo segundo, com o id maior) no `ConclusoesDoCursoApiTest`, nos dois bancos, e no oráculo pelo HTTP (falsificado com a mais
recente: 4 divergências). **Resta** o índice único e a limpeza das 191 linhas, na fatia 85.

#### PREPARADO em 06/11/2026 -- o índice e a limpeza no passo 3 do corte (a fatia 85, VIPCLASS-CONCLUSAO-UNICA; a F5 do desenho)

Pela 213 (a) do dono (uma conclusão por pessoa e curso, vale a primeira) e pela 192 (a) (o que apaga linha vai à etapa 2):
- **`scripts/migracao-vipclass-conclusao-unica.sql`**, no passo 3 de `docs/procedimento-de-corte.md` (depois da limpeza e antes do
  dump; a linha "Roda no passo 3 da janela, e não aqui" no passo 0, e não o laço -- o desenho dizia "passo 0", e a fila o corrigiu):
  conta antes (os pares, os repetidos, as linhas a mais e, delas, quantas têm outra nota e outra data), lista o que fica e o que sai,
  tira toda conclusão que tem outra mais antiga no mesmo par (a menor `data`; no mesmo segundo, o menor `id`), cria a `UNIQUE KEY
  uq_vipclass_checkpoints_usuarios_vipclass_usuario (vipclass, usuario)` e confere depois (nenhum repetido; os pares de antes; uma
  linha por par; o que saiu igual ao que estava a mais). Idempotente. O irmão da cópia do PostgreSQL,
  `postgres/migracao-vipclass-conclusao-unica.sql` (por `inquilino_id`, com a restrição do nome que o gerador dá).
- **O esquema:** a chave no `mysql.sql` e, pela lista `$unicidades` do gerador, no `postgres/schema.sql`
  (`vipclass_checkpoints_usuarios_inquilino_id_vipclass_usu_691ae94`, com o `inquilino_id` na frente); os bancos da suite e do
  contrato nascem com ela. As fixtures não têm par repetido; os casos que leem o par repetido (o cartão, as primeiras do aluno, o
  "Quem fez", o certificado) o gravam numa tabela temporária sem a chave (`CaracterizacaoTestCase::semAChaveDasConclusoes`, o
  molde dos cupons).
- **O código:** o envio grava a conclusão pela conexão (e não pelo `flush`, que fecharia o EntityManager) e trata a recusa da
  chave como "Você já concluiu este curso" -- na tela antiga a frase de sempre, na API o 409 `ja_concluiu`.
- **Provado:** o roteiro do MySQL no `ensaiar-sql-do-passo-3.php` (o par de três, o empate no mesmo segundo, o id fora da ordem,
  o que não é par, a segunda vez, a chave que recusa o par e aceita outro); o do PostgreSQL no `VipClassConclusaoUnicaTest` (o
  mesmo cenário sobre a tabela sem a chave); a recusa do banco como "já concluiu", nos dois bancos, na tela antiga e na API, com e
  sem a chave (`VipClassAlunoPeloErpTest`, `MeusCursosApiTest`). Medido na cópia, só com `SELECT` de contagem: 2.131 conclusões,
  1.940 pares, 167 repetidos (119 pessoas, 17 cursos), 191 linhas saem, 4 com outra nota (3 maior que a primeira), todas com outra
  data, nenhum empate; os mesmos números no PostgreSQL da cópia.

**A verificação (06/11/2026)** consertou: o caso 35 do `ensaiar-vipclass-adm-api.php`, que parava o ensaio nos dois bancos ao gravar
o par repetido contra a chave (agora confere a recusa quando a chave existe); o caso que prova a releitura depois da trava, que a
chave tornava cego (agora confere que o envio não chega ao `INSERT`, e tem o irmão na temporária sem a chave); o `catch`, que
nenhum caso separava de outro erro do banco (`testOutroErroDoBancoNoEnvioNaoEJaConcluiu`, pela chave estrangeira); e o recorte do
inquilino nas três temporárias sem a chave no PostgreSQL (`TemporariaSemAChaveComORecorteTest`). Fica: a violação da `PRIMARY KEY
(id)` também vira "já concluiu" (a mesma classe do DBAL), só possível com a sequência atrás do maior id.

**Resta:** a aplicação na produção, no passo 3 da janela (o dono); e as cópias locais, que seguem com os 167 pares e sem a chave.
**A limpeza nelas JÁ ESTÁ AUTORIZADA** pela própria 213 (a) -- o texto da opção na 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"* --, e não pela 83 (a), que só cobre o que acrescenta; a fatia 85 não a rodou porque a ordem dela era só `SELECT` na `franquiabv_dev`, e rodá-la (o roteiro do MySQL e o irmão em `postgres/`) fica com o coordenador. Não é pergunta ao dono. Até lá o
`conferir-limpeza-da-unicidade.php` aceita a cópia suja porque o passo 3 a limpa (ele passou a ler as migrações do passo 3), e o
ensaio geral do corte sobre a cópia para na carga desta tabela (o D728).

**06/11/2026, o coordenador rodou a limpeza nas duas cópias locais** (a 213 (a), pelo texto acima), depois do commit da fatia 85: `scripts/migracao-vipclass-conclusao-unica.sql` na `franquiabv_dev` do MySQL e `postgres/migracao-vipclass-conclusao-unica.sql` na `franquiabv_dev` do PostgreSQL, cada uma com a tabela guardada antes (fora do repositório, no rascunho da sessão). Nas duas: de 2.131 linhas em 1.940 pares para 1.940 linhas em 1.940 pares, 191 apagadas (4 com nota diferente da primeira, todas com data diferente), e a chave criada (`uq_vipclass_checkpoints_usuarios_vipclass_usuario` no MySQL; `UNIQUE (inquilino_id, vipclass, usuario)` no PostgreSQL). Conferido contra a tabela guardada, par a par: cada um dos 1.940 ficou com a sua primeira (a menor data e, no empate, o menor id), nenhuma primeira faltou e nenhuma que ficou deixa de ser a primeira. **Resta** a produção, no passo 3 da janela (o dono).

### D373 — Sem pergunta cadastrada, o checkpoint da VIP Class vale nota 100: 23 aprovações sem prova no treinamento número 20 — REGISTRADO em 03/10/2026 — CORRIGIDO em 15/10/2026: a 119 (a)

Achado pelo desenho da VIP Class (`docs/f3-telas-restantes/tela-vipclass.md`, DV-3), e reconferido no
código em 03/10/2026.

- A nota é `$numeroAcertos >= $numeroPerguntas ? 100 : 0`
  (`module/ISCadastro/src/Service/VipClassEduService.php:27`): sem pergunta, 0 é maior ou igual a 0, e a
  nota é 100, acima do corte de 80 (`:33`). A validação do formulário passa sem pergunta nenhuma
  (`module/ISCadastro/src/Controller/VipClassEduController.php:140-150`). Sai a conclusão, e com ela o
  certificado.
- **Medido** (M5 e M16 do desenho): só o curso 20 está assim -- 1 aula, 0 perguntas, 8 grupos, criado e
  alterado pela última vez em 09/07/2025 --, com 23 conclusões de 19 pessoas, todas com nota 100; a
  última em 17/06/2026.

**O conserto espera o dono:** é a pergunta 2 do desenho (o que vale a conclusão do curso sem
perguntas), que o consolidado deixou para depois com a recomendação "bloqueado até ter checkpoint, e as
23 conclusões ficam". A F0 da VIP Class isola o comportamento de hoje numa função da regra, para a
resposta ser uma linha.

#### CORRIGIDO em 15/10/2026 -- a resposta 119 do dono, opção (a) (a fatia 5, RESPOSTAS-DO-PROGRAMA)

**A decisão:** *"Bloqueado até ter checkpoint — ninguém conclui o curso, o cartão diz 'sem checkpoint', e as 23
conclusões que já existem ficam."* Na tela antiga, que está em uso.

**O conserto:** a regra do curso, isolada como a F0 do desenho pede (`ISConfiguracao\Regra\RegraDoCursoVipClass::temCheckpoint`:
ao menos uma pergunta ativa — as que o checkpoint mostra); o `VipClassEduService::checkpointFinalizar` recusa o curso
sem ela antes da conta da nota, com a frase, e nada é gravado; a página do curso (`vip-class-edu/aulas.phtml`) diz "sem
checkpoint" e a frase no lugar do botão, a do checkpoint (`checkpoint.phtml`) mostra o aviso no lugar do formulário, e o
cartão da lista (`index.phtml`) ganha o selo. A conta das perguntas ativas é
`VipClassCheckpointPerguntaRepository::contarAtivasPorCurso`, que o controller (`aulasAction`) e a lista dos cartões
(`VipClassRepository::listagemIndexEdu`) chamam.

**As 23 conclusões ficam:** nada as apaga, e quem tem a conclusão segue vendo o certificado na página do curso — o
`checkpointCertificadoAction` não passa pela regra. Na cópia (`franquiabv_dev`, só contagens, 15/10/2026): o curso 20
continua o único sem pergunta ativa entre os 21 cursos ativos de lá, com as 23 conclusões de 19 pessoas (a última de
17/06/2026) — o 90001000, curso das fixtures, também está ativo e sem pergunta, e não conta. O bloqueio não tranca outro
curso: nenhum dos 21 tem o 20 como pré-requisito, e o que libera o Visagismo é o 17 (`VisagismoController`), que tem
pergunta.

**Provado** em `D373CursoSemPerguntaTest` (11 casos, nos dois bancos, pelo despacho em processo e pelas telas desenhadas
com o que a ação devolve): o envio do curso sem pergunta, e do curso só com a desativada, é recusado sem gravar; o curso
com pergunta conclui com o acerto e recusa o erro, como antes; a página do curso, a do checkpoint e o cartão; a conclusão
de antes fica e o certificado segue oferecido; e a conta por curso. Sete mutantes, um por vez, cada um com caso vermelho
(a guarda do serviço, a regra, cada uma das três telas, a conta sem o filtro da situação, o controller sem a regra).
**E da verificação do mesmo dia, dois casos:** quem já concluiu e manda o checkpoint de novo ouve a recusa, e a linha dele
fica como estava, com a nota e a data (a recusa que apagasse a conclusão de antes passava pelos casos de recusa, que partem
de zero — agora deixa este vermelho); e **o certificado sai**: o `checkpointCertificadoAction` do curso sem pergunta gera o
PDF inteiro para quem tem a conclusão, e o 404 para quem não a tem. O PDF roda num processo filho do mesmo phpunit, porque o
`FPDFTable::Output` termina o processo (o D107) — chamado na suíte, ele a encerrava com saída 0 e sem o resumo, e foi o que
a primeira versão do caso fez; o certificado que recusasse o curso sem pergunta deixa o caso vermelho.
**Não provado:** o desenho do PDF (o caso confere que ele sai inteiro, e não o que está escrito nele), e a tela no
navegador. A administração não muda: o aviso "sem checkpoint" dela é
da F2 do desenho (`docs/f3-telas-restantes/tela-vipclass.md`).

### D374 — Gravar um molde de privilégio num par de grupo e funcionalidade que já tem molde: o MySQL aceita calado, e o PostgreSQL dá 500 — PARCIAL em 04/10/2026: o novo e o editar; o índice é esquema — PREPARADO em 17/10/2026: a chave única nas duas cópias e no `mysql.sql`; na produção, no passo 3 do corte — PREPARADO em 18/10/2026: o molde do par fica o 1º (a 237 c)

Achado pelo desenho de Grupos e privilégios (`docs/f3-telas-restantes/tela-acl.md`, D-ACL-3; lido, não
exercitado), e reconferido no código em 03/10/2026.

- `novo` grava o molde sem conferir se o par já existe
  (`module/ISConfiguracao/src/Controller/PrivilegiosController.php:81-86`;
  `module/ISConfiguracao/src/Service/PrivilegioService.php:24-40`), e `editar` pode trocar o par para um
  que já existe (`PrivilegioService.php:71-76`).
- **No PostgreSQL** o índice único `privilegios_inquilino_id_grupo_funcionalidade_unico`
  (`postgres/schema.sql:1804`) recusa, e não há `try` em volta: a gravação dá 500 -- risco do corte.
- **No MySQL** não há índice (`mysql.sql:1209-1220`), e o molde repetido entra calado. Com ele, quem é
  cadastrado no grupo, ou troca para ele, ganha duas linhas pelo `salvarPrivilegios`
  (`module/ISConfiguracao/src/Service/UsuarioService.php`) -- as gêmeas que o D289 trata na gravação.
- **Medido** (C2 do desenho): hoje há 0 pares repetidos em `privilegios`; os 90 pares do Yungas no grupo
  3 estão no par cujo molde já esteve repetido -- correlação, e não causa provada.

**O conserto** (a F0 de Grupos, sem decisão): `novo` recusa o par que já existe; `editar` não troca o
par -- os dois campos ficam só para leitura, e o servidor ignora o que vier neles. O índice único do
MySQL entra no SQL da migração (o bloco 5 de `scripts/pre-migracao-limpeza.sql` já limpa as
repetições); nas cópias locais, só com a autorização do dono, a P8 do consolidado.

#### PARCIAL em 04/10/2026 -- o item GRUPOS-F0

**O conserto:** `PrivilegioService::insert` devolve nulo -- e não grava nada -- quando o par já tem molde,
e o novo do ERP diz *"Este grupo já tem um privilégio para esta funcionalidade: edite o que existe."*; o
editar não troca o par: grupo e funcionalidade aparecem desligados (`Privilegio::paraEditar`), ficam fora
da validação, e o serviço grava só `ler` e `escrever` no molde da rota (o D395).

**Provado** nos dois bancos: `PrivilegiosDoErpTest` (o par repetido recusado com a frase, e a contagem
continua 1; o editar com grupo, funcionalidade e `id` de outro molde no corpo grava só o nível do molde
da rota; o formulário mostra o par desligado) e `PrivilegioPropagacaoTest` (o serviço não grava, e
ninguém ganha linha). O mutante sem a recusa do par: vermelho no MySQL (o segundo molde entra calado) e,
no PostgreSQL, **com erro** -- a violação do índice único, o 500 deste débito.

**A revisão (04/10/2026): o editar como o navegador o envia, e o token.** O `<select>` com `disabled` não
vai no POST, e o filtro exige grupo e funcionalidade: é o grupo de validação de `paraEditar()` que deixa a
edição gravar -- e nenhum caso postava sem os dois (arrancá-lo deixava a suíte verde e a tela sem
gravar). E é ele que escolhe os campos que valem: o `security` fora dele desliga o `Csrf`. Dois casos
novos em `PrivilegiosDoErpTest`: o editar só com `ler`, `escrever` e o token grava o nível; sem o token,
ou com um errado, nem o editar nem o novo gravam, e quem recusa é o campo `security`. Mutantes: o grupo de
validação arrancado (os `disabled` ficam) e o `security` fora dele -- vermelhos. O código não mudou.

**Resta:** o índice único de `privilegios` no MySQL -- esquema, com o dono (a P8). Sem ele, dois "novo"
simultâneos no mesmo par ainda podem gravar dois.

*Atualizado em 12/10/2026 (o item ESQUEMA-NAS-COPIAS):* a P8 é a pergunta 83 do lote, respondida em
12/10/2026 com a opção (a). Na cópia, `privilegios` segue com 0 pares (grupo, funcionalidade) repetidos
(contado de novo neste dia, só leitura) e sem índice único no MySQL: ali o índice só acrescentaria. Mas a 83
vale "quando a resposta pedir", e a pergunta que pede este índice é a 125 do lote (as duas regras dos
privilégios repetidos, com o D289), ainda sem resposta -- e a opção (a) dela junta as gêmeas de
`privilegios_usuarios`, que apaga linha e continua um a um. O índice segue esperando a 125; nada foi aplicado.

*Atualizado em 17/10/2026 (a fatia ACL-API):* as duas portas de escrita do molde -- a API nova e o ERP -- travam a linha
do grupo (`SELECT ... FOR UPDATE`) antes de gravar, e a recusa do par repetido do "Novo" passou para depois
da trava (`PrivilegioService::gravarMolde`). Dois "novo" simultâneos no mesmo par, por essas portas, gravam um e recusam o
outro, nos dois bancos (`MoldePelosControllersTest::testAsDuasPortasTravamOGrupoAntesDeLerOMolde` prova que a trava vem
antes da leitura do molde na API e no "Novo" do ERP; dois pedidos ao mesmo tempo não foram ensaiados). O "editar" e o
"remover" da tela antiga acham o molde ANTES da transação (`find`, no controller e no `delete`), e o que os protege de outra
aba gravando no meio é a releitura depois da trava (`refresh`): `testAsPortasDoErpReleemOMoldeDepoisDaTrava`, acrescentado
pela verificação da fatia -- antes dele, arrancar a releitura deixava tudo verde. O índice continua sendo o que vale para
qualquer outra porta -- o SQL à mão, a migração --, e segue esperando a 125 (a fila, fatia 11).

#### PREPARADO em 17/10/2026 -- a chave única do molde (a fatia 11 da fila das telas que faltam, PRIVILEGIOS-GEMEOS)

A resposta 125 (a) do dono (12/10/2026) pediu "as duas regras": a do molde e a das pessoas (a do D289). **Nas duas cópias
locais** o MySQL ganhou a `UNIQUE KEY uq_privilegios_grupo_funcionalidade (grupo, funcionalidade)` -- 0 moldes repetidos,
contados antes, e nada a juntar --; o PostgreSQL já a tinha. **No `mysql.sql`** a mesma chave: a suíte do MySQL nasce com ela, e
o gerador do PostgreSQL continua a emiti-la pela lista `$unicidades`, e não duas vezes. **Na produção**, pelo passo 3 do corte
(`scripts/migracao-privilegios-unicos.sql`, a etapa 2 pela resposta 192 (a)), antes de juntar as gêmeas das pessoas: o molde
repetido de valores diferentes, que o bloco 5 de `pre-migracao-limpeza.sql` não tira, a recusa, e o roteiro para sem apagar
nada.

**O bloco 5 tirava, até a verificação da fatia** (17/10/2026): ele agrupava só por (grupo, funcionalidade), sem olhar `ler`
nem `escrever`, e roda antes desta migração no passo 3 -- o molde repetido de valores diferentes saía calado, ficando o mais
antigo, e a parada que o texto prometia nunca acontecia. O ensaio só a via porque rodava a migração sozinha. Desde a
verificação o bloco 5 só tira o repetido de valores iguais (a aprovação de 26/08/2026 é a dos valores idênticos), e o
`scripts/ensaiar-sql-do-passo-3.php` roda o bloco 5 antes da migração, como o passo 3: o de valores iguais sai e a migração
segue; o de valores diferentes fica, e a chave do molde para o roteiro (com o bloco 5 de antes, vermelho). O procedimento
manda contá-lo antes, na réplica -- na cópia de 16/08/2026, nenhum --, e o que fica, se aparecer um, é a pergunta do lote
de 17/10/2026 (parar e o dono decidir, que é o que está; ficar o que concede mais; ou ficar o mais antigo).

As duas portas continuam travando o grupo antes de ler o molde (a fatia ACL-API); a chave é o que vale para qualquer outra --
e, se uma corrida passar pela trava, o banco recusa o segundo. A recusa da chave das pessoas na propagação do molde novo
(a corrida com outra gravação do mesmo par de alguém do grupo) volta com o molde inteiro: 409 `mudou` na API, a frase no
"Novo" do ERP (o D289). **Provado** nos dois bancos: `PrivilegioDaTelaNovaTest::testOBancoRecusaOSegundoMoldeDoPar`,
`MoldePelosControllersTest::testOMoldeNovoNaCorridaComOutraGravacaoDoParVoltaAtrasInteiro` e
`PrivilegiosDoErpTest::testONovoNaCorridaComOutraGravacaoDoParDizAFraseENaoGrava`.

#### PREPARADO em 18/10/2026 -- o molde repetido fica o mais antigo (a resposta 237 (c); a fatia 16b da fila das telas que faltam, RESPOSTAS-237-238)

**A resposta do dono** (237 (c), 17/10/2026): *"Fica o mais antigo"* -- "como a limpeza fazia até hoje"; a noite não
para. Não é a recomendação da pergunta (a (b), ficar o que concede mais). **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 ele recebe as duas, e o `Acl` as soma). Quem já está no grupo não muda: as linhas dele são
`privilegios_usuarios`.

**O que mudou, só no SQL do passo 3** (a etapa 2; nada roda nas cópias locais nem no laço do passo 0):

- **O bloco 5 de `scripts/pre-migracao-limpeza.sql`** voltou a tirar todo molde repetido, ficando o de menor id -- a regra de
  antes da verificação de 17/10/2026 --, e passou a LISTAR o de valores diferentes antes de apagar (o grupo, a funcionalidade,
  o que fica e os que saem, em id:ler escrever), para o registro da noite. O "DEPOIS" voltou a "tem que ser zero".
- **`scripts/migracao-privilegios-unicos.sql`** deixou de criar a chave do molde primeiro: o molde repetido sai na mesma
  transação das gêmeas das pessoas, pela mesma regra do bloco 5 (o piso, para o que chegar a ela sem a limpeza antes), com a
  lista de cada um, e as duas chaves nascem depois. Continua idempotente: a segunda vez conta zero, lista nada e diz que as
  chaves já existem. Uma consequência da ordem nova: se o servidor recusar o `INPLACE` de uma chave, a junção já foi -- rodar
  de novo não a refaz e tenta a chave outra vez (antes, a recusa vinha antes de apagar).
- **`docs/procedimento-de-corte.md`** (a linha do passo 0, o passo 3 e a tabela da volta atrás) e
  `docs/procedimento-de-corte-etapa-1.md` dizem a regra nova; a contagem na réplica fica, para saber antes quem são.

**Provado:** `scripts/ensaiar-sql-do-passo-3.php` (saída 0, MySQL 8.4.11), com dados inventados nas tabelas sem as chaves: o
bloco 5 com um molde repetido de valores iguais (o 5, gêmeo do 3) e dois de valores diferentes -- um em que o mais antigo dá
MENOS (o 2 só lê, o 4 lê e escreve) e um em que dá MAIS (o 1 lê e escreve, o 6 só lê) -- lista os dois de valores diferentes,
tira os três mais novos e termina em zero, e a migração segue sem nada a listar, junta a gêmea e cria as duas chaves; a
migração SEM o bloco 5 antes faz o mesmo sozinha, lista os três (os iguais também), e a segunda vez não muda nada; e a chave do
molde recusa o repetido seguinte. **E num ensaio à parte, em bancos descartáveis** (MySQL `descartavel_f16b_molde` e
`descartavel_f16b_molde_so_migracao`, PostgreSQL `descartavel_f16b_molde`, apagados no fim; o roteiro é do rascunho): 7 moldes
(1 repetido de valores iguais, 2 de valores diferentes) e 3 linhas de pessoas (1 gêmea) antes; 4 moldes, 0 repetidos, 2 linhas,
0 pares repetidos e as 2 chaves depois -- com o bloco 5 antes e sem ele, e igual na segunda rodada. No PostgreSQL, com as duas
restrições do `postgres/schema.sql`, a carga do molde de antes para na `privilegios_inquilino_id_grupo_funcionalidade_unico`, e a
de depois entra inteira (4 moldes e 2 linhas). Os mutantes, um por vez, estão no roadmap de 18/10/2026 (RESPOSTAS-237-238).

**A verificação da fatia (18/10/2026)** achou só texto e o ensaio, e consertou: a data da resposta (17/10/2026, e não 18, nos
dois roteiros, no ensaio e nos dois procedimentos); o desenho de Grupos (`tela-acl.md`, o D-ACL-3) e o parágrafo do passo 3
do D289, que ainda diziam que a noite parava; e o `ensaiar-sql-do-passo-3.php`, que perdia as falhas quando a migração não
criava uma das chaves -- o caso seguinte tirava as chaves com `DROP INDEX` direto, a da que não existia falhava, e o portão
saía com 2 ("não consegui medir") sem imprimir o que já tinha medido. Desde a verificação ele tira só a chave que existe, e o
"não consegui medir" que vem depois de uma falha medida sai com 1 e com a lista. Os mutantes C1 (a migração guarda o texto da
chave do molde e não a cria) e C2 (o mesmo na das pessoas) saem com 1 e dizem a chave que faltou; com o portão de antes, com 2.

**Segue PREPARADO:** o que roda é o passo 3 da janela. **O que não se provou:** o MySQL 8.0 da réplica (o ensaio diz
`CONTEINER_MYSQL=<um 8.0>`, e não há um de pé aqui); e a réplica de verdade -- quantos moldes repetidos de valores diferentes ela
tem, na contagem que o procedimento manda fazer antes.

### D375 — A retirada do caixa continua ativa quando o financeiro estorna ou exclui a despesa da sangria: 17 casos, R$ 5.635,00 fora da gaveta — REGISTRADO em 03/10/2026 — PARCIAL em 13/10/2026 — CORRIGIDO em 14/10/2026

Achado pelo desenho de Contas a pagar (`docs/f3-telas-restantes/tela-saidas.md`, o débito 11 da seção
2.3), e reconferido no código em 03/10/2026. O desenho o registra como desacordo: o que fazer é a P11 do
consolidado.

- A retirada do caixa marcada "financeiro" cria uma despesa do tipo 5 já paga
  (`module/ISVenda/src/Service/CaixaService.php:256-287`), e cancelar a retirada no caixa apaga essa
  despesa (`:335-338`). O caminho de volta não existe: estornar o pagamento ou excluir a despesa em
  Contas a Pagar (`module/ISFinanceiro/src/Service/SaidaService.php:283` e `:319`) não olha a retirada,
  que continua ativa no caixa, com o dinheiro fora da gaveta.
- **Medido** (M7 e M2 do desenho): 17 retiradas ativas cuja despesa não tem mais dinheiro ativo --
  estornada ou excluída no financeiro --, R$ 5.635,00, de 3 unidades, de 01/2021 a 04/2024; e 24
  parcelas de sangria alteradas pela tela de Contas a Pagar. As despesas de sangria são 8.187, 62% de
  todas.
- O apagar físico de `:335-338` contradiz a regra de que despesa com dinheiro não se exclui (o D205).

**O conserto espera o dono:** é a P11 do consolidado, com a recomendação "só ler; desfaz no caixa" -- a
despesa da sangria fica só para leitura em Contas a Pagar, e cancelar a retirada passa a estornar e
excluir a despesa, em vez de apagá-la. Até a resposta, a T2 de Contas a pagar sai sem essa peça.

#### PARCIAL em 13/10/2026 -- as duas portas amarradas (a resposta 141; o item CONTAS-A-PAGAR)

**Decidido pelo dono em 13/10/2026** (pergunta 141 da página do lote, a opção (c), e **não** a recomendada): "Amarrar as
duas: estornar no financeiro cancela a retirada -- não serve quando o caixa já fechou, que não aceita cancelar retirada,
e é o caso de quase toda despesa de sangria quando chega ao financeiro". O que fazer com o caixa fechado a resposta não
disse.

**O que "amarrar" quer dizer do lado do caixa, conferido no desenho:** a pergunta lembrava que "cancelar a retirada no
caixa apaga a despesa paga de vez, contra a regra de que despesa com dinheiro não se exclui", e o desenho
(`docs/f3-telas-restantes/tela-saidas.md`, a P2) descrevia o cancelamento que desfaz sem apagar como "estornar o
pagamento e excluir a despesa, deixando o rastro". É o que entrou: o cancelamento no caixa estorna e exclui, sem apagar,
e a despesa sai de Contas a Pagar como saía.

**O conserto:**
- **O estorno no financeiro cancela a retirada** (`SaidaService::estornarPagamento`): sob a trava da despesa, a retirada
  que a criou é relida com trava (`retiradaDaDespesaRelida`); se ela está ativa, `CaixaService::retiradaCanceladaPeloEstorno`
  trava o caixa, relê a retirada, devolve o valor ao caixa, a cancela, e só então o pagamento é estornado, na mesma
  transação. A despesa fica como a de todo estorno -- a parcela reaberta, na lista --, e continua ligada à retirada.
- **O caixa já fechado recusa o estorno**, com a frase `SaidaService::RECUSA_DO_CAIXA_FECHADO` ("Esta despesa veio de
  uma retirada do caixa, e o caixa já fechou: retirada de caixa fechado não se cancela, e o pagamento não foi
  estornado." -- até a verificação dizia "Este pagamento veio de", o que não é verdade do pagamento lançado à mão, ver
  abaixo), na tela antiga e com 409 na API. Nada muda.
- **Cancelar a retirada no caixa não apaga mais a despesa** (`CaixaService::retiradaCancelar` com
  `SaidaService::desfazerASangria`): o pagamento é estornado com o motivo "Retirada do caixa cancelada" e quem cancelou,
  as parcelas que ficaram sem dinheiro e a despesa são excluídas com o autor, e a retirada continua apontando para ela
  (o `setSaida(null)` e o `em->remove` saíram). As duas portas do caixa (a tela antiga e a API) passam o autor da
  sessão, e a tela antiga diz "O pagamento dela em Contas a Pagar foi estornado.".
- **A ordem das travas é a mesma nas duas portas:** a despesa, a retirada e o caixa por último -- o cancelamento no caixa
  trava a despesa antes do caixa quando a retirada tem despesa --, para que as duas, na mesma sangria ao mesmo tempo, não
  se esperem em cruz. A retirada é lida com trava porque, no MySQL, a primeira leitura simples da transação tira a foto
  que as seguintes leem, e a do caixa tem de ser a de depois da espera.
- **O que não mudou:** as 17 retiradas ativas com a despesa já estornada ou excluída no financeiro (R$ 5.635,00) ficam
  como estão -- é dado, e a resposta não pediu mexer nele. **Corrigido pela verificação:** este item dizia que cancelar
  uma delas no caixa "devolve o valor e exclui a despesa, sem estorno novo"; os caixas delas estão **todos fechados**
  (medido na `franquiabv_dev`, 13/10/2026: das 18 retiradas ativas com um pagamento estornado, nenhuma em caixa aberto),
  e as duas portas do caixa recusam antes do serviço ("Retirada de caixa fechado não se cancela"). Das 18, 16 têm a
  parcela excluída à mão, 1 a parcela quitada sem pagamento ativo, e a 18ª -- a retirada 11906 -- tem a parcela paga de
  novo à mão (o pagamento 6083 estornado em 01/04/2022, o 6128 lançado em 11/04/2022, ativo); para ela o "sem estorno
  novo" não valeria. O caso `testCancelarARetiradaCujaDespesaJaFoiEstornadaSoExclui` mede esse estado com o caixa
  **aberto**, que a cópia não tem: é o caminho de defesa, e o comentário dele passou a dizer isso.

**Provado** em `tests/Caracterizacao/ContasAPagarRespostasTest.php` (24 casos), `D375SangriaAmarradaTest.php` (21) e
`ContasAPagarSemAsColunasTest.php` (4), novos, e nos vizinhos ajustados às assinaturas novas (`SaidaFinanceiroTest`,
`SaidaSobTravaTest`, `SaidaApiTest`, `SaidaApiEscritasTest`, `CaixaTest`, `CaixaApiTest`, `CaixaSobTravaTest`,
`ColunasForaDoMapeamentoTest`): 358 casos do filtro do item (Contas a Pagar, o caixa, as colunas fora do mapeamento e
os vizinhos), verdes no MySQL e no PostgreSQL, em série. O item CONTAS-A-PAGAR (o roadmap de 13/10/2026) tem a tabela
inteira dos mutantes; os deste débito estão abaixo.

- com o caixa aberto, o estorno no financeiro devolve os R$ 120,00 ao caixa, cancela a retirada, estorna o pagamento com
  o motivo e o autor, e deixa a despesa ativa com a parcela reaberta; com o caixa fechado, é recusado e nada muda --
  pelo serviço, pela tela antiga (com a frase) e pela API (409 com a frase); a retirada já cancelada não devolve de novo,
  nem quando o cancelamento chega enquanto o estorno espera a trava do caixa; o cancelamento no caixa estorna e exclui a
  despesa sem apagar, guarda quem cancelou (pelo serviço, pela tela antiga do caixa e pela API dele), e não mexe de novo
  no segundo cancelamento; a despesa já estornada só é excluída; a retirada sem "financeiro" cancela como antes; e as
  duas portas travam a despesa antes do caixa, com a primeira leitura sendo a da despesa com trava;
- mutantes vermelhos, cada um sozinho: o estorno sem a amarra (5 casos); a amarra sem devolver ao caixa (4); a amarra
  devolvendo a já cancelada (1, nos dois bancos -- a primeira rodada deixou este vivo, e o caso
  `testQuemEsperouOCaixaReleARetiradaEDevolveUmaVezSo` entrou para matá-lo); o cancelamento apagando a despesa, como
  antes (5); sem excluí-la (4); sem estornar (3); travando o caixa antes da despesa (1, nos dois bancos); a retirada lida
  sem trava (1, no `SaidaSobTravaTest`, que passou a exigir a leitura dela com trava, nos dois bancos); e as duas portas
  sem a frase do caixa fechado (1 cada).

#### A verificação do item, 13/10/2026 -- a amarra é pela despesa, e não pelo pagamento

A revisão do item achou, lido no código, que **a amarra é pela despesa**: a retirada aponta para a despesa, e nada liga um
pagamento a ela. A sangria cria uma parcela com um pagamento, mas a despesa aceita parcela lançada à mão ("Inserir
parcela(s)") e o pagamento dela. Com dois pagamentos ativos -- R$ 120,00 da sangria e R$ 50,00 de fora --, estornar o de
fora cancelava a retirada e devolvia os R$ 120,00 ao caixa com o da sangria ainda pago (o mesmo dinheiro contado duas
vezes, e a frase dizendo "o valor voltou ao caixa"); e cancelar a retirada no caixa estornava os dois, o de fora com o
motivo "Retirada do caixa cancelada". **Qual pagamento é o da gaveta, o dono não disse -- foi ao lote.** Até a resposta, as
duas portas **recusam** quando a despesa da sangria tem **mais de um pagamento ativo**, com a frase
`SaidaService::RECUSA_DA_SANGRIA_COM_OUTRO_DINHEIRO` ("Esta despesa veio de uma retirada do caixa e tem mais de um
pagamento: não se sabe qual deles é o dinheiro que saiu da gaveta, e nada foi estornado nem cancelado.") -- o molde que a
141 deu para o caixa fechado. Nas quatro portas: as duas de Contas a Pagar (a tela antiga e a API, 409) e as duas do caixa
(a tela antiga e a API, 409). **Na cópia não há nenhuma:** nenhuma despesa de sangria tem mais de uma parcela, e a única
com dois pagamentos (a da retirada 11906) tem um só ativo -- o lançado à mão depois do estorno do primeiro, que conta como
o dinheiro da gaveta lançado outra vez e continua amarrado (com o caixa fechado, recusado pela frase do caixa fechado).

Os outros achados da verificação, consertados:
- **A frase do pagamento já estornado** (`SaidaService::recusaDoEstorno`): o estorno repetido de um pagamento já
  estornado, numa sangria de caixa fechado com a retirada ativa (o estado das 18), recebia "o pagamento não foi
  estornado" -- de um pagamento que está. As duas portas passaram a perguntar primeiro se o pagamento já estava
  estornado ("Este pagamento já estava estornado." na tela antiga; "O pagamento já está estornado." com 409 na API).
- **A frase do caixa fechado fala da despesa** ("Esta despesa veio de uma retirada do caixa"), e não do pagamento.
- **Dois mutantes vivos, mortos:** sem o `|| !$retirada->getStatus()` do estorno, a retirada já cancelada passava pelo
  caixa, e com o caixa fechado o estorno de um pagamento novo era recusado com a frase falsa -- nenhum caso separava isso
  (o único com a retirada cancelada usava o caixa aberto, onde os dois caminhos dão o mesmo); e a pergunta ao caixa
  depois dos laços de `desfazerASangria` estornava e excluía antes da recusa (que faz `commit`) -- nenhum caso cancelava no
  caixa fechado uma sangria com pagamento ativo.
- **Achado por um mutante, no caminho:** a frase do outro dinheiro lia a situação dos pagamentos com `fetchFirstColumn`,
  e no PostgreSQL ela é booleana: o `false` do primeiro pagamento estornado é o mesmo `false` com que o DBAL diz "acabaram
  as linhas", e a lista voltava vazia. Lê a linha inteira agora.

**Provado** em `D375SangriaAmarradaTest.php`, com oito casos novos (21 no total; 358 no filtro do item, verdes no MySQL e
no PostgreSQL, em série): a retirada já cancelada com o caixa fechado estorna só no financeiro; o único pagamento ativo
lançado à mão depois de um estorno antigo continua amarrado, e no feitio da 11906 a tela diz que o caixa fechou; a despesa
com outro pagamento recusa as duas portas pelo serviço e as quatro portas pela frase, sem mudar nada; o outro pagamento
depois de um estornado também recusa; o estorno repetido diz que já está estornado, nas duas portas; e o cancelamento no
caixa fechado não mexe na despesa. **Treze mutantes**, um de cada vez, restaurados pelo sha256, todos vermelhos no
PostgreSQL, e doze no MySQL -- o da leitura por `fetchFirstColumn` só morde o PostgreSQL (no MySQL a situação vem como
número, e ele fica vivo lá): sem a recusa do outro dinheiro no estorno e no cancelamento; a conta com os estornados (no
serviço e na frase); a frase pela leitura de uma coluna só; cada uma das quatro portas sem a frase; a frase perguntando
pela sangria antes do já estornado; a retirada já cancelada passando pelo caixa; o caixa conferido depois dos laços; e a
frase do caixa fechado de volta a "Este pagamento".

**O que fica -- o PARCIAL do título -- foi ao lote** (CONTAS-A-PAGAR, 13/10/2026): o que o estorno faz quando o caixa já
fechou (recomendo continuar recusando); se o estorno no financeiro que cancelou a retirada exclui a despesa junto, como
o cancelamento pelo caixa (recomendo que sim; fica aberta até a resposta, como todo estorno); e, da verificação, **qual
pagamento da despesa da sangria é o da gaveta** (recomendo recusar parcela nova na despesa da sangria enquanto a retirada
estiver ativa; até lá, as portas recusam a despesa com mais de um pagamento ativo) e **quem pode cancelar a retirada pelo
estorno no financeiro** -- hoje qualquer pessoa com Contas a Pagar na unidade, e o administrador, cancela a retirada do
caixa aberto de outro operador, sem o privilégio do caixa nem a posse que as duas portas do caixa exigem (recomendo
exigir o que o caixa exige; fica como está até a resposta, por ser o que a 141 escreveu sem restrição). A tela nova de
Contas a Pagar ainda não existe (a 7ª da ordem), e a do caixa não oferece cancelar retirada. Nada foi aberto num
navegador.

#### CORRIGIDO em 14/10/2026 -- as respostas 171, 172, 174 e 175 (o item CAIXA-E-SANGRIA)

**Decidido pelo dono em 14/10/2026** (as perguntas 171, 172, 174 e 175 da página do lote, todas na opção recomendada):

- **171 (a):** "A despesa da sangria cujo caixa já fechou: o estorno em Contas a Pagar continua RECUSADO, com a frase que
  diz por que, nas duas portas; quem errou corrige por um lançamento novo."
- **172 (b):** "Depois do estorno em Contas a Pagar que cancelou a retirada (caixa aberto), a despesa da sangria é EXCLUÍDA
  junto, como o cancelamento pelo caixa já faz: sai de Contas a Pagar pelas duas portas (a tela antiga e a API), com o
  autor, sem apagar linha."
- **174 (b):** "Recusar parcela nova na despesa da sangria enquanto a retirada estiver ativa (nas duas portas de Contas a
  Pagar); o que for de fora vira outra despesa. A recusa provisória da despesa com mais de um pagamento ativo continua
  valendo."
- **175 (a):** "Só quem poderia cancelar a retirada no caixa (o privilégio do caixa E ser o operador daquele caixa, a
  mesma regra das portas do caixa) pode cancelá-la pelo estorno em Contas a Pagar; para os outros, o estorno é recusado
  com 'cancele a retirada no caixa'."

(A 173, a retirada de R$ 0, é o **D480**.)

**O conserto:**
- **171 -- nada a mudar, conferido:** o caixa fechado recusa o estorno, nada muda, e a frase é
  `SaidaService::RECUSA_DO_CAIXA_FECHADO`, pelo serviço, pela tela antiga e com 409 pela API -- os casos
  `testComOCaixaFechadoOEstornoERecusadoENadaMuda`, `testATelaAntigaDizOQueAconteceuComARetirada` e
  `testAApiRecusaComOCaixaFechadoECancelaComEleAberto`, que já estavam aqui e seguem verdes. Os comentários que diziam
  "foi ao lote" passaram a dizer a resposta.
- **172 -- a despesa sai junto** (`SaidaService::estornarPagamento`): o estorno que cancelou a retirada exclui as parcelas
  que ficaram sem dinheiro e a despesa, com o autor e sem apagar -- o mesmo trecho do cancelamento pelo caixa, que saiu de
  `desfazerASangria` para `excluirASangriaSemDinheiro`, por onde as duas portas passam. A regra do D205 vale igual: a
  parcela que fica com dinheiro (a quitada com motivo, que o estorno não desfaz) segura a despesa. `CaixaService::
  retiradaCanceladaPeloEstorno` diz ao estorno se **esta** chamada cancelou a retirada: o estorno de uma sangria cuja
  retirada já estava cancelada (por fora da amarra; na cópia, nenhuma retirada cancelada tem despesa) não cancelou nada,
  e fica como o de todo estorno. A tela antiga diz "..., o valor voltou ao caixa, e a despesa saiu de Contas a Pagar." (a
  frase de antes fica para a despesa que a quitada segura); a API devolve a parcela excluída, e desde a verificação do
  item diz o que aconteceu: `estado` "excluida" na parcela, `saida.estado` e `retirada.estado` (a verificação, abaixo).
- **174 -- a parcela nova é recusada** (`SaidaService::insertParcelas`): sob a trava da despesa, a retirada que a criou é
  relida com trava (a ordem das portas da sangria: a despesa, a retirada); ativa, nada entra -- nem uma, nem em lote. A
  frase é `SaidaService::RECUSA_DA_PARCELA_NOVA_NA_SANGRIA` ("Esta despesa veio de uma retirada do caixa e não recebe
  parcela nova: o que for de fora vira outra despesa."), na tela antiga (a mensagem da página das parcelas) e com 409 na
  API. A retirada cancelada não segura mais nada. A recusa do outro dinheiro (`outroDinheiroNaSangria`, a frase
  `RECUSA_DA_SANGRIA_COM_OUTRO_DINHEIRO`) fica, como a resposta pediu, para o que já estiver lançado. O botão "Inserir
  parcela(s)" continua na tela antiga; a recusa vem ao enviar, com a frase.
- **175 -- só quem opera o caixa cancela pelo estorno:** `CrudController::operadorDoCaixa` devolve o `usuarioId` quando
  ele tem o privilégio do caixa (`vendas/nova/escrever`, o das duas portas do caixa, o D227), e null sem ele; as duas
  portas de estorno de Contas a Pagar o passam a `estornarPagamento` e a `recusaDoEstorno`. `CaixaService::
  retiradaCanceladaPeloEstorno`, sob a trava do caixa e com a retirada relida, recusa a retirada ativa de caixa cujo
  operador não é ele (`CaixaService::operaOCaixa`) -- e nada muda. A frase é `SaidaService::RECUSA_DE_QUEM_NAO_OPERA_O_CAIXA`
  ("Esta despesa veio de uma retirada do caixa aberto, e só o operador daquele caixa a cancela: cancele a retirada no
  caixa. O pagamento não foi estornado."), depois da do caixa fechado, que vale para todos. Como nas portas do caixa, o
  administrador também só cancela a do próprio caixa, e na personificação opera quem a sessão mostra (a pessoa
  assumida), com quem entrou como o autor. O parâmetro nulo é o fechado: quem chamar o serviço sem passá-lo recebe a
  recusa, nunca cancela o caixa de outro; a despesa da tela e a sangria já cancelada não perguntam por ele.
- **O portão** `scripts/conferir-guarda-do-caixa.php` lê a quarta porta: `CrudController::operadorDoCaixa` tem de pedir o
  mesmo privilégio do caixa do ERP, por argumentos literais, e toda ação de controller que chama `estornarPagamento(` --
  derivadas do código, e comparadas com a lista -- tem de chamá-lo. O ajudante mora no `CrudController`, e não nas ações:
  escrito na ação da API, o `getAcessoDe` do caixa faria `conferir-acl-da-api.php` contar a ação como guardada por ele.

**O que não mudou:** as 17 retiradas ativas com o pagamento da despesa já estornado no financeiro (R$ 5.635,00; 16
delas com a parcela excluída depois, à mão, e nenhuma com a despesa excluída -- as despesas de sangria excluídas na
cópia são zero) estão todas em caixa fechado -- o estado da 171: nada se cancela, e "quem errou corrige por um
lançamento novo". Medido na
`franquiabv_dev` em 14/10/2026 (só contagens): nenhuma retirada cancelada com despesa; nenhuma despesa de sangria com
mais de uma parcela; 8.187 retiradas ativas com despesa; 3 caixas abertos com sangria ativa.

**Provado** em `tests/Caracterizacao/D375SangriaAmarradaTest.php` (30 casos, 9 novos) e no `SaidaSobTravaTest` (o
lançamento da parcela passou a exigir a leitura da retirada com trava, depois da despesa), com os vizinhos -- 453 casos
do filtro do item (`D375|D208|ContasAPagar|Saida|Caixa|ColunasForaDoMapeamento|Retirada|D206D207|D365|D380|RegraDaSaida`),
verdes no MySQL e no PostgreSQL, em série. Os casos: o estorno pelo operador cancela a retirada e exclui a despesa e a
parcela com o autor, pelo serviço, pela tela antiga e pela API; a quitada com motivo segura a despesa, e a tela antiga diz
só o que aconteceu; o estorno da sangria já cancelada (com o caixa aberto, por quem nem tem o privilégio do caixa; e com o
caixa fechado) não cancela nem exclui; a parcela excluída junto não é paga de novo; a despesa da sangria não recebe
parcela nova, nem em lote, e a recebe com a retirada cancelada; a da tela continua recebendo; as duas portas dizem a
frase da parcela nova; o colaborador da unidade, o administrador e o operador sem o privilégio do caixa recebem, pelas
duas portas, "cancele a retirada no caixa", e nada muda; com o caixa fechado, a frase é a do caixa fechado para eles
também; e na personificação opera a pessoa assumida, e quem entrou fica como o autor da exclusão. O outro dinheiro
continua recusado nas quatro portas, com a parcela de fora lançada pelo banco (o estado do que já estiver lançado).

**Vinte e dois mutantes**, cada um sozinho, restaurados pelo sha256, **todos vermelhos nos dois bancos** (a tabela no
roadmap de 14/10/2026); dezenove deste débito: a parcela nova aceita, a retirada lida sem trava no lançamento, a
cancelada segurando a parcela, cada porta sem a frase da parcela nova; o estorno cancelando a retirada de qualquer um, o
ajudante sem o privilégio, o ajudante lendo quem entrou em vez da pessoa da sessão, cada porta sem passar o operador, a
frase de quem não opera sumida e sem olhar quem opera, e quem opera perguntado antes de reler a retirada; o estorno que
cancelou sem excluir (a regra de antes, 7 casos), excluindo sem ter cancelado, o caixa dizendo que cancelou a já
cancelada, a frase da tela antiga presa em cada um dos dois textos, e a despesa saindo com a quitada dentro. E cinco do
portão, com 1: o ajudante pedindo outro bit, sem bit nenhum, com o bit lido de variável, e cada porta de estorno sem
perguntar quem opera. Nada foi aberto num navegador, e nenhum ensaio HTTP rodou.

**A verificação do item (14/10/2026)** achou quatro coisas, corrigidas no mesmo dia, sem esquema:
- **A API do estorno devolvia, com 200, a parcela excluída com cara de conta em aberto** (`quitada` falso, `situacao`
  "aberta"), sem dizer que a retirada foi cancelada nem que a despesa saiu -- o que a tela antiga diz na frase. O
  recurso da parcela (`SaidaJson::parcela`) ganhou o `estado` (`ativa` ou `excluida`, o vocabulário do da despesa), e a
  resposta do estorno (`SaidaJson::estorno`) traz ao lado `saida: {id, estado}` e `retirada` (`null` na despesa da
  tela; `{id, estado}`, `ativa` ou `cancelada`, na da sangria), lidos do banco depois do estorno
  (`SaidaService::retiradaDaDespesa`). O irmão tinha o mesmo defeito, e o mesmo conserto o cobre: a resposta da exclusão
  da parcela também devolvia a excluída como aberta. Os casos: `testAApiRecusaComOCaixaFechadoECancelaComEleAberto` (a
  parcela e a despesa excluídas, a retirada cancelada), o novo `testAApiDizQueADespesaFicaQuandoAQuitadaASegura` (a
  parcela excluída e a despesa ativa: o que separa um `estado` do outro), `ContasAPagarRespostasTest::
  testAApiPedeEGuardaOMotivoDoEstorno` (a despesa da tela: tudo ativo, `retirada` nula) e `SaidaApiEscritasTest::
  testExcluirAParcelaAbertaContinua200`. A `retirada` que sai de um estorno aceito é sempre `cancelada` (o aceito com
  ela ativa a cancela), e o mutante que a fixa em `cancelada` é equivalente; o campo fica como fato, no molde do da
  despesa.
- **O comentário do estorno dizia que 16 das 17 sangrias tinham sido excluídas à mão**: foram as parcelas. Contado de
  novo na `franquiabv_dev` (só contagens): das 17, todas com o pagamento estornado, 16 com a parcela excluída, nenhuma
  com a despesa excluída -- e despesa de sangria excluída não há nenhuma na cópia. O comentário de
  `SaidaService::estornarPagamento` e o "O que não mudou" acima dizem isso agora.
- **O D480 dizia que o texto não numérico ia ao serviço**; recebia "Valor indiponível.". Corrigido lá, com caso.
- **O caso do estorno da sangria já cancelada com o caixa fechado não conferia a despesa nem a parcela**, e o "Provado"
  acima prometia os dois estados. `testARetiradaJaCanceladaComOCaixaFechadoEstornaSoNoFinanceiro` passou a conferir: a
  despesa fica, e a parcela reaberta.

Os casos do D375 passaram a 31 (10 novos no item). **Nove mutantes**, cada um sozinho, restaurados pelo sha256, **todos
vermelhos nos dois bancos** (o filtro `D375SangriaAmarrada|D208RetiradaNoFinanceiro|SaidaSobTrava|ContasAPagarRespostas|
SaidaApiEscritas`, 118 casos): o recurso da parcela sem o `estado`, com ele sempre `ativa`, o `estado` da despesa lido da
parcela, a retirada sempre `ativa`, a ação devolvendo só a parcela (a de antes), a ação sem passar a retirada, a retirada
lida só se ativa, o estorno da sangria já cancelada excluindo a despesa com o caixa fechado, e a tela velha do caixa com
a conferência de antes (vermelha também no caso do "abc", o do D480). E um controle: o penúltimo mutante, contra o caso
do caixa fechado de antes (sem as duas conferências novas), ficou **verde** nos dois bancos -- o caso de antes não o
via.

### D376 — A Cota-parte antiga deixa o caixa e o grupo 9 escolherem qualquer colaborador e qualquer período pela URL, e qualquer um abre a Ver. 2 por `?versao=2` — REGISTRADO em 03/10/2026 — CORRIGIDO em 02/11/2026: a CP4 (a fatia 68) passou o pedido da Cota-parte antiga pela política

Achado pela especificação da Cota-parte (`docs/f3-relatorios/spec-cota-parte.md`, seção 4, D-cp3) e pela
parte comum (`docs/f3-relatorios/spec-comum.md`, seção 4, "Cota-parte: o furo da URL"), e reconferido no
código em 03/10/2026. Lido, não exercitado.

- `exportarAction` usa o `?colaborador=` da URL para o gerente, o caixa e o grupo 9
  (`module/ISRelatorio/src/Controller/VendasController.php:46`), e as datas da URL para todos
  (`:47-48`): o caixa e o grupo 9 tiram o PDF e a planilha da Cota-parte de qualquer colaborador, em
  qualquer período. E quem tem o privilégio abre a Ver. 2 por `?versao=2` (`:29` e `:50`).
- O registro tinha o D158 (o XLS mostra o que o PDF esconde, no mesmo `exportarAction`), e não este
  furo: a escolha de quem e de quando.

**O conserto** (a CP4 da Cota-parte, sem decisão): `PedidoDaCotaParteAntiga::pedido` decide o
colaborador, as datas, a unidade e a versão pelo escopo do relatório -- o caixa vê o dia, e o
colaborador só a si --, e o `scripts/conferir-recorte-por-unidade.php` ganha a vigia de
`exportarAction`. A parte comum dava o furo como "morre com o PDF antigo"; a CP4 o fecha antes.

**Corrigido em 02/11/2026 (a fatia 68, REL-COTA-PARTE-ARQUIVO; a CP4 do `spec-cota-parte.md`).**
`ISRelatorio\Regra\PedidoDaCotaParteAntiga::pedido` (nova, `final`, sem banco) decide o colaborador, as datas, a unidade e a
versão pelo escopo da política `cotaParte` (`RecorteDoRelatorio::escopo`, pelas marcas): o administrador (a rede) escolhe tudo e é
o único com a Ver. 2; o gerente escolhe o colaborador e as datas, na unidade da sessão; o caixa (o `dia`) recebe hoje, de todos,
ignorando a query; o colaborador (o `proprio`) recebe só a si, nas datas pedidas. O `VendasController::exportarAction` e o
`indexAction` (o `?versao=` do formulário) chamam a regra; o nulo é o `notFoundAction` de antes. O `== 9` do grupo 9 saiu (do
`conferir-grupos-por-id.php` também): o grupo 9 cai no `proprio` -- 0 ativos e 1 inativo na cópia (só SELECT, 03/11/2026). Na
cópia, as 356 pessoas que veem o item "Vendas" do ERP antigo continuam vendo o mesmo item (10 na rede, 108 na unidade, 58 no dia
com o PDF do dia, 180 só a si), e ninguém perde o próprio PDF. Provado pelo `PedidoDaCotaParteAntigaTest` (a matriz dos sete
usuários, o caixa esperto, o colaborador que pede outro, a versão 2 só do administrador, e o `indexAction` em processo) e pela vigia
nova do `scripts/conferir-recorte-por-unidade.php` nas duas ações (o token `PedidoDaCotaParteAntiga::pedido(`).
**A ligação no `exportarAction`, provada em processo (a verificação da fatia 68).** A vigia só cobra a PRESENÇA da chamada: uma
linha depois dela que relesse o `?colaborador=`, as datas ou a versão da URL reabria o furo com todos os portões verdes. A geração
saiu para o `VendasController::gerarArquivo` (o `Output('I')` encerra o processo), e o
`PedidoDaCotaParteAntigaTest::testOExportarEntregaAosGeradoresOPedidoDaPolitica` despacha o `exportarAction` com um dublê que guarda
o pedido que chegaria aos geradores, para seis dos sete usuários (o colaborador sem marca fica só na matriz pura: o id dele não
existe nas fixtures). Os três mutantes da releitura (o colaborador, as datas e a versão) ficam vermelhos nele, nos dois bancos.
**O que o D376 não cobre:** as COLUNAS do PDF antigo ainda saem pelo id do grupo (`PdfVendasVersao1`, `grupo == 2 || grupo == 9`):
o grupo 9, que agora cai no `proprio` e recebe só a si, ainda vê no PDF a própria produção ("Valor Total") e não a cota -- o
contrário do `proprio` da tela nova (a pergunta 277 (a), o D158). Antes da 68 ele via isso de qualquer colaborador; a 68 só estreitou
o alcance. A planilha antiga escolhe pelas marcas e não tem isso. Fica com o PDF antigo, até a aposentadoria (o D306), e está escrito
no D671. CORRIGIDO.

### D377 — Os defeitos menores que os desenhos das telas mandam só registrar, sem fatia que os conserte: o cupom cancelado no aplicativo, sete de Unidades, os do mural, o Push e as URLs dos banners — REGISTRADO em 03/10/2026 — PARCIAL em 16/10/2026: o Nova de Unidades só ao administrador — PARCIAL em 20/10/2026: as URLs dos banners na lista antiga e na API nova — PARCIAL em 25/10/2026: na tela nova dos avisos, os grupos que não abrem a agenda vêm marcados — PARCIAL em 07/11/2026: o U18 na tela antiga, e o modal das mensagens — PARCIAL em 08/11/2026: a unidade nova sem a origem nasce com os textos em branco, e a aba abre

Os desenhos das telas restantes (`docs/f3-telas-restantes/`) mandam registrar estes, e não os põem em
fatia nenhuma: o conserto muda o contrato do aplicativo, está fora da migração, espera o dono ou é
menor que uma fatia. Ficam juntos, cada um com o rótulo do desenho; reconferidos no código em
03/10/2026. Quem consertar um o risca daqui, e o débito fecha quando a lista esvaziar.

- **DS-8, Sorteios -- o aplicativo mostra cupom de venda cancelada.** `selecionarCuponsPorSorteioCliente`
  não olha a situação da venda (`module/ISMarketing/src/Entity/SorteioVendaRepository.php:91-101`).
  Medido (C10 de `tela-sorteios.md`): 2 clientes do sorteio #4 só têm cupom de venda cancelada, que não
  pode ganhar. O conserto muda o contrato do aplicativo e pede fixture nova -- as duas fixtures de
  sorteio só têm cupom de venda cancelada.
- **U18, Unidades -- sete menores da tela antiga** (`tela-unidades.md`): ~~o "Nova" aparece a quem só lê
  e leva a 404~~ -- riscado em 16/10/2026 (a fatia UNIDADES-SERVIDOR, o D504): a lista só mostra o Nova, o editar e o
  alternar a quem escreve E é administrador; ~~NPS zero e
  NPS não apurado aparecem iguais (`:117`; na tela nova, desde 16/10/2026, "não apurado" e 0 são diferentes; a lista
  antiga, que o menu do ERP ainda abre, segue igual)~~ -- riscado em 07/11/2026 (a fatia 118); `mensageriaClonar` copia
  os textos da unidade 1 real, ~~fora
  da gravação da unidade~~ -- só esta parte riscada em 07/11/2026 (a fatia 118: a cópia entrou na transação da
  unidade); ~~**a origem segue a unidade 1, e quando ela não tem a linha ativa a cópia não acontece** (o `if ($mensageria)`
  do `mensageriaClonar`; no PostgreSQL, também quando quem cadastra é de outro inquilino, pela RLS de
  `unidades_mensagerias`): a unidade nasce sem os textos, e a aba "Mensageria" dela dá 404 (na cópia do MySQL, 4 das 64
  unidades estão sem a linha ativa). De onde copiar, e o que fazer sem a origem, é a pergunta "De onde a unidade nova
  copia os textos das mensagens" do lote (07/11/2026) (`module/ISConfiguracao/src/Service/UnidadeService.php`,
  `insert` e `mensageriaClonar`)~~ -- riscado em 08/11/2026 (a fatia 138, pela resposta 373 (a) do dono: sem a origem, a
  unidade nova nasce com os textos em branco, e a aba das que já nasceram sem a linha abre em branco); ~~a
  chave do WhatsApp vai num campo de texto comum (`module/ISConfiguracao/src/Form/Unidade.php:354-358`)~~ -- riscado em
  07/11/2026 (a fatia 118: campo de senha; a chave continua no HTML da página de editar, que só o administrador abre);
  ~~o `tipo` é `varchar(30)` no banco e `length=20` no mapeamento
  (`module/ISConfiguracao/src/Entity/Unidade.php:37`; R18)~~ -- riscado em 07/11/2026 (a fatia 118); ~~a aba se chama
  "Menasageria" (`module/ISConfiguracao/view/is-configuracao/unidades/editar.phtml:29`)~~ -- riscado em 07/11/2026 (a
  fatia 118); e o link de boas-vindas é
  gravado e devolvido pela administração, e nada mais o lê (`Entity/Unidade.php:282`).
- **M10, Mensagens -- re-datar um aviso apaga o dia anterior.** Cada aviso tem um dia só
  (`module/ISConfiguracao/src/Entity/Mensagem.php:47-52`); 39 dos 74 foram editados mais de um dia
  depois de criados, e não há registro de quando cada um foi ao ar (`tela-mensagens.md`). Espera a
  pergunta 3 do desenho, o aviso que volta a valer.
- **Mensagens, os menores** (`tela-mensagens.md`): os grupos 9 e 11 aparecem como alvo do aviso e não
  abrem a agenda (`module/ISCadastro/src/Controller/AgendasController.php:169-174`) -- *desde 25/10/2026 (a fatia 38,
  MENSAGENS-TELA), a tela nova os marca "não abre a agenda" no detalhe e nas caixas do formulário, e o "ninguém ativo"
  no grupo sem pessoa ativa (a decisão 4 do desenho: continuam oferecidos); a tela antiga, que o ERP ainda abre, segue
  sem a marca*; a lista antiga
  ordena por `data DESC` (`module/ISConfiguracao/src/Entity/MensagemRepository.php:43`), com o nulo no
  fim no MySQL e no começo no PostgreSQL -- sem efeito enquanto nenhum aviso estiver sem dia --; e ~~o
  modal da lista pergunta "Deseja realmente remover?" para o que só desativa
  (`module/ISConfiguracao/view/is-configuracao/mensagens/index.phtml:157`)~~ -- riscado em 07/11/2026 (a fatia 118: "Deseja
  realmente desativar esta mensagem?"). Os grupos oferecidos por id
  fixo, `IN(1,2,3,6,8)`, já estão no D124.
- ~~**Versão do aplicativo -- o status do Push sem lista** (`tela-versao-app.md`; o Push está fora da
  migração): o select desliga o validador de lista (`module/ISConfiguracao/src/Form/AppPush.php:25`) e
  aceita qualquer texto; só `OneSignal::ativo()` (`module/ISBase/src/Util/OneSignal.php:61`) leria esse
  texto, e ninguém o chama.~~ -- tirado da lista em 07/11/2026 (a fatia 118), sem conserto: o Push está fora da migração,
  e nada lê o texto.
- **Dη, Banners -- as URLs presas ao disco local** (`tela-banners.md`; não confundir com o Dα dos
  Banners, que está no D361): a lista monta `/banners/<arquivo>`
  (`module/ISConfiguracao/view/is-configuracao/app-banners/index.phtml:96`), e o aplicativo recebe
  `sistema.url` mais `banners/` (`module/ISApi/src/Controller/AplicativoController.php:469`);
  `Armazenamento::url()` não tem chamador fora do próprio S3 (`module/ISBase/src/Util/ArmazenamentoS3.php:286`).
  Ligar o S3 (o D33) faria os dois caminhos apontarem para o vazio. É conserto para o dia em que o S3
  for ligado, com o contrato do aplicativo rodando (o endpoint está congelado, o D5).

#### PARCIAL em 20/10/2026 -- o Dη dos Banners, nas duas telas (a fatia 21, BANNERS-SERVIDOR)

A lista antiga (`app-banners/index.phtml`) e a `/adm/api/banners` montam o endereço da imagem por `Armazenamento::url()`, e
não mais por um `/banners/` escrito no código: no disco local o endereço é o mesmo de sempre, e muda junto se o S3 for ligado
(o D33). Provado em processo com um duble que devolve outro domínio (`TelasDosBannersDoErpTest`, `BannersPelaApiTest`), e o
mutante que devolve o `/banners/` escrito fica vermelho nas duas.

**Resta do Dη:** o endpoint do aplicativo, `/api/banners/selecionar`, continua com `sistema.url` mais `banners/` mais a
coluna -- e de propósito: ele está congelado (o D5), e a versão local de `url()` devolve um caminho relativo. Um caso prende o
que o aplicativo recebe hoje (`BannersPelaApiTest::testOAplicativoRecebeOQueSempreRecebeu`: as duas chaves, só os ativos, a
ordem e a URL de sempre, também do banner cadastrado pela API nova), e o mutante que trocasse a URL pela do armazenamento fica
vermelho. Trocá-lo é para o dia do S3, com o contrato do aplicativo rodando. E seguem de pé os outros itens desta lista.


#### PARCIAL em 07/11/2026 -- os menores do U18 e o modal das mensagens (a fatia 118, UNIDADES-TELA-ANTIGA)

Riscados, cada um com caso em processo nos dois bancos (`TelasDasUnidadesDoErpTest`, salvo onde se diz):

- ~~NPS zero e não apurado iguais na lista antiga~~: o nulo aparece "não apurado", e o zero "0"
  (`unidades/index.phtml`; `testAListaAntigaDistingueNpsZeroDeNaoApurado`, os dois e o 8,5).
- ~~`mensageriaClonar` fora da gravação da unidade~~: `UnidadeService::insert` grava a unidade e a cópia dos textos numa
  transação só; a falha NA CÓPIA desfaz a unidade, e quem chama recebe o erro, como recebia o da unidade (antes, a unidade
  ficava sem a linha da mensageria, e a aba dela dava 404 para sempre). Vale para o cadastro do ERP e para o `POST
  /adm/api/unidades`, que passam pelo mesmo `insert`. **Só a falha:** a origem continua a unidade 1, e quando ela não tem a
  linha ativa (ou, no PostgreSQL, a RLS a esconde de quem cadastra) a cópia não acontece, a unidade grava sem os textos, e
  a aba dá 404 como antes -- segue de pé, com a pergunta ao dono (a verificação da fatia). `testONovoGravaAUnidadeEOsTextosTudoOuNada` força a falha na cópia (um ouvinte do `onFlush`), com pontos de
  salvamento, e confere que a unidade sumiu; o controle nasce com os textos.
- ~~a chave do WhatsApp num campo de texto comum~~: campo de senha, com `autocomplete="new-password"`, no editar e no novo. O
  campo é escrito na view, e não pelo `formElement` com `type=password`: o `FormInput` do Laminas apaga o valor do campo de
  senha ao desenhar, a página voltaria com a chave vazia, e salvar a apagaria (é o mutante M1, vermelho). O valor sai pelo
  `escapeHtmlAttr` da view, que antes era do `formElement`: a chave com aspas e `&` volta inteira no atributo, no editar e no
  novo (`testOValorDaChaveDoWhatsappSaiEscapado`, da verificação; os mutantes sem o escape, vermelhos). **Fica:** a chave
  continua no HTML da página de editar, que só o administrador abre; não devolvê-la pediria decidir como se apaga a chave.
- ~~o `tipo` com `length=20` no mapeamento~~: 30, o da coluna nas duas cópias e no `postgres/schema.sql`
  (`testOTamanhoDoTipoEODaColuna` lê o `information_schema` de cada banco).
- ~~a aba "Menasageria"~~: "Mensageria", no editar e na página dos textos.
- ~~o modal da lista antiga de mensagens pergunta "Deseja realmente remover?" para o que só desativa~~: "Deseja realmente
  desativar esta mensagem?" (`D199AtivarRemoverPorPostTest`, o caso das mensagens).

**Segue de pé, e por isso PARCIAL:** o DS-8 (o contrato do aplicativo, congelado pelo D5), o M10 (espera a pergunta 3 do
desenho de mensagens), a origem dos textos da unidade nova e a cópia que não acontece sem ela (a pergunta "De onde a
unidade nova copia os textos das mensagens", ao lote em 07/11/2026; a escolha do modelo é do dono), o resto do Dη (o endpoint do aplicativo, para o dia do S3), o link de boas-vindas sem leitor, a ordem
do nulo na lista antiga de mensagens (sem efeito enquanto nenhum aviso estiver sem dia) e os grupos 9 e 11 sem a marca na tela
antiga das mensagens. O status do Push sai da lista: o Push está fora da migração (`tela-versao-app.md`), e nada lê o texto.

**Riscados no lugar** (a verificação da fatia 118): cada item consertado está riscado também na lista do começo, como o Nova
de 16/10/2026, para quem a ler não o achar de pé.

#### PARCIAL em 08/11/2026 -- a unidade nova sem a origem nasce com os textos em branco, e a aba abre (a fatia 138, RESPOSTAS-DE-08-11-API-E-CADASTRO)

**A resposta 373 do dono** (08/11/2026) foi a **(a)**: *"Da unidade 1, e em branco quando ela não tiver: como hoje; sem os textos
dela, a unidade nova nasce com os textos vazios, a aba abre, e alguém os escreve."* Fechou a parte do U18 que esperava a pergunta.

- **O cadastro:** `UnidadeService::mensageriaClonar` copia a linha ativa da unidade 1, como antes; sem ela -- a unidade 1 sem a
  linha ativa ou, no PostgreSQL, escondida pela RLS de quem cadastra de outra rede --, grava a linha em branco
  (`mensageriaEmBranco`: ativa, os quinze textos nulos), na mesma transação da unidade (a da fatia 118). Vale para o ERP e para o
  `POST /adm/api/unidades`, que passam pelo mesmo `insert`.
- **As que já nasceram sem a linha** (4 das 64 unidades da cópia, as 4 ativas; contado só com `SELECT` no `franquiabv_dev`, nos
  dois bancos, em 08/11/2026): a aba "Mensageria" (`UnidadesController::mensageriaAction`) não dá mais 404 -- abre com o
  formulário em branco, e **só grava a linha quando alguém salva os textos**. Abrir não escreve nada, e nenhuma linha foi escrita
  em banco nenhum: as 4 ganham a linha quando um administrador as escrever pela tela. O 404 fica para a unidade que não existe ou
  que a RLS esconde, e o id que a coluna não guarda não vai mais ao banco (`IdDaColuna`, o D516). O salvar não hidrata mais o
  `id`, a `unidade`, o estado nem as datas do POST: a linha é a que a aba achou pela rota, ou a nova daquela unidade. **Pela
  lista dos textos, e não tirando as outras** (a verificação da fatia 138): o `unset` das chaves em minúsculas deixava passar
  `Status=0` e `DATA_CRIACAO`, porque o hidratador monta o setter por `'set' . ucfirst($chave)` e o PHP chama o método sem olhar
  a caixa -- um administrador com o POST montado à mão desativava a linha (as mensagens automáticas da unidade paravam e a aba
  voltava a abrir em branco) ou refazia a data de criação. Agora `UnidadeService::mensageria` passa ao hidratador só as quinze
  chaves de `UnidadeService::TEXTOS_DA_MENSAGERIA`.
- **As mensagens com o texto em branco não saem**, como a pergunta dizia -- mas duas, não: o crédito do programa usado e o cupom
  gerado (`VendaPagamentoService`) gravavam um `PushApp` sem tipo quando os dois textos do canal estavam vazios, e o `flush`
  dava `tipo cannot be null` no fim da venda (achado pelo caso novo; nenhuma linha da cópia tinha os dois textos em branco, e a
  linha em branco da unidade nova os teria). Agora só gravam o push com tipo, o molde do resgate no `ClienteService` ("SEM
  MODELO, SEM PUSH", de 28/09/2026). O "cliente chegou", o código e o resgate já não saíam. **As rotinas automáticas que a
  pergunta cita** (a verificação da fatia 138) -- o parabéns (`notificarAniversario`), a pesquisa da primeira visita
  (`clientesNotificarPrimeiraVisita`), o "sentimos sua falta" (`clientesNotificarSentimosSuaFalta`) e o NPS da venda
  (`npsNotificacoes`) -- também não saem: a linha em branco é ativa, e por isso as consultas delas (`JOIN un.mensageria`) passam a
  achar os clientes da unidade, mas cada uma testa o texto vazio do canal antes de gravar o push. Agora rodadas no banco da suíte,
  e não só lidas.
- **Provado** nos dois bancos: `TelasDasUnidadesDoErpTest` (o cadastro sem a origem nasce com a linha em branco e a aba a abre; no
  PostgreSQL, quem cadastra de outra rede ganha a linha em branco na rede dele -- e, se a conexão ignorar a RLS, como o
  superusuário do job do CI, vê a unidade 1 e copia; a aba da unidade sem linha abre em branco, não escreve ao abrir, grava ao
  salvar, não segue o `id` nem a `unidade` do POST e atualiza a mesma linha na segunda vez; a unidade que não existe e o id
  grande continuam 404) e `D351MensagensDoBalcaoTest::testComOsTextosEmBrancoNenhumaMensagemDoBalcaoSai` (as cinco do balcão,
  com tudo o que as faria sair menos o texto, e o cupom gerado como controle). Os mutantes, um por vez e devolvidos pelo sha1,
  vermelhos nos dois bancos: o cadastro sem a linha em branco, a aba de volta ao 404, o push do crédito e o do cupom sem a guarda,
  e o hidratador de volta ao `id` do POST. **Na verificação**: `TelasDasUnidadesDoErpTest::testOPostDaAbaSoGravaOsTextosEmQualquerCaixa`
  (`Status`, `STATUS`, `Data_criacao`, `DATA_CRIACAO`, `Unidade`, `UNIDADE`, `Id` e as minúsculas: a linha continua ativa, da
  mesma unidade, com a data de criação de antes) e `D351UnidadeDesativadaTest::testComOsTextosEmBrancoAsRotinasNaoMandam` (as
  quatro rotinas, com a unidade ativa, o WhatsApp ligado e o cliente no aplicativo, não enfileiram nada; o controle: com os
  textos escritos na mesma linha, cada uma sai pelo WhatsApp). Mutantes vermelhos nos dois bancos: o `unset` das minúsculas no
  lugar da lista, a lista com `data_criacao`, a lista com `status`, sem lista nenhuma, e cada uma das quatro rotinas sem o teste do
  texto vazio do WhatsApp.

**Segue de pé, e por isso PARCIAL:** o DS-8 (o contrato do aplicativo, congelado pelo D5), o M10 (espera a pergunta 3 do desenho de
mensagens), o resto do Dη (o endpoint do aplicativo, para o dia do S3), o link de boas-vindas sem leitor, a ordem do nulo na lista
antiga de mensagens e os grupos 9 e 11 sem a marca na tela antiga das mensagens.

### D378 — O passo 8 do corte rodava a suíte de caracterização na produção, e a mensagem dela mandava carregar as fixtures lá — PARCIAL em 03/10/2026 — CORRIGIDO em 11/10/2026

**Achado no levantamento dos débitos que não precisam do dono (03/10/2026), conferido ao escrever o
CORTE-ROTEIRO.** O passo 8 de `docs/procedimento-de-corte.md` era
`docker compose exec app phpunit --testsuite caracterizacao`, com "espera-se verde inteiro", logo
depois de o `local.php` passar a apontar para o PostgreSQL de produção. A suíte não passa ali, e não
por regressão:

- `CaracterizacaoTestCase::exigirFixtures` procura a unidade 90001000 e, sem ela, para **todos** os
  casos com *"Aplique antes de rodar a suíte"* e o comando que carrega `tests/Caracterizacao/fixtures.sql`
  -- 95 `INSERT` em 44 tabelas: unidades, usuários, clientes, vendas, 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, e a suíte continua vermelha, porque lê o PostgreSQL. Adaptado, grava no banco que vai ao
  ar.
- Com as fixtures, a conferência do cenário 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 --
  o de produção, naquele passo.

**O ensaio geral tem o mesmo nó**, pela leitura dos roteiros (não rodado: ele roda a carga). As fases
`caracterizacao` e `volta_atras` de `scripts/ensaiar-corte.sh` rodam a mesma suíte; a fase
`conferencia` para com 2 desde 05/09/2026 quando acha linha da faixa das fixtures nos bancos. Sobre a
cópia do desenvolvimento, que tem as fixtures, o ensaio para na conferência; sobre uma cópia da
produção, para na suíte. O ensaio de 27/08 é anterior à guarda da conferência.

**O que foi feito:** o passo 8 diz, em maiúsculas, para **nunca** carregar as fixtures na produção, e
não roda nada contra o banco de produção; o portão de comportamento da janela fica sendo o teste manual,
que ganhou a entrada com maiúscula no login (D349) e a busca sem acento (D119). Os números do passo
foram remedidos: 1.611 casos em 161 arquivos (eram 109), e 67 de contrato (eram 35). A tabela "O que
já está automatizado" e o `ensaio-geral-do-corte.md` dizem o mesmo.

**A revisão do mesmo dia achou o teste manual sem regra de falha**: o "qualquer falha aqui é motivo de
volta atrás" tinha saído com a suíte. Voltou, para o teste manual, com a única exceção da busca sem
acento quando o 7c for pulado. E a entrada com maiúscula reprovaria por construção sem o código do
D349, que agora é o 6 de "O que hoje bloqueia".

**O que resta, e é decisão do dono** (lote de perguntas, 03/10/2026): o que entra no lugar da suíte
na janela -- tirá-la e ficar com o ensaio geral, as provas da carga e dos papéis, o 7b e o teste manual;
rodá-la num clone do PostgreSQL carregado, com as fixtures só no clone; ou um subconjunto só de
leitura --, e o que muda nas duas fases do ensaio. `CaracterizacaoTestCase` e `ensaiar-corte.sh` não
foram mexidos.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 7): a opção (a), "Tirá-la da janela"
-- ficam o ensaio geral, as provas da carga e dos papéis, o 7b e o teste manual. Conferido no
procedimento: o passo 8 já não rodava nada contra a produção, e passou a dizer que foi decidido; a
tabela "O que já está automatizado", o "Quanto tempo a janela precisa" e o `ensaio-geral-do-corte.md`
dizem o mesmo. **O que resta deste débito é do ensaio:** as fases `caracterizacao` e `volta_atras` de
`scripts/ensaiar-corte.sh` continuam paradas pelo mesmo nó, e o clone do PostgreSQL carregado, com as
fixtures só nele -- o caminho que o lote recomendou para o ensaio --, não está escrito.
`CaracterizacaoTestCase` e `ensaiar-corte.sh` não foram mexidos.

#### CORRIGIDO em 11/10/2026 -- o item RESPOSTAS-SEM-RODADA

**O conserto** (o clone que a recomendação da pergunta 7, seguida pelo dono, deixou para o ensaio: "(a)
na janela, e o clone de (b) no ensaio geral, sobre a cópia"; sem decisão nova): as duas fases da suíte de
`scripts/ensaiar-corte.sh` rodam em clones, com as fixtures só neles, e as origens ficam como a carga as
deixou.

- **`clone_pg`**, depois das provas dos papéis: `CREATE DATABASE <BANCO_PG>_suite TEMPLATE <BANCO_PG>`. O
  TEMPLATE não copia as configurações do banco nem os privilégios dele: a fase regrava as duas que o
  `postgres/schema.sql` grava com `ALTER DATABASE` (o `search_path` e o fuso), **para se o clone não ficar
  com as mesmas da origem**, roda o `criar-papeis.sh` sobre o clone, e carrega as fixtures e as sequências
  como o `rodar-caracterizacao-postgres.sh`.
- **`caracterizacao`**: a suíte com um `local.php` próprio, o do PostgreSQL com o `dbname` do clone
  (`apontar_para_o_clone`); o da fase `apontar` volta no fim, passe a suíte ou não.
- **`clone_mysql`**, antes da volta atrás: a mesma regra para o outro banco, porque a suíte da volta atrás
  também pede as fixtures e elas também não podem ir à origem (mudariam a impressão digital, e a segunda
  execução sairia anulada). O MySQL não tem TEMPLATE: é o `mysqldump` da origem num banco novo com o charset
  dela, sem os eventos, e as fixtures por cima; o pipe roda num `bash -o pipefail`, para o dump que morre
  no meio derrubar a fase.
- **`volta_atras`**: o `local.php` restaurado, a semente e o conferidor leem a origem, como antes; a suíte
  roda no clone do MySQL, com o `local.php` dele, e o restaurado volta no fim.
- **`apagar_clones`**, fora do `TOTAL`: os dois saem. O trap passou a restaurar também o `local.php` do
  clone do MySQL (ele tem o host `mysql`, e o trap de antes o dava por restaurado).
- Os nomes são `<banco>_suite`, recusados com caractere fora de `[a-z0-9_]` ou com "prod" (até a verificação
  abaixo, `CLONE_PG` e `CLONE_MYSQL` os trocavam). O `comparar-ensaios.sh` cronometra as duas fases novas.
- **O veredito da suíte saía em branco:** os portões do fim só liam "OK (N tests, M assertions)", e com
  caso pulado -- a suíte de hoje pula três, de propósito, no D199 -- o PHPUnit 10 escreve "OK, but some
  tests were skipped!" e "Tests: N, Assertions: M, Skipped: K."; o comparador aceita branco contra branco
  calado. `veredito_da_suite` lê as duas formas.
- O `ensaio-geral-do-corte.md`, o `procedimento-de-corte.md` e o comentário do
  `UsuarioHorarioVigenciaTest` que dizia que a volta atrás roda a suíte no `franquiabv_dev` dizem o mesmo.

**Provado sobre bancos descartáveis** (`d378_origem` nos dois servidores, com a estrutura de hoje, uma
unidade inventada abaixo de 90000000 e nenhuma fixture; apagados no fim), rodando as funções tiradas do
próprio roteiro, iguais byte a byte, com uma troca só na cópia: um `--filter` de quatro arquivos na suíte.
**28 provas verdes:** o clone do PostgreSQL nasce com as fixtures e a unidade da origem, com as mesmas
configurações (o fuso `America/Sao_Paulo` numa sessão nova), o `bvip_app` entra e o `PUBLIC` não cria
temporária, como na origem, que continua sem fixture; a suíte nele passa (67 casos, 299 asserções) e o
`local.php` volta ao da fase `apontar`; o clone do MySQL tem as 86 tabelas da origem e a da marca, o
charset dela (`utf8mb3_general_ci`) e nenhum dos eventos que o `mysql.sql` deu a ela, e a origem fica sem fixture; a volta
atrás passa (67 casos) e o `local.php` volta ao guardado; os dois clones saem; o dump que morre no meio
(uma vista com a tabela apagada) derruba a `clone_mysql`; o trap devolve o `local.php` guardado depois do
`local.php` de cada clone; e o veredito lê as duas formas. **Sete mutantes**, cada um sozinho na cópia,
todos vermelhos: sem as configurações (7 provas, e com isso se viu que o TEMPLATE não as copia: o clone
ficava em `Etc/UTC` e sem o `bvip` no caminho), sem o `criar-papeis.sh` no clone (1, o `PUBLIC` com
temporária), as fixtures na origem (3), a suíte sem o `local.php` do clone (1: ela leu a origem), o dump
sem o `pipefail` (1: o clone pela metade passou), o trap de antes (1) e o veredito de antes (1). E as três
recusas de nome, rodando o roteiro de verdade: saída 1 antes de qualquer escrita.

**Não provado:** o ensaio inteiro, que escreve no MySQL da cópia (a `limpeza` e o `passo3`) e não rodou
aqui; sobre a cópia do desenvolvimento ele continua parando na `conferencia`, que recusa a faixa das
fixtures que a cópia tem. A suíte inteira nos clones (só os 67 casos dos quatro arquivos). E o tamanho da
produção: o tempo e o disco das duas fases novas -- a `clone_mysql` custa um dump e uma carga do MySQL
inteiro -- são do laboratório do BVIP, pela mão do dono.

#### A verificação do mesmo dia (11/10/2026)

Cinco achados no `scripts/ensaiar-corte.sh`, conferidos no código, e o conserto de cada um:

- **O local.php do clone do MySQL passava pela guarda do `apontar`.** O trap tinha aprendido a reconhecê-lo,
  mas a guarda irmã conferia só `'host' => 'mysql'`. Uma execução morta à força no meio da suíte da volta
  atrás (SIGKILL, a queda do aplicativo: o trap não roda) deixa o `local.php` do clone e o guardado bom; na
  seguinte, sobre uma cópia da produção -- justo o caso deste débito --, a guarda passava, o `local.php` do
  clone ia por cima do guardado, o conferidor da vigência da volta atrás media o clone recém-feito, e no fim o
  `local.php` apontava para um banco apagado, com o original perdido. **Agora** a guarda pede o `local.php`
  da origem pelo host E pelo banco (`local_php_e_o_da_origem`: `'dbname' => '<BANCO_MYSQL>'`), e roda no
  **começo do roteiro**, antes da `limpeza` e do `passo3`, que escrevem na cópia (e o `passo3` só se mede na
  primeira execução) -- e de novo no `apontar`, porque entre um e outro passam horas de carga.
- **O `apagar_clones` deixava o GRANT do clone do MySQL**: o `DROP DATABASE` não tira o privilégio, e a prova
  deste débito tinha deixado a linha `d378_origem_suite` em `mysql.db` (o banco não existia mais). **Agora** o
  fim faz `REVOKE IF EXISTS ALL ON <clone>.* FROM <usuário do app>`; a linha que a prova deixou saiu com o
  mesmo comando, rodado aqui no MySQL 8.4.11 (e de novo, sem o GRANT: só o aviso 1141, saída 0).
- **"As três recusas de nome saem com 1 antes de qualquer escrita" não valia sempre.** O trap estava armado
  desde a primeira linha e copiava o `ensaios/local.mysql.guardado` -- que nenhuma execução apaga, e pode ser
  velho -- sempre que o `local.php` não parecesse o da origem. **Agora** o trap só age depois que a fase
  `apontar` trocou o `local.php` (`MEXI_NO_LOCAL_PHP`), e compara o arquivo byte a byte com o guardado em vez
  de adivinhar pelo host: desfaz o do PostgreSQL, o do clone dele e o do clone do MySQL, e antes do `apontar`
  não escreve nada.
- **Um `CLONE_*` com o nome de outro banco vivo era apagado**: as guardas só comparavam o clone com a própria
  origem, e `BANCO_MYSQL=franquiabv_copia CLONE_MYSQL=franquiabv_dev` passava por elas e apagava o banco do
  desenvolvimento na `clone_mysql`. **Agora** o nome do clone não vem do ambiente: é sempre `<banco>_suite`, e
  a recusa do "nome igual ao da origem" saiu, porque deixou de poder acontecer.
- **Nenhum portão ficava vermelho se o conserto fosse desfeito**: a única prova era o roteiro do rascunho.
  **Agora** há o `scripts/ensaiar-clones-do-ensaio-de-corte.sh`, no `conferir-tudo.sh` e no CI: roda o
  roteiro inteiro, com `sh`, num diretório temporário, com dublês do `docker` e do `php` que anotam cada
  pedido com o banco do `local.php` daquele instante (o `psql` dublê guarda as configurações que o `ALTER
  DATABASE` grava e o TEMPLATE não copia; o dump roda o texto do roteiro, com o `pipefail` dele, sobre um
  `mysqldump` de mentira). **11 casos**: o ensaio inteiro verde (as fixtures só nos clones, a suíte duas vezes,
  cada uma no `local.php` do seu clone, o conferidor da volta atrás na origem, o `criar-papeis.sh` no clone,
  só o clone apagado, o REVOKE depois do último GRANT, os vereditos com caso pulado e o `local.php` igual no
  fim); a suíte vermelha em cada clone; o `local.php` do clone do MySQL e o do PostgreSQL deixados por uma
  execução morta (nenhum pedido, nada escrito); o `local.php` trocado no meio da carga (o `apontar` recusa, e
  nada é guardado); as duas recusas do começo com um guardado velho (nada escrito); o `CLONE_*` do ambiente
  ignorado; o dump que morre no meio; e a configuração da origem que o clone não recebe. Não toca banco
  nenhum. **Quinze mutantes**, cada um numa cópia do roteiro passada ao ensaio, **todos vermelhos**: sem a
  guarda do começo, com a do `apontar` só pelo host (e os dois juntos), sem o REVOKE, o trap de antes, o
  `CLONE_*` do ambiente, a suíte de cada fase na origem, as fixtures na origem em cada banco, o dump sem o
  `pipefail`, o clone sem as configurações, sem o `criar-papeis.sh` no clone, o veredito de antes e o trap que
  nunca age. O caso que derrubou cada um está na entrada do item no `docs/roadmap.md`.

**Achado de passagem, e registrado à parte:** o laboratório do BVIP sobe com o `local.php` do modelo do Docker,
que lê o host e o banco do ambiente (`getenv`), e as guardas deste roteiro procuram os dois escritos. Lá o ensaio
nunca passou do `apontar` -- já antes deste débito --, e agora para no começo. É o **D469**; o clone deste
débito só roda lá depois dele.

### D379 — O roteiro de carga do corte estava velho: não copiava quatro colunas, as três `hash_anterior*` e duas tabelas, e o procedimento não mandava regerá-lo — CORRIGIDO em 03/10/2026

**Achado ao simular o passo 1 do corte para o D323.** `scripts/migrar-dados.sh` é **gerado** por
`scripts/gerar-migracao-dados.php` a partir do `mysql.sql` -- as listas de colunas de cada `SELECT` e
de cada `\copy` --, e o do repositório era de 27/08, mexido à mão uma vez em 08/09 para a `iframe_bi`.
Regerado contra o `mysql.sql` de hoje, ele ganhou `clientes_sessoes`, `usuarios_horarios`,
`vendas.autorizado_por`, `vendas.autorizado_em`, `pontuacoes_historicos.autorizado_por`,
`unidades.pontuacao_validade_dias` e as três `hash_anterior*` (14 linhas novas e 6 trocadas). Rodado como
estava, o PostgreSQL receberia essas colunas vazias ou com o padrão, sem erro nenhum, e a prova 1 de
`conferir-migracao.php` acusaria `clientes_sessoes` e `usuarios_horarios` em qualquer cópia em que elas
tenham linha -- a do desenvolvimento tem 2.432 em `usuarios_horarios` (contadas em 03/10).

E o passo 4 do procedimento regerava só o schema, embora o passo 1 refaça o `mysql.sql`.

**O que mudou:** `scripts/migrar-dados.sh` regerado pelo gerador, sem edição (o mesmo arquivo que o
gerador produz hoje, byte a byte); e o passo 4 roda os dois geradores, com o porquê. Em produção essas
colunas estão vazias ou no padrão até o corte -- o código antigo não as escreve --, então nada se
perderia desta vez.

**O que não foi provado:** a carga regerada não foi rodada -- `migrar-dados.sh` não roda aqui. O que
se provou é que o gerador, rodado sobre a estrutura de produção com o passo 0 aplicado (o mesmo
ensaio do D323), produz o mesmo roteiro que o do repositório, menos a `iframe_bi` de `unidades`.
*("Com o passo 0 aplicado" era inexato até a revisão de 03/10/2026: a estrutura do ensaio não tinha a
`sessoes`, que o passo 0 de então mandava aplicar. Com o passo 0 de agora, que não a aplica, é
exato.)* **Pela leitura do roteiro, e não rodado:** uma coluna que o MySQL não tenha faz o `SELECT`
daquela tabela falhar calado -- o `mysql_` manda o erro para o nulo --, o `\copy` recebe zero linhas, e
o `conferir()` aceita `COPY 0`. A carga "passa" com a tabela vazia, e só a primeira prova de
`conferir-migracao.php` acusa. O portão de corte segura; o roteiro sozinho, não. Nenhum
portão compara o `migrar-dados.sh` com o gerador; o `conferir-inventario-atual.sh` faz isso para o
inventário de ações, e é o molde, se um dia valer a pena.

### D380 — O "Quitar" não deixa marca na venda: na comanda reaberta os débitos voltam a aparecer e somam em dobro, ou ficam abertos com o cliente pagando — PERGUNTA ao dono em 03/10/2026 — CORRIGIDO em 06/10/2026 — PARCIAL em 06/10/2026: falta a tela nova — PARCIAL em 08/10/2026: a tela nova lê a marca e manda a bandeira; falta a comanda com débito novo depois do "Quitar", fechada nela (pergunta ao lote) — PARCIAL em 10/10/2026: a tela nova avisa (a 54) e o ERP recusa (a 55); falta o ERP com o débito novo (pergunta ao lote) — PARCIAL, verificado em 10/10/2026: mais duas perguntas ao lote — CORRIGIDO em 12/10/2026 — PARCIAL, verificado em 12/10/2026: falta Devedores (lote) — DECIDIDO em 12/10/2026: Devedores e a venda em andamento abandonada ficam como estão (as respostas 160 e 161)

> **Revisto em 12/10/2026, na verificação do mesmo item:** o D380 segue PARCIAL. A dívida recebida pela tela de
> Devedores depois do "Quitar" ainda sai duas vezes -- o mesmo dinheiro da 79, por outra porta, escrito aqui desde 06/10
> e que não tinha ido ao lote --, e dois "Quitar" no mesmo instante ainda passam juntos pela consulta da 79; os dois foram
> ao lote numa pergunta só. A frase da 79 na venda em andamento passou a dizer quem a solta, e a venda em andamento de
> quem nunca volta ao PDV também foi ao lote. Ver "A verificação da quarta rodada", no fim deste débito.
>
> **12/10/2026, o item PDV-QUITAR-E-PACOTE:** o dono respondeu as três perguntas da terceira rodada. Na **77**, a (b),
> "Como está": o finalizar do ERP sem a quitação, com os débitos somados em parte, cobra a parte somada e deixa os
> débitos abertos -- é a regra, e o ERP não mudou. Na **78**, a (a): o finalizar da API recusa uma vez, com 409
> `debitos_em_parte`, até o corpo trazer o somado que a tela mostrou, e a tela nova relê e mostra o aviso da 54. Na
> **79**, a (a): o "Quitar" das duas portas recusa quando outra venda aberta do cliente na unidade já tem os débitos
> somados. Feitas as três, o PARCIAL fecha. Ver "A quarta rodada", no fim deste débito.
>
> **10/10/2026, o item PDV-TERCEIRA-RODADA:** o dono respondeu as perguntas 54 a 56 (a opção (a) nas três).
> A comanda da API diz os débitos somados em parte, e a tela nova avisa e fecha sem a bandeira (a 54); o
> finalizar do ERP sem a quitação, com os débitos já somados, recusa (a 55); e o centavo da remoção é o D450,
> corrigido. Resta a variante da 55 com um débito novo no meio, que foi ao lote. Ver "A terceira rodada", no
> fim deste débito.
>
> **08/10/2026, o item PDV-QUITAR-SEGUNDA-RODADA:** a tela nova lê a marca e manda a bandeira (a 41), e
> nada do item mudou o "Quitar" que soma só o débito novo (a 43). Resta um caso da tela nova -- a comanda
> com um débito que nasceu depois do "Quitar" do ERP, fechada nela --, que foi ao lote como pergunta. Ver "A
> tela nova", no fim deste débito.
>
> **Revisto em 08/10/2026, na verificação do mesmo item:** a tela que abriu a comanda antes do "Quitar" do
> ERP e finalizou sem reler mandava o corpo sem a bandeira, e o finalizar passava -- o defeito da 41 de volta
> por uma corrida. Agora é 409 `debitos_somados`, e a tela relê a comanda. A marca passou a ler os itens
> arredondados ao centavo (o meio centavo do desconto parecia débito somado), e seis escritas da comanda
> ganharam o caso que exige o campo. O PARCIAL continua, com duas linhas a mais no lote: o 422 que se repete
> quando um débito nasce com a comanda aberta na tela nova, e o finalizar do ERP sem a bandeira, que não
> ganhou a recusa. Ver "A verificação", no fim deste débito.
>
> **Revisto em 06/10/2026, na verificação do item:** o ERP está consertado, e a tela nova não. A comanda em
> que o "Quitar" do ERP somou os débitos, fechada pela tela nova, cobra os débitos no total e os deixa
> abertos -- a tela nova nunca manda `pagarDebitos`, e a comanda que ela lista não diz que os tem. Falta
> isso, e é pergunta ao dono (o lote de 06/10/2026). Ver "A revisão", no fim deste débito.
>
> **08/10/2026:** o dono respondeu (página do lote, perguntas 41 e 43, 07/10/2026): a tela nova lê a marca e manda a bandeira, e o débito novo soma só ele, como está. Falta a tela nova, na fila -- ver o fim deste registro.

**Achado ao consertar o D319, lido no código, não medido.** O total da venda é a única marca de que os
débitos foram somados, e nada a lê:

- **a comanda reaberta.** `VendasController::comandaAction` mostra o bloco dos débitos sempre que o
  cliente deve (`div_debitos`, em `comanda.phtml`), e a bandeira `modfin_debitos_add` nasce vazia. Quem
  clicou "Quitar", saiu e voltou vê os débitos de novo, com o total já somado: clicar outra vez soma em
  dobro; não clicar finaliza sem `pagarDebitos`, e o cliente paga os débitos no total e eles continuam
  abertos. No `/vendas/novo` não acontece: recarregar apaga a venda temporária.
- **o duplo clique.** O botão "Quitar" troca o ícone e não se desabilita: dois pedidos somam duas vezes.
  Desde o D319 o servidor soma o devido, mas não sabe que já somou.
- **a API nova** também soma sempre que há devido (`adicionarDebitosAction`); o finalizar dela recusa a
  bandeira sem a soma (o D159), e não a soma em dobro.
- **o finalizar do ERP** aceita `pagarDebitos` sem "Quitar" nenhum -- a metade do D159 que ficou de fora
  de propósito, quando a recusa no balcão era um risco que a API nova não tinha.

**O que precisa do dono** (no lote de perguntas de 03/10/2026): como marcar a soma -- uma coluna em
`vendas`, que é mudança de esquema, ou o proxy do D159 (o excedente do total sobre os itens) --, e se o
finalizar do ERP passa a recusar a bandeira sem a soma, como a API.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 14): na marca, "(b) com (c)" -- o
proxy do D159 (o que o total tem acima da soma dos itens), sem esquema, e a tela: o "Quitar"
desabilitado durante o pedido, e o bloco dos débitos escondido na comanda reaberta quando o total já os
cobre; na recusa, "Sim" -- o finalizar do ERP passa a recusar `pagarDebitos` sem a soma, como a API nova
(lá 422; aqui `sucesso: false` e a frase). ~~A implementação está na fila.~~ (feita em 06/10/2026, logo
abaixo)

#### O "Quitar" com marca — CORRIGIDO em 06/10/2026, o item PDV-QUITAR-E-COMANDA

**O conserto**, pela decisão de 06/10/2026, num lugar só: `ISVenda\Regra\RegraDosDebitosNaVenda`, sem
consulta ao banco -- quem chama passa o devido, com o recorte da unidade da sessão. A marca é o que o total
da venda aberta tem acima da soma dos itens (o proxy do D159); a regra diz, no cabeçalho, que ela não vale
para o histórico.

- **O "Quitar" não soma de novo**, nas duas portas (`VendasPagamentosController::pagarDebitosAction` e
  `Adm\ComandasController::adicionarDebitosAction`): com o total já cobrindo o devido, a resposta é a do
  primeiro pedido -- no ERP `sucesso` e o total, na API 200 com a comanda -- e nada é gravado. O débito que
  nasce depois do "Quitar" soma só ele: somar o devido inteiro de novo cobraria duas vezes o que já estava
  no total. (Isso não estava na pergunta; é a leitura da marca escolhida, e o único jeito de não cobrar em
  dobro sem recusar o balcão.)
- **A tela:** o "Quitar" se desabilita durante o pedido, e a função também confere (`pdvPagarDebitos`, no
  `mod-vendas.js`, que o layout passou a servir com `?cache=165`); a comanda reaberta com o total já
  cobrindo os débitos sai como o JavaScript deixa a tela depois do clique -- o bloco escondido e a bandeira
  `pagarDebitos` ligada (`VendasController::comandaAction` e `comanda.phtml`). Esconder o bloco sem ligar a
  bandeira levaria todo finalizar dessa comanda a não baixar nada. Sem "Quitar", ou com um débito que
  nasceu depois dele, o bloco aparece, como antes.
- **O finalizar do ERP recusa a bandeira sem a soma** (`validarFinalizarVenda`): `sucesso: false` e *"Esta
  venda não cobre os débitos que seriam quitados.<br/>Clique em "Quitar" para somar os débitos à venda
  antes de finalizar."*. O devido conferido é o das linhas que o finalizar baixa: o cliente da venda, na
  unidade da sessão. O finalizar da API faz a mesma conta pelo mesmo método, e responde como antes (422).

**Provado** em `D380QuitarComMarcaTest` (14 casos; 18 desde a revisão, abaixo), verdes nos dois bancos.
Contra o código de antes, 8 vermelhos -- o segundo "Quitar" na venda em andamento e na comanda, o do débito novo, as duas recusas do
finalizar, a comanda reaberta (a página, e o balcão que a reabre e finaliza) e o segundo `POST .../debitos`
da API -- e os 6 controles verdes: sem "Quitar" o bloco aparece, e com o débito novo também; sem a bandeira
o finalizar fecha e os débitos ficam; depois do "Quitar" ele fecha e baixa os dois da casa, e não o da
outra franquia; e a recusa e o controle do finalizar da API. A página é lida do HTML que o `comanda.phtml`
desenha, renderizado em processo. No front, `quitarDaTelaAntiga.teste.ts` (7 casos) roda as funções do
arquivo servido com um jQuery de mentira: o segundo clique não sai enquanto o primeiro não volta, a
resposta e o erro devolvem o botão, e o piso da versão no layout. Mutantes, um de cada vez, cada um com o
seu vermelho:

| mudança | vermelho |
|---|---|
| o "Quitar" do ERP soma o devido sempre | 3: os dois segundos "Quitar" e o do débito novo |
| a regra soma o devido inteiro depois do "Quitar" | 1: o do débito novo |
| o finalizar do ERP sem a recusa | 2: as duas recusas |
| o devido do finalizar sem a unidade | 3: os dois daqui que finalizam depois do "Quitar", e o do D319 (`D317A320RestosDoD316Test`) |
| a comanda reaberta sem a marca | 2: a página e o balcão que a reabre |
| a comanda reaberta que esconde o bloco sem ligar a bandeira | 2: os mesmos |
| a página sem esconder o bloco pela marca | 1: a página |
| o `POST .../debitos` da API soma sempre | 1: o segundo pedido |
| o finalizar da API sem a recusa do D159 | 1: a recusa |
| o JavaScript sem conferir o botão; sem desabilitá-lo | 1 cada: o segundo clique |
| a resposta sem devolver o botão; o erro sem devolvê-lo | 2 e 1 |
| o layout de volta ao 164 | 1: o piso |

O portão do recorte (`conferir-recorte-por-unidade.php`) vigia a soma pelo que falta do devido do servidor e
o recorte da unidade no devido do finalizar; arrancado o código de cada uma (as três entradas, uma de cada
vez), a entrada dela fica vermelha.

**O que fica, lido no código e não medido:**
- a venda em andamento com os débitos de um cliente somados, sem pagamento, vira a comanda de outro com a
  dívida dentro -- o **D433**, com a pergunta ao lote;
- o débito que nasce entre o "Quitar" e o finalizar faz o finalizar recusar -- é o que a bandeira baixaria
  sem cobrar. ~~Na comanda, reabri-la mostra o bloco para o "Quitar" que falta; no `/vendas/novo` o bloco já
  está escondido, e o caminho é "Abrir comanda" (do mesmo cliente) e quitar lá;~~ (errado, e consertado na
  revisão, abaixo: a saída descrita não existia);
- o débito pago por outro caminho no meio (a tela de devedores) deixa o total acima do devido: a comanda
  reaberta esconde o bloco e o finalizar baixa o resto, com o cliente pagando o total. Não piorou: antes o
  bloco aparecia com o devido menor, e o "Quitar" o somava outra vez;
- a tela antiga não foi aberta num navegador.

#### A revisão — 06/10/2026, a verificação do item PDV-QUITAR-E-COMANDA

A verificação (lida no código) achou quatro coisas que o conserto acima deixava, e duas afirmações sem prova.

- **A recusa mandava clicar num botão escondido -- CONSERTADO.** O débito que nasce no meio da venda (a
  outra comanda do cliente fechada fiado em outro caixa) faz o finalizar recusar com "Clique em Quitar",
  mas depois do "Quitar" o bloco dos débitos está escondido. No `/vendas/novo` recarregar apaga a venda
  (`novoAction` chama `apagarVendaTemporaria`), e a saída descrita acima falhava: o "Abrir comanda" some
  quando o cliente tinha comanda aberta ao ser escolhido -- justamente a origem mais provável do débito
  novo --, e mesmo visível devolveria a outra comanda, sem os itens desta venda. Agora a recusa leva o
  devido de agora (`quitar_debitos`, só nela), e o `pdvFinalizarVenda` mostra o bloco de novo, com o valor;
  a bandeira continua ligada -- o que já foi somado está no total -- e o "Quitar" soma só o que falta. Vale
  para a comanda também, sem recarregar.
- **A comanda reaberta mostrava a dívida no painel do cliente -- CONSERTADO.** Com o bloco escondido, o
  campo vermelho "Débitos" do painel seguia com o devido; o `pdvPagarDebitos` o limpa depois do clique, e a
  página reaberta passou a sair igual (`VendasController::comandaAction`).
- **O `POST .../debitos` da API que soma só o débito novo não tinha caso -- POSTO.** O mutante que somava o
  devido inteiro ali sobrevivia; o caso gêmeo do ERP agora existe na API.
- **A tela nova -- FICA, e é o PARCIAL do título.** `Comandas.tsx` finaliza sem `pagarDebitos` ("a omissão é
  decisão"), e `ComandaJson` não publica nem os débitos nem o excedente. A comanda com o "Quitar" do ERP,
  fechada na tela nova, cobra os débitos no total e os deixa abertos -- o sintoma deste débito pela outra
  porta, e assim já era antes dele. Corrigir pede escolher entre a tela nova ler a marca e mandar a bandeira,
  ou a API baixar sozinha quando a comanda já cobre os débitos: foi ao lote como pergunta. A API nova não
  tem consumidor em produção; durante a transição, com as duas telas em uso, o caminho fica aberto.
  `testAComandaComOQuitarDoErpFechadaPelaApiSemABandeiraDeixaOsDebitosAbertos` retrata o que está de pé, e
  vira quando a resposta vier.
- **"O débito novo soma só ele" foi ao lote** para o dono confirmar: é a leitura da marca escolhida, mas
  não estava na pergunta 14, e o bloco mostra o devido inteiro ("Cliente possui débitos: 60,00") enquanto
  o total sobe só o que falta (10,00).
- **A prova nos dois bancos.** Os casos contra o código de antes e os mutantes tinham rodado só no MySQL.
  Na revisão, os 27 casos do item (`D380QuitarComMarcaTest`, 18, e `D384ComandaDeOutroClienteTest`, 9)
  rodaram contra o código do HEAD nos dois bancos -- 15 vermelhos e os mesmos 12 verdes em cada um -- e os
  mutantes abaixo rodaram nos dois, cada um com o mesmo vermelho no MySQL e no PostgreSQL:

| mudança | vermelho |
|---|---|
| o "Quitar" do ERP soma o devido sempre | 4: os dois segundos "Quitar", o do débito novo e a saída da recusa |
| o finalizar do ERP sem a recusa | 3: as duas recusas e a saída dela |
| a comanda reaberta sem a marca | 2: a página e o balcão que a reabre |
| o `POST .../debitos` da API soma sempre | 2: o segundo pedido e o do débito novo |
| o `POST .../debitos` da API soma o devido inteiro, e não o que falta | 1: o do débito novo |
| o finalizar da API sem a recusa do D159 | 1: a recusa |
| a recusa do finalizar sem o `quitar_debitos` | 2: as duas recusas |
| o `quitar_debitos` em toda recusa, e não só na dos débitos | 5: o controle daqui e os quatro do D166 e do D320 que leem a resposta inteira |
| a comanda reaberta sem limpar o débito do painel | 1: a página |

  No front, `quitarDaTelaAntiga.teste.ts` (10 casos) roda também o `pdvFinalizarVenda`: na recusa dos
  débitos o bloco volta com o devido e a bandeira fica, o "Quitar" que volta soma e esconde de novo, e a
  outra recusa não mexe no bloco. Mutantes no JavaScript, um de cada vez: sem o bloco que volta, sem o
  valor, desligando a bandeira, e mostrando o bloco em toda recusa -- 1 vermelho cada.

**Decidido pelo dono em 07/10/2026** (página do lote), duas perguntas. Na **41**, a opção (a), "A tela
nova lê a marca e manda a bandeira" -- "Mostra “Débitos de R$ X já somados; serão quitados ao
finalizar”." É o PARCIAL do título, e a implementação está na fila (outro item); nada mudou no código
neste, e `testAComandaComOQuitarDoErpFechadaPelaApiSemABandeiraDeixaOsDebitosAbertos` vira com ela. Na
**43**, a opção (a), "Como está" -- "Soma só o que falta; o bloco mostra o que o cliente deve." O débito
novo depois do "Quitar" soma só ele, e o bloco segue dizendo o devido inteiro: nada a mudar --
`testODebitoQueNasceuDepoisDoQuitarSomaSoEle` e o gêmeo da API o fixam, e o arquivo inteiro (18 casos)
rodou verde nos dois bancos em 08/10/2026. O que resta deste débito: a tela nova (a 41).

#### A tela nova — PARCIAL em 08/10/2026, o item PDV-QUITAR-SEGUNDA-RODADA

**O conserto**, pela decisão de 07/10/2026 (pergunta 41, a opção (a), "A tela nova lê a marca e manda a
bandeira" -- "Mostra “Débitos de R$ X já somados; serão quitados ao finalizar”"):

- **A API diz que há débitos somados.** O recurso da comanda (`ComandaJson::completo`) ganhou
  `debitosSomados`: o devido do cliente na unidade da sessão, em decimal de texto, quando o total já o
  cobre; nulo sem "Quitar", com um débito que nasceu depois dele (a bandeira seria recusada com 422), com o
  cliente sem débito nenhum, e fora da comanda aberta (a marca não lê o histórico; na concluída e na
  cancelada sai nulo, e `VendaJson`, a venda feita, tira a chave). A conta é a da comanda reaberta do ERP,
  agora num método só: `RegraDosDebitosNaVenda::jaSomados` (o devido positivo e coberto), que
  `VendasController::comandaAction` passou a chamar no lugar da expressão dele, igual. Toda resposta de
  `/adm/api/comandas` passa pelo campo (`Adm\ComandasController::recurso`), e não só o `GET`: a tela troca o
  estado pela comanda que cada escrita devolve, e um pagamento recebido depois do "Quitar" apagaria o aviso.
  O devido é lido com o recorte da unidade -- as linhas que o finalizar baixa --, e o portão do recorte
  (`conferir-recorte-por-unidade.php`) ganhou essa entrada; a contagem do `ISVenda` em
  `conferir-cobertura-por-par.php` foi de 111 a 112 (par já vigiado).
- **A tela mostra e manda.** `Comandas.tsx` (`Finalizar`) mostra *"Débitos de R$ X já somados; serão
  quitados ao finalizar."* quando a comanda traz o campo, e manda `pagarDebitos: true` na finalização --
  a mesma bandeira que o ERP manda (`pagarDebitos`, do `#modfin_debitos_add`, no `mod-vendas.js`); sem o
  campo, o corpo segue sem ela. O servidor confere como antes, pela marca (`RegraDosDebitosNaVenda::cobre`,
  no finalizar da API): o valor não vem da tela. Os tipos (`ComandaCompleta.debitosSomados: string | null`;
  `VendaCompleta` sem a chave) seguem o PHP.
- **A API não baixa sozinha**: era a opção (b), que o dono não escolheu. Sem a bandeira, o finalizar da API
  cobrava e não baixava, como antes (*desde a verificação, recusa com 409: ver "A verificação", abaixo*) --
  o caso que retratava o defeito
  (`testAComandaComOQuitarDoErpFechadaPelaApiSemABandeiraDeixaOsDebitosAbertos`) virou
  `testSemABandeiraOFinalizarDaApiNaoBaixaSozinhoERecusa`, a guarda de que o servidor não decide pela tela, e o
  irmão com a bandeira (`testAComandaComOQuitarDoErpFechadaPelaApiComABandeiraBaixaOsDebitos`) fecha e baixa
  os dois débitos da casa, e não o da outra franquia.
- **A 43 não mudou:** o segundo "Quitar" soma só o débito novo, e o bloco do ERP diz o devido inteiro --
  `testODebitoQueNasceuDepoisDoQuitarSomaSoEle` e o gêmeo da API continuam verdes. Na tela nova, a comanda
  com um débito novo depois do "Quitar" vem com o campo nulo (não cobre), e a finalização sai sem a bandeira:
  o cliente paga o que o total tem e os débitos ficam abertos -- o que a comanda reaberta do ERP também faz
  se o operador fechar sem o segundo "Quitar" que o bloco oferece; a tela nova não tem esse botão (a fatia
  o deixou de fora). É o PARCIAL do título, em "O que fica", abaixo.

**Provado** em `D380QuitarComMarcaTest` (26 casos; eram 18, um trocado e nove novos), verde nos dois
bancos: o campo depois do "Quitar" do ERP (50,00, e não os 550,00 com a outra franquia), nulo sem "Quitar",
com o débito novo e com o cliente sem débito; o campo na resposta de um pagamento recebido e na do
`POST .../debitos`; a finalização com a bandeira (baixa) e sem ela (não baixa, e a concluída sai nula); a
venda sem a chave. No front, `ComandasLigada.teste.tsx` (76 casos; 2 novos): com o campo, o aviso com o
texto da resposta e o corpo `{pagarDebitos: true}`; sem ele, nenhum aviso e o corpo `{}`. Os mutantes
estão na tabela do item, no D433.

**O que fica, lido no código e não medido:**
- **o PARCIAL do título:** a comanda com um débito que nasceu depois do "Quitar" do ERP, fechada pela tela
  nova -- o campo vem nulo (o total cobre 50,00 de 60,00, e a bandeira seria recusada), a tela não avisa
  nada, e os 60,00 ficam abertos com o cliente pagando os 50,00 que o total já tem. No ERP o bloco volta com
  o "Quitar" que soma os 10,00 (a 43); a tela nova não o tem. Avisar sem a bandeira, dar à tela nova o
  "Quitar", ou deixar, é escolha do dono: foi ao lote (08/10/2026), com a recomendação de um aviso;
- o débito pago por outro caminho no meio (já escrito acima): o total fica acima do devido, o campo diz o
  devido (o que a bandeira quita), e o cliente paga o total;
- a tela nova não foi aberta num navegador com uma comanda assim.

#### A verificação — 08/10/2026, o mesmo item; o D380 continua PARCIAL

Sete achados da verificação, conferidos no código. O que cada um virou:

- **A tela decidia a bandeira pela comanda que carregou** (o mais grave). Aberta na tela nova antes do
  "Quitar" do ERP e finalizada sem reler, a comanda saía com o corpo sem `pagarDebitos`, e o finalizar da
  API passava: o cliente pagava os débitos no total e eles continuavam abertos -- o defeito da 41, de volta
  por uma corrida entre as duas telas. Com fiado já autorizado nem precisava de pagamento novo (a página do
  ERP apaga os pagamentos e não limpa a autorização, o resto do D165). **O conserto:**
  `Adm\ComandasController::finalizarAction` recusa com 409 e `detalhes.motivo = debitos_somados` o finalizar
  SEM a bandeira quando o total já cobre o devido (`RegraDosDebitosNaVenda::jaSomados`, a conta do campo), e
  não grava nada; a tela (`Comandas.tsx`, `Finalizar`) relê a comanda nessa recusa (`verComanda`, o único
  pedido novo, e só neste caminho), diz *"A comanda mudou em outra tela: os débitos do cliente foram somados
  ao total, e a finalização sem quitá-los foi recusada. A comanda foi relida — confira o total e finalize de
  novo."*, e o aviso da 41 aparece; o segundo clique leva a bandeira. **Não é a opção (b)**, que o dono
  recusou ("A API baixa sozinha"): nada se baixa sem a bandeira, e o operador vê o que vai acontecer, que é o
  motivo da (a). O débito novo depois do "Quitar" não entra na recusa (o total não cobre), e segue como
  antes até a resposta do lote. O caso que dizia "sem a bandeira, a API não baixa sozinha" passou a dizer
  também que ela recusa (`testSemABandeiraOFinalizarDaApiNaoBaixaSozinhoERecusa`, nada gravado), e entraram
  a saída da recusa (a comanda relida, com o campo, e finalizada com a bandeira), o controle sem "Quitar"
  (fecha como antes) e o do débito novo (fecha como antes: o PARCIAL fixado). No front, a recusa relida e
  finalizada com a bandeira, e o 409 de outro motivo, que não relê nada.
- **Só 2 das 8 escritas da comanda aberta tinham caso que exigisse o campo**, e trocar o `recurso` por
  `ComandaJson::completo` nas outras 6 não deixava nada vermelho -- o aviso sumiria na primeira remoção. Entrou
  um caso por escrita (abrir a comanda que já existe, lançar, tirar e ajustar item, estornar pagamento,
  autorizar fiado) e um que lê o controller sem os comentários (`token_get_all`) e exige uma chamada só de
  `ComandaJson::completo`, dentro do `recurso`: a ação que nascer devolvendo o `completo` direto fica
  vermelha.
- **O `POST .../debitos` não revogava a autorização do fiado** -- achado ao escrever o caso do fiado; é do
  D165 (a tabela dele ganhou a quinta linha). Agora revoga quando soma, e não quando o total já cobre.
- **A marca lia o meio centavo do desconto como débito somado** -- o D433, "A verificação": o excedente passou
  a somar os itens arredondados ao centavo. O irmão deste débito: dois itens que arredondam para baixo
  (45,90 com 3% de desconto, 44,523 cada) tiravam meio centavo cada, e depois do "Quitar" o finalizar
  recusava a bandeira (49,994 contra 50,00), e a comanda reaberta mostraria o "Quitar" de novo, que somaria
  0,01. Na cópia, nenhuma das 459 vendas com item fora do centavo tem o total abaixo dos itens crus por mais
  que a folga (só leitura, 08/10/2026): o caso é raro, e
  `testComItensQueArredondamParaBaixoOFinalizarAceitaABandeiraDepoisDoQuitar` o fixa.
- **As prévias do `/design-sync`** tipadas como `ComandaCompleta` (cinco literais, em `_comanda-a.ts` e
  `_comanda-b.ts`) ganharam `debitosSomados: null`. Eram TS2741, que o `npm run build` não vê: ele só inclui
  `src`. Conferido com um `tsc` próprio sobre as prévias: os 5 sumiram, e ficam 72 erros de antes, de outros
  assuntos -- o módulo `frontend`, que só o empacotador do design resolve, e três tipos de outras telas. A
  prévia do `Finalizar` ganhou o estado com o aviso (`ComDebitosSomados`).
- **O `f2-fatia-pdv.md` chamava o D380 de corrigido**, e o parágrafo "não oferece `pagarDebitos`" seguia sem
  ressalva: corrigidos, com o que fica.
- **O 422 que se repete** (lido no código, não medido): o débito que nasce com a comanda já aberta na tela
  nova e o aviso na tela. A bandeira vai, o servidor a recusa com 422 a cada clique ("Some os débitos à
  comanda antes de finalizar"), e a tela não tem o botão de somar; nada é gravado, e a saída passa pelo
  "Quitar" do ERP. A tela não mudou: reler ali levaria ao caso do PARCIAL, que é a pergunta do lote, e foi
  uma linha a mais nela.

**O que fica, além do PARCIAL do título:**

- **o finalizar do ERP sem a bandeira, com os débitos somados**: a página da comanda aberta em outra aba
  antes do "Quitar" tem a bandeira desligada, e o finalizar dela cobra os débitos e não os baixa -- a mesma
  corrida, do lado do ERP. A recusa não foi para lá porque o finalizar do ERP é o do balcão das 32 lojas:
  foi ao lote (08/10/2026), com a recomendação de recusar com a frase de recarregar a comanda;
- o centavo que tirar um item de meio centavo deixa no total (o D450);
- nada disto foi aberto num navegador.

**Provado:** `D380QuitarComMarcaTest` (39 casos; eram 26) e `D384ComandaDeOutroClienteTest` (20; eram 18),
verdes no MySQL e no PostgreSQL. Os mutantes da verificação, um de cada vez, com o arquivo devolvido e
conferido pelo sha1 depois de cada um:

| mudança | vermelho (o mesmo no MySQL e no PostgreSQL) |
|---|---|
| a marca com os itens crus (`excedente` sem arredondar o item) | 3: o meio centavo pelo ERP, o "Sem Cadastro" com ele pela API, e os itens que arredondam para baixo |
| a recusa nova arrancada (o finalizar sem a bandeira passa) | 2: a recusa e a saída dela, relida e finalizada |
| a recusa nova com qualquer devido, no lugar de `jaSomados` | 2: o controle sem "Quitar" e o do débito novo |
| abrir, lançar item, tirar item, ajustar item, estornar pagamento e autorizar fiado com `ComandaJson::completo` no lugar do `recurso` -- seis mutantes, um de cada vez | 2 cada: o caso da escrita e o que lê o controller sem comentários |
| o `POST .../debitos` sem `desautorizarFiado` | 1: a autorização revogada |
| o `POST .../debitos` revogando sempre, também sem somar | 1: o controle que não soma nada |

No front (`ComandasLigada.teste.tsx`, 78 casos), três mutantes em `Comandas.tsx`, um de cada vez: a tela
sem reler na recusa, e o aviso da releitura arrancado -- 1 vermelho cada, o caso da recusa relida --; e
relendo em todo 409 -- 1, o 409 de outro motivo. Os vizinhos (44 arquivos que tocam o código mudado, 664
casos, 2 pulados) verdes nos dois bancos; `vitest` 1599 de 1599, `build` e `lint` com saída 0 (os 6 avisos
do lint são de arquivos que este item não toca); `conferir-tudo.sh` pelo host com saída 0 -- 62 ok e o
vermelho conhecido do D50.

#### A terceira rodada — PARCIAL em 10/10/2026, o item PDV-TERCEIRA-RODADA

**Decidido pelo dono em 10/10/2026** (página do lote, perguntas 54 a 56, a opção (a) nas três). Na **54**, "um aviso
na tela nova, sem bandeira" -- "Débitos de R$ 50,00 somados, mas o cliente deve R$ 60,00: some o que falta pelo ERP
antes de finalizar, ou os débitos ficam abertos" --, e a API passa a dizer também o que já está somado quando não cobre;
com ela, a recusa que se repetia para. Na **55**, "o ERP recusa também" -- "Os débitos do cliente já estão somados ao
total (o "Quitar" foi feito em outra tela). Clique em "Quitar" antes de finalizar." --, e o segundo "Quitar" não soma
de novo, só liga a quitação; a mudança cabe no servidor. Na **56**, tirar e ajustar um item subtraem o item
arredondado: é o D450, corrigido.

**O conserto:**

- **A API diz o somado e o devido.** O recurso da comanda (`ComandaJson::completo`) ganhou `debitosSomadosEmParte`:
  `{somado, devido}` em decimal de texto quando o total tem débitos somados e não cobre o devido -- o débito que nasceu
  depois do "Quitar". O `somado` é o excedente (o que o total tem acima dos itens arredondados), e o `devido`, o da
  unidade da sessão. Nulo sem nada somado, com o devido coberto (aí fala `debitosSomados`) e fora da comanda aberta;
  `VendaJson` tira a chave. A conta mora em `RegraDosDebitosNaVenda::somadosEmParte` (`temDebitosSomados` e não
  `cobre`) e passa pelo `recurso`, então vem em toda resposta da comanda -- o caso que lê o controller sem os
  comentários segue verde (uma chamada de `completo`, dentro dele).
- **A tela nova avisa e fecha sem a bandeira.** `Comandas.tsx` (`Finalizar`) mostra o texto da 54 quando o campo vem,
  e o corpo sai sem `pagarDebitos` -- a bandeira continua só com `debitosSomados`. **A recusa que se repetia:** a
  comanda aberta aqui com o aviso dos já somados, e o débito novo no meio. A bandeira vai, o servidor a recusa com 422
  no campo `pagarDebitos` (como antes), e a tela agora relê a comanda nessa recusa -- pelo campo, e não pela frase: o
  aviso troca para o da 54, uma linha diz que a finalização foi recusada e a comanda relida, e o clique seguinte sai
  sem a bandeira. O pedido novo (`verComanda`) só existe nesse caminho e no do 409 `debitos_somados`. Os tipos
  (`ComandaCompleta.debitosSomadosEmParte: { somado: string; devido: string } | null`; `VendaCompleta` sem a chave)
  seguem o PHP. As cinco prévias do `/design-sync` tipadas como `ComandaCompleta` ganharam `debitosSomadosEmParte: null`
  (o `build` não as vê: só inclui `src`), e a do `Finalizar`, o estado `ComDebitosSomadosEmParte`.
- **O finalizar do ERP recusa sem a quitação, com os débitos já somados** (`VendasPagamentosController::validarFinalizarVenda`):
  `sucesso: false`, a frase da 55, e o devido em `quitar_debitos`. O `pdvFinalizarVenda` já mostrava o bloco com o
  "Quitar" nessa chave (a revisão de 06/10/2026), e o JavaScript não mudou, nem o `?cache=`. O "Quitar" seguinte não
  soma (o total já cobre, `aSomar` dá zero), responde como o primeiro, e o JavaScript liga a bandeira; o finalizar com
  ela fecha e baixa. A conta é `jaSomados` -- a mesma da comanda reaberta, que liga a bandeira, e a do 409 da API:
  quem clicou "Quitar" na mesma página ou reabriu a comanda manda a bandeira e não passa pela recusa. O devido passou a
  ser lido em todo finalizar do ERP, e não só com a bandeira (uma soma a mais por finalização, com o recorte da
  unidade; a entrada do portão do recorte é a mesma linha). A venda sem débitos somados finaliza como antes.

**Provado:** `D380QuitarComMarcaTest` (47 casos; eram 39, 8 novos), `D450CentavoDoItemRetiradoTest` (5, novo) e
`D384ComandaDeOutroClienteTest` (20) -- 72 casos, 292 asserções, verdes no MySQL e no PostgreSQL. Contra o código de
antes, 10 vermelhos, os mesmos nos dois bancos: os 4 do campo novo, a recusa do ERP e a saída dela, e os 4 do D450; e
verdes os controles (o ERP com o débito novo, sem a bandeira, fecha como antes; a venda feita sem a chave; o item sem
fração de centavo). No front, `ComandasLigada.teste.tsx` (81 casos; 3 novos): o aviso com o texto da 54 e o corpo
`{}`; a bandeira recusada com 422 no campo, a releitura (`GET` depois do `POST`), o aviso trocado e o segundo corpo
`{}`; e o 422 de outro campo, que não relê. Contra o código de antes, 2 vermelhos.

Os mutantes, um de cada vez, com os arquivos devolvidos e conferidos pelo md5 depois de cada um
(`rodar-mutantes-r3.sh`, no rascunho):

| mudança | vermelho (o mesmo no MySQL e no PostgreSQL) |
|---|---|
| a comanda sem o campo novo (`debitosSomadosEmParte` sempre nulo) | 2: o somado e o devido com o débito novo, e a bandeira recusada com a saída |
| o campo também com o devido coberto (sem o `cobre` de `somadosEmParte`) | 1: o controle com os débitos cobertos |
| o campo também sem nada somado (sem o `temDebitosSomados`) | 1: o controle sem "Quitar" |
| `VendaJson` sem tirar a chave nova | 1: a venda feita |
| o finalizar do ERP sem a recusa nova | 2: a recusa e a saída dela |
| a recusa nova sem o devido em `quitar_debitos` | 1: a recusa |
| a recusa nova com qualquer devido, no lugar de `jaSomados` | 3: o controle sem "Quitar", o do débito novo e o da outra recusa (`testAOutraRecusaNaoLevaODevido`) |
| tirar o item cru (o `remover` de antes) | 3: tirar pelo PDV e pela API, e a comanda do outro cliente (o D450) |
| ajustar tirando o item cru (o `alterar` de antes) | 1: o ajuste da quantidade (o D450) |
| `totalSemOItem` sem arredondar | 4: os três de tirar e o de ajustar (o D450) |

No front (`ComandasLigada.teste.tsx`, 81 casos), cinco mutantes em `Comandas.tsx`, um de cada vez: o aviso da 54
arrancado, e a bandeira mandada também com o campo novo -- 2 vermelhos cada, os dois casos novos do aviso e da
releitura; a tela sem reler no 422 do campo, e o aviso da releitura arrancado -- 1 cada, a bandeira recusada; e relendo
em todo 422 -- 1, o 422 de outro campo. O portão do recorte, com a unidade arrancada da linha do devido do finalizar do
ERP (que saiu de dentro do `if` da bandeira), fica vermelho, e volta verde com ela.

Os vizinhos -- 51 arquivos que tocam o código mudado, derivados do `grep` pelas classes mudadas e pelo finalizar, 710
casos, 2 pulados -- verdes no MySQL e no PostgreSQL (`RoyaltiesTest` e `CronTravaSimultaneaTest`, que o `grep` também
trazia, ficaram de fora de propósito: chamam `royaltiesAtualizar`, e não tocam o PDV). `vitest` 1607 de 1607, `build` e
`lint` com saída 0 (os 6 avisos do lint são de arquivos que este item não toca); as prévias do `/design-sync` com um
`tsc` próprio, os mesmos 72 erros de antes e nenhum do campo novo; `conferir-tudo.sh` pelo host com saída 0 -- 62 ok e
o vermelho conhecido do D50.

**O que fica:**

- **o finalizar do ERP sem a quitação, com os débitos somados EM PARTE** -- o PARCIAL do título (lido no código, e
  fixado em `testComDebitoNovoDepoisDoQuitarOFinalizarDoErpSemABandeiraSegueComoAntes`): o "Quitar" somou 50,00, nasceu
  um débito de 10,00, e o finalizar sem a bandeira -- na aba aberta antes do "Quitar", ou na comanda reaberta quando o
  operador fecha sem o segundo "Quitar" que o bloco oferece -- passa: o cliente paga os 50,00 no total, e os 60,00
  ficam abertos. Não são os débitos "já somados" da 55, e a tela nova faz o mesmo, com o aviso da 54 -- quando leu a
  comanda depois do "Quitar"; a que leu antes fecha sem o aviso (a verificação, abaixo). Foi ao lote (10/10/2026), com
  a recomendação de recusar como a 55;
- o débito pago por outro caminho no meio (já escrito acima);
- nada disto foi aberto num navegador.

#### A verificação da terceira rodada — 10/10/2026, o mesmo item; o D380 continua PARCIAL

Cinco achados da verificação, conferidos no código, e um sexto achado ao consertar um deles (o troco, o D463). O que
cada um virou:

- **A recusa da 55 só tinha caso na comanda.** Os três casos novos do ERP eram da comanda (status 3), e os da venda em
  andamento que finalizam sem a bandeira não têm "Quitar" antes: a recusa limitada à comanda (`getStatus() == 3` antes
  de `jaSomados`) deixava os 72 verdes, e a venda do `/vendas/novo` com os débitos já somados voltava a fechar sem a
  quitação. Entrou `testNaVendaEmAndamentoOFinalizarDoErpSemABandeiraComOsDebitosJaSomadosRecusa` (a mesma frase, o
  devido em `quitar_debitos`, nada gravado), e o mutante fica vermelho nele.
- **A saída da recusa da 55 passava pela tela da conta de antes** (lido no código; não é regressão: antes da 55 a mesma
  aba fechava o mesmo fiado e ainda deixava os débitos abertos). A aba que lançou o pagamento do total fica com o
  "Finalizar" à mostra (o lançamento responde `fiado: 0`); o "Quitar" de outra aba sobe o total; o finalizar dela é
  recusado, e o "Quitar" do bloco que volta não soma -- mas o `pdvPagarDebitos` só trocava o total e a bandeira, e não
  a tabela de pagamentos nem o par "Finalizar"/"Finalizar fiado". O "Finalizar" que ficou fechava os débitos como fiado
  sem o modal do gerente, porque o servidor do ERP não confere a autorização (o D86). O mesmo acontecia com o "Quitar"
  clicado depois do pagamento na mesma página. **O conserto:** o "Quitar" do ERP responde como o lançamento e a remoção
  do pagamento -- `fiado` (o recebido abaixo do total) e `tabela` (a `tabela-pagamentos.phtml` da conta nova), também
  quando não soma nada --, e o `pdvPagarDebitos` aplica os dois como o `pdvAdicionarPagamento`. O `mod-vendas.js` foi
  ao `?cache=166`, e o piso do `quitarDaTelaAntiga.teste.ts` com ele. Os casos que conferiam a resposta do "Quitar"
  (`D380`, `D316`, `D317A320` e `D384`) passaram a conferir o `fiado`; a tabela, que é HTML, só nos casos novos. A
  autorização no servidor continua com o D86.
- **O troco seguia o da conta de antes** -- achado ao escrever o caso de cima: o "Quitar" depois de um pagamento com
  troco deixava o troco antigo, e o finalizar o devolvia com a dívida baixada. É o **D463**, corrigido no serviço das
  duas portas.
- **O "Quitar" em duas vendas abertas do mesmo cliente cobra os débitos duas vezes** (não vem deste item; lido no
  código e fixado em `testComOQuitarEmDuasVendasAbertasASegundaCobraOsDebitosQueAPrimeiraBaixou`). A marca é da venda:
  com a comanda e a venda em andamento do mesmo cliente abertas, o "Quitar" de cada uma soma os 50,00, porque o
  excedente da outra não conta; a venda em andamento é paga e finalizada com a quitação, e o devido vai a zero. A
  comanda não cai em recusa nenhuma -- `jaSomados` exige devido, e `cobre` com zero é verdadeiro, então nem a 55 no ERP
  nem os dois campos da tela nova -- e fecha com os 50,00 já baixados dentro do total. As duas vendas abertas são o
  caminho do D452; é da mesma classe do "débito pago por outro caminho no meio", escrito acima. Recusar pede escolha do
  dono (onde, e qual a saída): foi ao lote, com a recomendação de o "Quitar" recusar quando outra venda aberta do
  cliente já tem os débitos somados.
- **A tela nova que leu a comanda antes do "Quitar" do ERP fecha calada quando os débitos estão somados só em parte**
  (lido no código). A tela abre a comanda, o "Quitar" do ERP soma 50,00, nasce um débito de 10,00, e a tela finaliza
  com o corpo `{}`: o total cobre 50 de 60, não sai o 409 `debitos_somados` (o único caminho, com o 422 da bandeira, que
  a faz reler), e a comanda fecha com os 60,00 abertos, sem o aviso da 54. Precisa do fiado já autorizado (o "Quitar"
  do ERP não revoga a autorização, o resto do D165) ou de um pagamento que já cobria o total novo. O servidor estava
  fixado (`testComDebitoNovoDepoisDoQuitarOFinalizarDaApiSemABandeiraSegueComoAntes`); este registro e o
  `f2-fatia-pdv.md` diziam a tela nova fechada -- corrigidos. Fechar pede um campo novo no corpo do finalizar, ou um
  pedido a mais por finalização: foi ao lote, com a recomendação de um 409 `debitos_em_parte` até a tela mostrar o
  aviso.
- **O `ComandaJson.php` final não tinha passado pela suíte** (a última edição, só de comentário, veio depois das
  rodadas): as rodadas desta verificação o incluem.

**Provado:** `D380QuitarComMarcaTest` (54 casos; eram 47, 7 novos), `D450CentavoDoItemRetiradoTest` (5) e
`D384ComandaDeOutroClienteTest` (20) -- 79 casos, 329 asserções, verdes no MySQL e no PostgreSQL, com o `ComandaJson.php`
na versão final. Os vizinhos -- 35 arquivos, derivados do `grep` pelo "Quitar" das duas portas, pelo serviço e pelo
controller do pagamento, e pelos que leem o `mod-vendas.js` --, 662 casos, verdes nos dois bancos. Os casos que
conferiam a resposta do "Quitar" em `D316EscritasEmVendaFechadaTest`, `D317A320RestosDoD316Test` e
`D384ComandaDeOutroClienteTest` passaram a conferir o `fiado` (1 em todos: o recebido não cobre o total novo). No front,
`quitarDaTelaAntiga.teste.ts` (14 casos; 4 novos -- a tabela e o "Finalizar fiado" com fiado, o "Finalizar" sem ele, a
recusa que não mexe em nada, e a saída da recusa da 55 com a conta de antes) e o piso do `?cache=` em 166; `vitest` 1611
de 1611, `build` e `lint` com saída 0 (os mesmos 6 avisos, de arquivos que não foram tocados).

Os mutantes, um de cada vez, com os arquivos devolvidos e conferidos pelo md5 depois de cada um
(`rodar-mutantes-r4.sh`, no rascunho):

| mudança | vermelho (o mesmo no MySQL e no PostgreSQL) |
|---|---|
| a recusa da 55 só na comanda (`getStatus() == 3` antes de `jaSomados`) | 1: a venda em andamento |
| a resposta do "Quitar" sem o `fiado` | 22: todos os que conferem a resposta (nos quatro arquivos) |
| a resposta do "Quitar" sem a `tabela` | 3: o "Quitar" depois do pagamento, a saída da recusa da 55 e o troco pelo ERP |
| o serviço sem recalcular o troco (o D463) | 3: o troco pelo ERP, pela API e o controle que o zera |
| o serviço zerando o troco sempre | 2: o troco pelo ERP e pela API |
| o servidor de antes inteiro (sem `fiado`, sem `tabela`, sem o troco) | 23 |

No front, `quitarDaTelaAntiga.teste.ts`: o `pdvPagarDebitos` sem trocar a tabela, 3 vermelhos (os dois do "Quitar" e a
saída da 55); sem refazer o par de botões, 2 (o do fiado e a saída da 55); o layout de volta ao 165, 1 (o piso); e o
JavaScript e o layout de antes inteiros, 4.

**O que fica, além do PARCIAL do título:**

- o finalizar do ERP sem a quitação, com os débitos somados em parte (a pergunta da terceira rodada, acima);
- a tela nova que leu a comanda antes do "Quitar" do ERP, com o débito novo no meio (pergunta ao lote, 10/10/2026);
- o "Quitar" em duas vendas abertas do mesmo cliente (pergunta ao lote, 10/10/2026);
- a autorização do fiado no servidor do ERP (o D86);
- nada disto foi aberto num navegador: nem a tabela e o par de botões do "Quitar", nem a recusa da 55.

#### A quarta rodada — CORRIGIDO em 12/10/2026, o item PDV-QUITAR-E-PACOTE

**Decidido pelo dono em 12/10/2026** (página do lote, perguntas 77 a 79):

- **77, a opção (b), "Como está"**: no ERP o bloco mostra o devido com o "Quitar", e quem fecha sem clicar cobra a parte
  somada e deixa os débitos abertos, como a tela nova faz desde a 54, sem o aviso. Nada muda no ERP, e o caso que fixava o
  comportamento passa a prender a regra decidida.
- **78, a opção (a)**: "A API recusa uma vez, até o operador ver o aviso" -- o finalizar sem a bandeira, com os débitos
  somados em parte, responde 409 (`debitos_em_parte`) quando o corpo não traz o somado que a tela mostrou; a tela relê, o
  aviso da 54 aparece, e o clique seguinte manda o somado visto e fecha como a 54 decidiu. Se o somado mudar no meio
  (outro "Quitar"), recusa de novo. Campo novo no corpo; o ERP não muda.
- **79, a opção (a)**: o "Quitar" recusa quando outra venda aberta do cliente na unidade já tem os débitos somados --
  "Os débitos deste cliente já estão somados na comanda 123. Finalize-os lá." --, nas duas portas, com uma consulta a mais
  por "Quitar". A comanda abandonada com a soma prende o "Quitar" das outras até ser finalizada ou cancelada.

**O conserto:**

- **A 77 não mexe em código.** `testComDebitoNovoDepoisDoQuitarOFinalizarDoErpSemABandeiraSegueComoAntes` passou a dizer
  que é a regra decidida, e o comentário do finalizar do ERP (`VendasPagamentosController::validarFinalizarVenda`) também.
- **A 78, no finalizar da API** (`Adm\ComandasController::finalizarAction`): sem a bandeira, com os débitos somados em
  parte (`RegraDosDebitosNaVenda::somadosEmParte`, a conta do campo `debitosSomadosEmParte`), o corpo tem de trazer
  `debitosEmParteVistos` com o somado de agora, em decimal de texto ("50.00"). Sem ele, ou com outro, **409** com a frase
  e `detalhes` = `{motivo: 'debitos_em_parte', somado, devido}`, e nada gravado. O somado é conferido em centavos, pela
  máscara do serviço (`Monetario::deDecimal`, a do lançamento): "50,00", a máscara brasileira, não conta como visto. Com o
  somado visto, fecha como a 54: sem quitar, os débitos abertos. A recusa mora onde a do `debitos_somados` já morava --
  depois das do fiado, do caixa, do crédito e da primeira visita --; com a bandeira, a de antes (422 no campo) segue, e
  com os débitos cobertos o 409 `debitos_somados` segue, mesmo com o campo novo no corpo.
- **A tela nova** (`Comandas.tsx`, `Finalizar`): com o aviso da 54 na tela, o corpo leva `debitosEmParteVistos` com o
  somado do aviso. No 409 `debitos_em_parte` ela relê a comanda (`verComanda` -- o terceiro caminho que relê, pelo
  `motivo`, e nunca pela frase), diz *"A comanda mudou em outra tela: parte dos débitos do cliente foi somada ao total, e a
  finalização foi recusada até o aviso abaixo ser visto. A comanda foi relida — confira o aviso e finalize de novo."*, e o
  clique seguinte manda o somado relido. O tipo `Finalizacao` (`api/comandas.ts`) ganhou o campo, com a recusa escrita no
  `finalizar`. O dublê de fila de outros testes não pede o endereço (o `grep` pela `finalizacao` e pelos débitos em parte
  em todos os `.teste.tsx`: só o `ComandasLigada.teste.tsx`).
- **A 79, nas duas portas do "Quitar"** (`VendasPagamentosController::pagarDebitosAction` e
  `Adm\ComandasController::adicionarDebitosAction`): depois da recusa "não tem débitos" e antes de somar, uma consulta às
  outras vendas abertas do cliente na unidade da sessão (`VendaRepository::selecionarOutrasVendasAbertasDoCliente`: a em
  andamento, `status` nulo, e a comanda, 3; a unidade é a do operador da venda, o recorte de `selecionarComandaCliente`), e
  a primeira com débitos somados (`RegraDosDebitosNaVenda::outraComOsDebitosSomados`, pelo `temDebitosSomados` de cada
  uma) faz recusar: no ERP, `sucesso: false` e a frase, que o `pdvPagarDebitos` já mostra e que devolve o botão (o
  JavaScript e o `?cache=` não mudaram); na API, 409 com `detalhes` = `{motivo: 'debitos_em_outra_venda', venda}`. Nada é
  gravado. **A frase diz o que a outra venda é**: "na comanda 123" para a comanda aberta, e "na venda em andamento 123"
  para a do `/vendas/novo` de outro caixa, que não é comanda -- a resposta trazia só o exemplo da comanda, e a troca do
  substantivo é minha, sem mudar a regra. A recusa vale também quando a venda que pede já tem a soma: o "Quitar" dela não
  somaria de novo (a 14), mas ligaria a quitação, e as duas fechariam baixando e cobrando a mesma dívida. O portão do
  recorte (`conferir-recorte-por-unidade.php`) ganhou três entradas: a unidade na consulta e nas duas portas.

**Na cópia** (`franquiabv_dev`, MySQL, 12/10/2026, só leitura, sem dado pessoal): das 17 vendas abertas (em andamento ou
comanda), nenhuma tem o total acima dos itens -- nenhum "Quitar" fica preso por uma soma antiga --, e 1 cliente tem duas
vendas abertas na mesma unidade.

**Provado:** `D380QuitarComMarcaTest` (60 casos; eram 54 -- dois trocados, a recusa da 78 no lugar do caso que fixava a
tela que leu antes e a recusa da 79 no lugar do que fixava as duas vendas, e seis novos: a saída da 78 com o somado
visto, o somado mudado no meio ou mal escrito, os débitos cobertos com o campo novo, o caminho inverso da 79, o `POST
.../debitos` recusado e o controle da outra unidade e da cancelada), com o `D453SessaoPassaAoQueFicouPeloPrecoTest`, o
`ColunasForaDoMapeamentoTest`, o `D435`, o `D452`, o `D450` e o `D384` -- 141 casos, 551 asserções, verdes no MySQL e no
PostgreSQL, em série. Contra o código de antes (os sete arquivos de produção do item tirados do HEAD, com os casos e o
esquema novos), 12 vermelhos, os mesmos nos dois bancos -- os 7 do D380 que não são controle, os 4 do D453 e a lista do
`ColunasForaDoMapeamentoTest` --, e verdes os controles (os débitos cobertos com o campo novo; a bandeira recusada pelo
campo e a saída dela). Os vizinhos -- 53 arquivos de caso, derivados do `grep` pelas portas mudadas (o "Quitar", o
finalizar, o lançamento e a remoção do item, a `ColunaNoBanco` e a `vendas_produtos`), menos o `RoyaltiesTest`, que roda o
`royaltiesAtualizar` -- 807 casos, 2 pulados, verdes nos dois bancos. No front, `ComandasLigada.teste.tsx` (109 casos;
eram 107, dois novos e dois corpos trocados), `vitest` 1687 de 1687 com `--maxWorkers=4`, `build` e `lint` com saída 0
(os 6 avisos do lint são de arquivos que este item não toca). Com `AMPLIFICAR_EFEITOS_MS`, os 7 casos do item (os dos
débitos em parte e da bandeira recusada) passaram em 6 de 6 rodadas a 50 ms e em 3 de 3 a 10 ms, depois de esperar o
cartão da finalização pelo `findBy` -- na primeira rodada dois deles caíram sem achar o botão logo depois do título, a
forma do **D471**. O arquivo inteiro com o amplificador a 50 ms dá de 3 a 6 vermelhos, outros a cada rodada, todos fora
do item; o do HEAD, 8 -- o D471, que fica.

#### Os mutantes da quarta rodada

Um de cada vez, com o arquivo devolvido e conferido pelo sha256 depois de cada um (`medir-mutantes-r35.py`, no rascunho),
contra os 141 casos do filtro, no MySQL e no PostgreSQL:

| mudança | vermelho (o mesmo no MySQL e no PostgreSQL) |
|---|---|
| o "Quitar" do ERP sem a recusa da 79 | 2: as duas vendas abertas e o caminho inverso |
| o `POST .../debitos` da API sem a recusa da 79 | 1: a recusa da API |
| a consulta das outras vendas sem a unidade | 1: a venda de outra unidade |
| a consulta sem o estado (a concluída e a cancelada entram) | 1: o mesmo caso, pela cancelada |
| a consulta sem tirar a própria venda | 11: os segundos "Quitar" e `POST .../debitos`, os do débito novo e as saídas das recusas |
| qualquer outra venda aberta prende (sem o `temDebitosSomados`) | 43: todo "Quitar" do cenário, que tem três vendas abertas do cliente |
| a frase sempre "na comanda" | 1: o caminho inverso, da venda em andamento |
| o finalizar da API sem a recusa da 78 | 3: a recusa, a saída com o somado visto e o somado de antes |
| a recusa da 78 sem olhar o corpo | 3: a saída com o somado visto, a bandeira recusada e a saída dela, e o somado de antes |
| qualquer valor no campo conta como visto | 1: o somado de antes e o mal escrito |
| a recusa da 78 sem o somado e o devido em `detalhes` | 2: a recusa e o somado de antes |

No front, quatro mutantes em `Comandas.tsx`, um de cada vez (`medir-mutantes-front-r35.py`): sem mandar o somado visto,
4 vermelhos (os quatro corpos dos débitos em parte); sem reler no `debitos_em_parte`, 2, e sem a frase da releitura, 2 (a
recusa sem o aviso e o somado mudado); relendo em todo 409, 1 (o 409 de outro motivo). O `Comandas.tsx` do HEAD com os
casos novos: 4 vermelhos. O portão do recorte sai 1 com a unidade arrancada de cada uma das três entradas novas, uma de
cada vez, e 0 depois.

**O que fica:**

- **dois "Quitar" no mesmo instante**, em duas vendas abertas do mesmo cliente (lido no código, não medido): a consulta da
  79 não trava nada, e os dois pedidos podem passar pela pergunta antes de um deles somar -- o defeito da 79 de volta, numa
  janela de milissegundos, em dois caixas. A resposta escolheu uma consulta a mais, e não uma trava; o duplo clique na
  mesma venda segue coberto pela regra da 14 e pelo botão que se desabilita;
- **a 78 confere o somado, e não o devido**, como a resposta pede ("se o somado mudar no meio, recusa de novo"): um débito
  que nasce depois de a tela mostrar o aviso muda só o devido, e o finalizar fecha com o aviso antigo -- o cliente paga o
  mesmo somado, e o débito novo fica aberto com os outros;
- o débito pago por outro caminho no meio (já escrito acima; na verificação, foi ao lote -- ver abaixo);
- a autorização do fiado no servidor do ERP (o D86);
- nada disto foi aberto num navegador: nem a recusa da 79 no ERP, nem o 409 relido na tela nova.

#### A verificação da quarta rodada — 12/10/2026, o mesmo item; o D380 continua PARCIAL

Quatro achados da verificação, conferidos no código. O que cada um virou:

- **A dívida recebida pela tela de Devedores depois do "Quitar" ainda é cobrada duas vezes** -- o mesmo dinheiro da 79,
  por outra porta (lido no código, e agora fixado em dois casos). A quitação de Devedores (`DevedorService::quitar`, o
  serviço da porta do ERP, `DevedoresController::pagarDebitoAction`, e da API, `Adm\DevedoresController::quitarAction`)
  grava a venda do débito, o baixa e soma ao caixa sem olhar as vendas abertas do cliente. Com os 50,00 já no total da
  comanda e o devido em zero, nada recusa: a comanda da API não diz `debitosSomados` nem `debitosSomadosEmParte` (a tela
  nova não avisa), a recusa da 78 não dispara, e o finalizar fecha pelos 150,00. Pago em parte (30 de 50), a comanda
  reaberta do ERP liga a bandeira sozinha (`jaSomados`: 50 cobre 20), e os 30,00 saem duas vezes. Não é regressão --
  estava no "O que fica" desde 06/10 ("o débito pago por outro caminho no meio") --, mas não tinha ido ao lote, e o
  título fechava CORRIGIDO. Recusar é escolha do dono (em que porta, e com que saída): **foi ao lote** (12/10/2026), com a
  recomendação de a quitação de Devedores recusar, com a frase da 79, quando uma venda aberta do cliente na unidade tem os
  débitos somados. Os dois casos fixam o que roda hoje e mudam com a resposta:
  `testComOsDebitosPagosEmDevedoresDepoisDoQuitarAComandaDaApiCobraDeNovoSemAviso` e
  `testComParteDosDebitosPagaEmDevedoresDepoisDoQuitarAComandaReabertaCobraDeNovoAParte`. **É o PARCIAL do título.**
- **Dois "Quitar" no mesmo instante passam juntos pela consulta da 79** (lido no código, não medido), e o título dizia
  CORRIGIDO com esse resto no "O que fica". Nas duas portas, a consulta das outras vendas, a pergunta, o `aSomar` e o
  total correm sem trava; em dois caixas, com milissegundos de diferença, os dois veem as outras vendas sem excedente, e
  os dois somam. A resposta escolheu "uma consulta a mais", e não uma trava. A mesma janela vale para a quitação de
  Devedores contra o "Quitar", e a trava do cliente que a quitação já pega fecha as duas se o "Quitar" a pegar também:
  isso **entrou na pergunta de cima**, como parte da recomendação, e não foi feito aqui. Fica no PARCIAL do título, com ela.
- **A frase da 79 mandava o caixa da comanda finalizar uma venda em andamento que ele não alcança.** A venda do
  `/vendas/novo` só sai do PDV de quem a abriu: o cancelar recusa o `status` nulo (`VendasController::cancelarAction`, só
  1 e 3), a comanda reaberta do ERP (`comandaAction`) e as escritas da API exigem a comanda (3) -- o `GET` da API a
  mostra, e só --, e o recarregar do PDV descarta só a do próprio operador (`novoAction`, `apagarVendaTemporaria`). **O
  conserto:** na venda em andamento, a frase diz quem a solta, no molde da do D433 para o mesmo objeto, na forma impessoal
  (vale também quando quem pede é quem a abriu) -- "Os débitos deste cliente já estão somados na venda em andamento 123.
  Finalize-os lá: quem abriu essa venda a finaliza ou recarrega o PDV (recarregar descarta a venda em andamento)."
  (`RegraDosDebitosNaVenda::fraseDaOutraVenda`, nas duas portas); a da comanda não mudou. Um caso novo prova a saída que
  ela diz: com o `novoAction` despachado pelo gerente que a abriu, a venda em andamento vai embora com a soma, e o "Quitar"
  da comanda soma (`testRecarregadoOPdvDeQuemAbriuAVendaEmAndamentoOQuitarDaComandaSoma`). A de quem nunca volta ao PDV
  prende o "Quitar" do cliente na unidade sem fim: escolha do dono, **foi ao lote** (12/10/2026), com a recomendação de
  deixar como está. Na cópia (`franquiabv_dev`, MySQL, 12/10/2026, só leitura, sem dado pessoal): 16 vendas em andamento,
  de 2019 a 2026, todas de outro dia, nenhuma sem cliente e nenhuma com o total acima dos itens; e a 1 comanda aberta,
  também sem -- nenhum "Quitar" preso hoje.
- **Comentários e título que descreviam só as colunas do cupom, ou a linha de base de antes.** O `conferir-schema-tool.sh`
  dizia "volta a 4" no parágrafo do cupom e "volta a 6" no novo, números que não fechavam juntos; agora cada parágrafo diz
  quanto a sua coluna desce, e que com as três mapeadas a contagem volta a 4. O cabeçalho de `ISBase\Doctrine\ColunaNoBanco`
  passou a dizer as três colunas, quem pergunta por cada uma e o custo por pedido de cada porta. O título do D473 ganhou a
  marca da terceira coluna. E o inventário de ações regerado **trocou** uma linha (a do `pagarDebitos`, que passa a ler
  `ClienteDebito Venda`), e não acrescentou uma, como dizia o resumo do item; o roadmap foi corrigido.

**Provado:** `D380QuitarComMarcaTest` com 63 casos (eram 60; os três novos acima, e a constante da frase trocada), com o
resto do filtro do item (`D453`, `ColunasForaDoMapeamentoTest`, `D435`, `D452`, `D450` e `D384`) e os três arquivos que
usam o serviço de Devedores (`DevedorTest`, `DevedorSobTravaTest` e `D237DinheiroTest`): 183 casos, 712 asserções,
verdes no MySQL e no PostgreSQL, em série. O front não mudou.

**Os mutantes da verificação**, um de cada vez, com o arquivo devolvido e conferido pelo sha256 depois de cada um
(`medir-verificacao-r35v.py`, no rascunho), contra o `D380QuitarComMarcaTest`, no MySQL e no PostgreSQL:

| mudança | vermelho (o mesmo no MySQL e no PostgreSQL) |
|---|---|
| a frase da venda em andamento sem quem a solta (o "Finalize-os lá." de antes) | 2: o caminho inverso, da venda em andamento, e a saída pelo PDV recarregado |
| a frase sempre "na comanda" | 2: os mesmos |
| o `novoAction` sem descartar a venda em andamento | 1: a saída pelo PDV recarregado |
| a quitação de Devedores com a opção (a) da pergunta nova (recusa com a soma numa venda aberta) | 2: os dois que fixam o que roda hoje, e que mudam com a resposta |

**O que fica, além do PARCIAL do título:**

- a dívida recebida em Devedores com a soma pendente, e a corrida de dois "Quitar" no mesmo instante (pergunta ao lote,
  12/10/2026);
- a venda em andamento de quem nunca volta ao PDV (pergunta ao lote, 12/10/2026);
- a 78 confere o somado, e não o devido (escrito acima, na quarta rodada);
- a autorização do fiado no servidor do ERP (o D86);
- nada disto foi aberto num navegador: nem a frase nova da 79 no ERP, nem a recusa da 78 na tela nova.

**Decidido pelo dono em 12/10/2026 (as respostas 160 e 161):**

- **160, opção c, e não a a recomendada:** a dívida recebida em Devedores com a soma pendente numa venda aberta fica como
  está, registrada -- e a corrida de dois "Quitar" no mesmo instante, que ia junto na (a), com a trava do cliente,
  também fica. Os dois casos que fixam o que roda hoje
  (`testComOsDebitosPagosEmDevedoresDepoisDoQuitarAComandaDaApiCobraDeNovoSemAviso` e
  `testComParteDosDebitosPagaEmDevedoresDepoisDoQuitarAComandaReabertaCobraDeNovoAParte`) passam a ser o comportamento
  escolhido;
- **161, opção a, a recomendada:** a venda em andamento abandonada prende o "Quitar" do cliente até quem a abriu voltar
  ao PDV, com a frase nova (a da verificação da quarta rodada, que diz quem a solta).

Nada mudou no código. **Com isso nada resta de pé no débito:** do "O que fica" acima, a 78 conferir o somado, e não o
devido, é a resposta 78 como o dono a escreveu ("se o somado mudar no meio, recusa de novo"); a autorização do fiado no
servidor do ERP mora no D86; e nenhuma das telas foi aberta num navegador, o que é limite da prova, e não defeito.

### D381 — A compra de crédito pelo ERP grava sem trava, e a devolução sob trava não a espera — REGISTRADO em 03/10/2026 — CORRIGIDO em 15/10/2026

**Achado ao consertar a devolução do D285, lido no código, não medido.**
`ClientesController::creditoComprarAction` (o ERP) chama `ClienteService::creditoComprar` direto: a
venda, o pagamento, o caixa (`valorFinal + x`) e o crédito (`valor + x`) em quatro gravações, sem
transação e sem trava, sobre o caixa lido por `selecionarPorUsuario` antes de tudo. A API passa por
`comprarCreditoTravado` desde o D280, que trava o cliente e relê o caixa. Com a devolução travando o
cliente (D285), a compra da API e a devolução se enfileiram; a compra do ERP não pega trava nenhuma, e
uma devolução no meio dela pode sumir do saldo (a compra regrava `valor + x` sobre o número que leu).

**O conserto já está dado** -- o ERP chamar `comprarCreditoTravado`, como o D232 fez com o "Quitar" nas
duas telas --, e não foi feito no D285 porque o item era a devolução. **O que não é técnico:** a página
de compra do ERP aceita o caixa de outro dia, e recusar lá é decisão do dono (o D280).

**15/10/2026:** o que não era técnico foi decidido e feito -- a página recusa o caixa de outro dia (a resposta 88, o
D280). A trava continua de fora.

**Não é a única escrita sem a trava:** o finalizar do PDV, a comissão de indicação e o cancelamento
de venda também regravam o saldo sem ela -- o D383.

**15/10/2026, com a resposta 85 do dono (a fatia 5c):** o conserto dado. `ClientesController::creditoComprarAction`
chama `ClienteService::comprarCreditoTravado`, como a API: o cliente e o caixa travados, tudo numa transação, e o caixa
que fechou enquanto o pedido esperava leva a página ao caixa. E `creditoComprar`, que as duas portas usam, soma o caixa e
o crédito por conta no banco (`CaixaRepository::somarAoSaldo`, `ClienteCreditoRepository::somarAoSaldo`). Provado em
`D321FinalizarUmaVezSoTest` (a primeira trava do pedido do ERP é a do cliente, e o crédito que outra sessão baixou no meio
recebe a compra por cima) e no `FidelidadeTudoOuNadaTest` (a falha no `UPDATE` do crédito desfaz a venda e o caixa).

### D382 — A devolução de crédito pelo ERP aceita valor negativo, que aumenta o crédito — REGISTRADO em 03/10/2026 — CORRIGIDO em 15/10/2026

**Achado ao consertar a devolução do D285, lido no código, não medido.** O formulário do ERP
(`CreditoDevolucaoFilter`) só confere que o valor não está vazio. `Monetario::removeMask("-10,00")` dá
`"-10.00"`, que cabe em qualquer saldo, e o serviço grava `valor - (-10)`: o crédito do cliente SOBE dez
reais, sem venda nem pagamento. Pede a credencial do gerente, como toda devolução. Era assim antes do
D285 (a comparação em `float` deixava passar do mesmo jeito); a API recusa, porque
`RegraDaFidelidade::conferirDevolucao` só aceita dígitos. A máscara de dinheiro da tela provavelmente
não deixa digitar o sinal -- não conferido --, então o caminho é o pedido escrito à mão.

**O conserto provável:** o serviço recusar valor que não é positivo, com uma frase própria no ERP (a de
hoje diria "superior ao crédito"). Não feito no D285 para não mudar a frase da tela sem olhar a tela.

**15/10/2026, com a resposta 89 do dono (a fatia 5c):** `ClienteService::creditoDevolucao` recusa o valor que não é
positivo antes de tudo (`DEVOLUCAO_SEM_VALOR`), e o ERP diz *"Informe um valor maior que zero."*; a API já o recusava no
campo. Provado em `D179DevolucaoRegistradaTest` ("-10,00" e "0,00" pelo serviço, e "-10,00" pelo ERP, com o saldo
intacto).

### D383 — O finalizar do PDV, a comissão de indicação e o cancelamento de venda regravam o saldo de crédito sem a trava do cliente: a devolução sob trava do D285 é sobrescrita por eles — REGISTRADO em 03/10/2026 — PARCIAL em 15/10/2026 — CORRIGIDO em 07/11/2026 (a fatia 101: o cancelamento e o resgate de pontos, no banco)

**Achado na revisão do D285 (03/10/2026), lido no código, não medido.** A devolução de crédito
(`ClienteService::creditoDevolucao`) trava o cliente, relê o saldo e só então grava; a compra pela
API (`comprarCreditoTravado`) pega a mesma trava. As outras escritas de `clientes_creditos.valor`
leem o crédito por `selecionarCreditoCliente` (DQL, sem trava), calculam `valor ± x` na memória e
gravam, sem transação nem `FOR UPDATE`:

| escrita | onde | conta |
|---|---|---|
| o crédito usado no pagamento | `VendaPagamentoService::finalizar` (linhas 113-118) | `valor - usado` |
| o troco que vira crédito | `VendaPagamentoService::finalizar` (linha 175) | `valor + troco` |
| a comissão de indicação | `VendaPagamentoService::gerenciarComissaoIndicacaoPrimeiraVenda` (linha 317) | `valor + comissão` |
| cancelar a venda de crédito | `VendaService::cancelar` (linha 203), sobre o crédito que os controllers leram | `valor - total` |
| o estorno do pagamento em crédito | `VendaService::cancelar` (linha 210) | `valor + pago` |
| a compra pelo ERP | `ClienteService::creditoComprar`, pelo `creditoComprarAction` (o D381) | `valor + x` |

O `finalizar` é chamado pelo `VendasPagamentosController` (o ERP) e pelo `Adm\ComandasController` (a
API), e nada em volta trava. O cancelamento confere o saldo no controller
(o `cancelarAction` de `Adm\VendasController` e o de `VendasController`, `credito >= total`) e grava depois,
no serviço -- a corrida do D228 e do D232.

**Passo a passo (o caso mais comum):** o saldo é 100; o caixa finaliza uma venda paga com 50 de
crédito, e o PDV lê 100; o gerente devolve 30 -- a devolução trava o cliente, relê 100, grava 70 e
confirma; o `UPDATE` do PDV, que esperava a linha, grava 100 - 50 = 50. O certo era 20: o cliente
ganha R$ 30. **No cancelamento:** o crédito de 100 vem de uma venda de crédito de 100; o
cancelamento lê 100 e confere 100 >= 100; a devolução de 100 trava, grava 0 e confirma; o
cancelamento grava 100 - 100 = 0 por cima. Saem R$ 200 do caixa contra um crédito de R$ 100, o saldo
fica 0, e ninguém é recusado.

**Não é regressão:** antes do D285 a devolução também gravava sem trava, e a corrida já existia entre
todas elas. O D285 enfileirou a devolução com a compra da API, e só.

**O conserto provável** é o molde do D285 em cada escrita: a trava do cliente, a releitura do crédito
com trava, e a conta sobre o relido -- no cancelamento, a conferência do saldo também sob a trava. O
`finalizar` não tem transação: as gravações dele são muitas e soltas (o D28 fala do que sair no meio
deixa), e pôr a trava ali muda a forma da gravação inteira do PDV. Por isso não foi feito na revisão
do D285, que era a devolução; é técnico, e não pede decisão.

#### O finalizar, a comissão e a compra pelo ERP somam no banco — 15/10/2026, a resposta 85 (a) do dono (a fatia 5c)

Da tabela de cima, quatro escritas passaram a ser conta no banco, por `ClienteCreditoRepository::somarAoSaldo` (e
`somarAoSaldoDoPrograma`, o `valor_pf`): o crédito usado no pagamento, o do programa, o troco que vira crédito
(`VendaPagamentoService::gravarAteAVendaFechada`) e a comissão de indicação (`gerenciarComissaoIndicacaoPrimeiraVenda`,
com a hora da alteração, como antes); e a compra pelo ERP (`ClienteService::creditoComprar`, o D381), que também passou a
correr sob a trava do cliente. A devolução sob a trava, que termina no meio, fica. Provado em `D321FinalizarUmaVezSoTest`,
nos dois bancos, também com outro processo segurando a linha do crédito.

**O que fica: o cancelamento de venda.** `VendaService::cancelar` continua tirando o recebido do caixa
(`valor_final - recebido`) e mexendo no crédito (`valor - total` na venda de crédito, `valor + pago` no estorno) sobre o que
o pedido leu: a retirada e a devolução que terminam no meio são desfeitas. Não foi levado ao banco junto, de propósito, e
por uma corrida que a fatia achou: a guarda das duas portas (`VendasController::cancelarAction`, o D317, e
`Adm\VendasController::cancelarAction`, o D213) confere a situação que o pedido leu, e dois cancelamentos da mesma venda no
mesmo instante, de sessões diferentes, passam os dois -- o estoque e as sessões do pacote, que já são conta no banco (o D388
e a resposta 60), voltam duas vezes. O caixa e o crédito NÃO saem duas vezes hoje só porque a regravação de um apaga a do
outro; com a conta no banco, sairiam. **O conserto provável** é o da resposta 85 levado ao cancelamento -- a regra que o dono
deu ao estoque, ao pacote e ao finalizar, *"levada ao resto da venda"*: o `UPDATE` condicional do estado (`status = 0` onde
ainda é o que o pedido conferiu) como a primeira escrita, e depois a conta no banco. É técnico, e cabe numa fatia própria,
com os dois bancos e a corrida com outro processo, no molde do `D321FinalizarUmaVezSoTest`.

**E a regravação do cancelamento apaga também as contas no banco dos outros** (a verificação da fatia, 15/10/2026): não
só a retirada e a devolução que terminam no meio. O cancelamento lê o caixa e o crédito sem trava e grava o número inteiro
(`VendaService::cancelar`, `setValorFinal(getValorFinal - recebido)` e `setValor(valor ± x)`): o finalizar de outra venda
no mesmo caixa que soma no meio (`CaixaRepository::somarAoSaldo`) some da coluna -- o cancelamento lê 300, o finalizar soma
70 (370), o cancelamento grava 300 - 50 = 250, e os 70 da venda somem; e o mesmo no crédito, com a compra de crédito, a
comissão de indicação, o troco que vira crédito e o crédito usado no pagamento, somados no meio. A coluna se corrige no
próximo recálculo do caixa (a tela de fechamento); o crédito do cliente, não.

**O resgate de pontos também regrava o crédito do programa** (`ClienteService::programaFidelidadeResgatarPontuacao`, pelas
duas portas: `Adm\FidelidadeController` e `ClientesController`): o crédito vem de `selecionarCreditoCliente`, sem trava, e o
resgate grava `setValorPf(getValorPf + valor)` e `setPontuacao(0)` com o número inteiro. Um finalizar pago com o crédito do
programa que termina no meio (`somarAoSaldoDoPrograma`, `valor_pf - x`) é apagado, e o cliente fica com o crédito que
usou. A tabela de cima só listava os escritores de `valor`. Não foi levado ao banco junto, pelo mesmo motivo do
cancelamento: o resgate não tem trava, dois resgates dos mesmos pontos no mesmo instante passam os dois, e hoje a
regravação de um apaga a do outro; com a conta no banco, o crédito do programa sairia em dobro. O conserto provável é o do
cancelamento -- primeiro fechar a corrida (a trava do cliente, como a devolução, ou a conta condicional dos pontos), e
depois a conta no banco --, na mesma fatia técnica.

#### O cancelamento e o resgate de pontos, no banco — 07/11/2026, a fatia 101 (FINALIZAR-E-CANCELAR-SEM-DINHEIRO-EM-DOBRO)

**O cancelamento** (`VendaService::cancelar`, as duas portas do PDV): as travas vêm antes de qualquer escrita -- o cliente
e, depois, o caixa, a ordem do finalizar e da devolução; a venda de crédito confere o saldo sob a trava do cliente, relido e
em centavos (`CANCELAMENTO_SEM_SALDO`: as portas dizem a frase que já diziam, sem sucesso -- o ERP a do saldo, a API o 409); a PRIMEIRA ESCRITA é o `UPDATE` condicional do
estado (`VendaRepository::cancelarSeAinda`: `status = 0` onde ainda é o que o pedido conferiu), e quem chega depois volta sem
gravar nada (`CANCELAMENTO_JA_MUDOU`), com a frase pelo estado de agora (`fraseDaVendaQueMudou`: *"Esta venda já está
cancelada."*, ou *"Esta venda mudou de situação enquanto o cancelamento era pedido. Abra a venda de novo e confira antes de
cancelar."* para a comanda que outro caixa finalizou no meio; 409 na API). Depois, o caixa sai por
`CaixaRepository::somarAoSaldo` (o recebido, como antes) e o crédito e o do programa voltam por `somarAoSaldo` e
`somarAoSaldoDoPrograma`: o que outra sessão somou no meio fica. **O resgate de pontos**
(`ClienteService::programaFidelidadeResgatarPontuacao`, as duas portas): trava o cliente e relê o crédito com trava, e só
resgata se os pontos relidos são os que o pedido viu (`RESGATE_PONTOS_MUDARAM`, sem nada gravado; 409 na API pela
`respostaDoResgate`); o `valor_pf` soma por conta no banco.

**Provado** em `D383CancelarUmaVezSoTest` (21 casos), nos dois bancos: a primeira escrita é o `UPDATE` condicional; quem
chega depois do outro cancelar não devolve caixa, crédito nem estoque de novo; a comanda finalizada no meio não é cancelada
como comanda; as duas portas dizem a frase; o caixa, o crédito e o programa (também sozinho, sem a soma do crédito antes, que relia a linha) somam por cima do que outra sessão gravou; a venda de crédito
recusa o saldo que baixou no meio e cancela com saldo; o resgate recusa os pontos que outro resgatou e soma o programa por
cima do finalizar do meio. Com OUTRO PROCESSO: dois cancelamentos -- só um vence, o caixa e o crédito uma vez; o que espera
a desistência do outro vence; dois resgates -- um vence. Os vizinhos ajustados: o portão de `CancelamentoGravaQuemEQuandoTest`
conta os dois `UPDATE` novos e confere que só o `VendaService` chama o condicional; o `PacoteEncerradoNoCancelamentoTest`
(a venda de crédito sem crédito agora recusa antes de escrever; a falha depois do `flush`, com o saldo que cobre a venda).
Mutantes na tabela do roadmap.

**Na verificação da fatia (07/11/2026), os casos que faltavam**, nos dois bancos (`D383CancelarUmaVezSoTest`, agora 26):

- **As portas na recusa do saldo** (`testAsDuasPortasDizemAFraseDoSaldoQueBaixouNoMeio`): a venda de crédito de 80 com o
  crédito em 100 passa a conferência das duas portas, e uma devolução o baixa a 50 antes do serviço. O ERP diz *"Não será
  possivel cancelar, o cliente não possui crédito suficiente para estornar."* sem sucesso, e a API o 409 *"O cliente não tem
  crédito suficiente para o estorno desta venda."*; nada gravado. Mutantes (MySQL): sem o ramo na API, 200 com a venda ainda
  concluída; sem o ramo no ERP, *"Venda cancelada e crédito estornado com sucesso."* sem nada cancelado -- os dois vermelhos.
- **A trava do caixa no cancelamento** (`testOCancelamentoTravaOCaixaAntesDaPrimeiraEscrita`, com OUTRO PROCESSO): uma sessão
  sob a trava do caixa que, ainda com ele preso, lê a venda com trava. O cancelamento espera o caixa sem ter tocado a venda,
  e os dois terminam. Mutante, nos dois bancos: sem a trava do caixa, o cancelamento escreve a venda e espera o caixa, o
  outro espera a venda, e o banco derruba um dos dois (*deadlock*) -- vermelho.
- **A trava do cliente no resgate** (`testOResgateEsperaADevolucaoNaOrdemDoCliente`, com OUTRO PROCESSO): uma devolução na
  ordem do `creditoDevolucao` (o cliente e, 1,5 s depois, o crédito) e o resgate dos 400 pontos: o resgate espera, relê 90 e
  soma por cima (90 e 20), e o código do aplicativo sai. Mutante, nos dois bancos: sem a trava do cliente, o resgate prende o
  crédito e espera o cliente para apagar o código, e o outro espera o crédito -- *deadlock*, vermelho.
- **A trava do cliente no cancelamento** é a do D694 (a última visita), e o caso está lá; no MySQL ela não tem mutante
  vermelho possível, pelo índice da chave estrangeira (dito lá).

**Não provado:** o 409 do resgate pela API (`Adm\FidelidadeController`) não tem caso em processo -- a recusa do serviço tem.

### D384 — Abrir a comanda de um cliente reaproveita a venda em andamento de outro, com os pagamentos dela — REGISTRADO em 03/10/2026 — CORRIGIDO em 06/10/2026

**Achado no conserto do D166, e medido em parte.** `VendaService::comandaAbrir` (pelo ERP, em
`VendasController::comandaAbrirAction`, e pela API nova, no `POST /adm/api/comandas`), quando o
cliente não tem comanda aberta e o operador tem uma venda em andamento, NÃO cria comanda: troca o
cliente da venda em andamento e a vira comanda -- com os itens e os pagamentos lançados para o
cliente de antes. O `D166CreditoSemLinhaTest` prova a parte do pagamento: o pagamento em crédito,
conferido para o primeiro cliente, fica na comanda do segundo.

**O que isso faz, lido no código:** com o segundo cliente SEM linha de crédito, o finalizar agora
recusa com a frase (o D166). Com o segundo cliente COM linha, o finalizar debita dele o crédito que foi
conferido contra o saldo do primeiro -- sem piso (o D160), o saldo dele pode ficar negativo. E o
pagamento em dinheiro do primeiro cliente fecha a comanda do segundo.

**Não medido:** quantas comandas na cópia nasceram assim -- a venda não guarda o cliente de antes.
**É pergunta ao dono**, porque muda o que o balcão vê ao abrir comanda com uma venda pela metade: tirar
só os pagamentos em crédito ao trocar o cliente, tirar todos (como o acrescentar item já faz), ou
recusar abrir a comanda de outro cliente enquanto a venda em andamento tiver pagamento. A
recomendação, no lote, é a recusa.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 15): a opção (c), "Recusar enquanto a
venda em andamento tiver pagamento", com a frase dizendo para remover os pagamentos ou finalizar a venda
antes -- nas duas portas (`VendasController::comandaAbrirAction` e o `POST /adm/api/comandas`). ~~A
implementação está na fila.~~ (feita em 06/10/2026, logo abaixo)

#### A recusa — CORRIGIDO em 06/10/2026, o item PDV-QUITAR-E-COMANDA

**O conserto**, no serviço que as duas portas chamam: `VendaService::comandaAbrir` não troca o cliente da
venda em andamento que tem pagamento lançado -- devolve `venda` nula, e a venda que impede em
`vendaComPagamento` --, e as portas escrevem a frase. O ERP (`comandaAbrirAction`) responde `sucesso: false`
e *"Há uma venda em andamento no PDV com pagamento lançado para outro cliente, código: N.<br/>Remova os
pagamentos dela ou finalize a venda antes de abrir esta comanda."*, que o `pdvComandaAbrir` passou a
mostrar (antes ele dizia sempre *"Não foi possível abrir a comanda."*); a API, 409 com a mesma frase e
`detalhes.motivo = venda_em_andamento_com_pagamento`, que a tela nova mostra no aviso de erro de sempre.
Segue como antes: sem pagamento; com o mesmo cliente -- o "Abrir comanda" do `/vendas/novo` manda o
cliente da própria venda, e é o caminho do balcão --; e com o cliente que já tem comanda aberta, que a
recebe de volta sem tocar na venda em andamento (o D52). A venda em andamento sem cliente conta como de
outro: o pagamento dela não foi conferido contra ninguém.

*Corrigido na revisão (06/10/2026):* "o 'Abrir comanda' manda o cliente da própria venda" tem uma exceção
pela tela. O "Cliente sem cadastro" grava a venda com o cliente 2; cadastrar a pessoa no meio da venda (o
botão do cliente abre o cadastro vazio, porque o 2 não se edita) troca o `#pdvcliente_id`, e a venda
continua do 2. Com pagamento lançado, o "Abrir comanda" dela é recusado, sendo a mesma pessoa -- o
**D434**, com a pergunta ao lote. Antes do D384 a venda virava a comanda e a página da comanda apagava o
pagamento (`apagarPagamentos`, a cada abertura): o operador não perde mais do que perdia.

**Provado** em `D384ComandaDeOutroClienteTest` (7 casos), verdes nos dois bancos; contra o código de antes,
os 3 da recusa vermelhos (o serviço, o ERP e a API) e os 4 controles verdes. Mutantes, um de cada vez: a
recusa arrancada (os 3), a recusa também para o mesmo cliente (os 2 dele, pelo ERP e pela API), também sem
pagamento (1) e antes da comanda reaproveitada (1). No front, o "Abrir comanda" mostra a frase do servidor
(`quitarDaTelaAntiga.teste.ts`; o mutante sem ela, 1 vermelho), e a tela nova mostra a recusa e não abre
(`ComandasLigada.teste.tsx`). O `D166CreditoSemLinhaTest` deixou de montar a comanda herdada por
`comandaAbrir`, que agora a recusa, e a grava como ele gravava; a guarda do D166 continua para as que já
existem.

*Na revisão (06/10/2026):* o caso da tela nova **não prova o conserto** -- ela já mostrava a frase de qualquer
409 e passa sem ele; é guarda de que continue mostrando. Ganharam caso duas promessas desta seção que nenhum
vermelho sustentava: os `detalhes` da API (`motivo` e a venda; arrancá-los deixa `testAApiRecusaComAFrase`
vermelho) e a venda em andamento sem cliente, que recusa também (trocar o ramo deixa
`testAVendaEmAndamentoSemClienteComPagamentoTambemRecusa` vermelho). E o "Sem Cadastro" recusado ficou
retratado (`testOSemCadastroComPagamentoERecusadoParaOClienteCadastradoNoMeio`), à espera da resposta do
D434. Os 9 casos rodaram contra o código de antes nos dois bancos (os 5 da recusa vermelhos, os 4 controles
verdes), e o mutante da recusa arrancada também (os 5).

**O que fica:** os débitos somados pelo "Quitar" não são pagamento, e a venda em andamento com eles e sem
pagamento continua virando a comanda de outro cliente -- o D433. O "Sem Cadastro" cadastrado no meio da
venda é recusado -- o D434. Não medido: quantas comandas nasceram assim (a venda não guarda o cliente de
antes). Não visto no navegador.

*08/10/2026, o item PDV-QUITAR-SEGUNDA-RODADA:* as duas sobras foram resolvidas pelas respostas do dono --
a recusa olha também os débitos somados pelo "Quitar" (o D433, a 40), e a venda do "Sem Cadastro" saiu dela
(o D434, a 42). A recusa dos pagamentos continua para a venda de outro cliente cadastrado, para a venda sem
cliente e para o avulso.

### D385 — Excluir pelo ERP uma parcela ou despesa já excluída regrava a data e diz sucesso — CORRIGIDO em 04/10/2026

**Sobra da revisão do D208**, que a deixou registrada "sem verificação independente" dentro dele. O
conserto do VENDAS-CAIXA-CUPOM tratou só da outra sobra (a retirada negativa), e a verificação dele
confirmou esta no código de hoje, por leitura; nada executado. Ganhou número para não sumir sob o
"CORRIGIDO" do título do D208.

- `SaidaService::excluirParcela` só recusa a parcela com pagamento (`temPagamento()`, o D135), e não
  olha `getStatus()`. `SaidasController::excluirParcelaAction` (ERP) a acha por `selecionarParcela`,
  que só confere a unidade: um POST na parcela já excluída regrava `data_alteracao` na parcela e na
  despesa e mostra *"Parcela excluída com sucesso."*. O lote (`excluirParcelas`) delega a ela.
- `SaidaService::excluirSaida` também não olha o estado: `excluirSaidaAction` (ERP), por
  `selecionarSaida`, regrava a data da despesa e de cada parcela dela e diz *"Saída excluída com
  sucesso."*.

**A API nova já recusa os dois**, antes de chamar o serviço: 409 com *"A parcela já está excluída."* e
*"A saída já está excluída."* (`Adm\SaidasController`). O ERP é a porta que falta.

**O que custa:** nada some -- já estava excluído --, mas a data de alteração passa a ser a do pedido
repetido, e a tela confirma uma exclusão que não aconteceu agora. Não medido: a exclusão repetida não
deixa outra marca além da data.

**O conserto é técnico, e tem molde:** a recusa no serviço, como a da regra do D208 ("parcela e
despesa ativas para lançar, pagar, quitar e editar"), com as frases da API na tela velha; e um caso
por porta, vermelho antes.

#### CORRIGIDO em 04/10/2026 -- o item FINANCEIRO-S1

**O conserto:** `SaidaService::excluirParcela()` e `excluirSaida()` recusam o que já está excluído,
sob a trava da despesa (a nota de 04/10/2026 no fim do D205); as duas portas leem a recusa e dizem qual foi -- o ERP *"Esta
parcela já estava excluída."* e *"Esta saída já estava excluída."*, a API as frases que já tinha. A API
deixou de conferir por conta própria antes de chamar: a regra mora no serviço, e ela só escreve a frase
-- e passou a ler a resposta de `excluirParcela()`, que ignorava. O lote recusa a já excluída também.

**Provado**, nos dois bancos, com a data como prova: a parcela excluída das fixtures não tem data de
alteração, e continua sem; a despesa excluída tem a data da primeira exclusão posta no passado, e ela
fica. `SaidaFinanceiroTest` (o serviço), `SaidasDoErpTest` (as frases da tela) e `SaidaApiEscritasTest`
(o 409 e a data, na parcela e na despesa). Mutantes: a recusa arrancada da parcela -- cinco vermelhos,
nas três portas (o lote do serviço e o do ERP inclusive); a da despesa -- três, um por porta (contagem
corrigida pela revisão, no log dos mutantes; a primeira versão daqui dizia três e dois).

### D386 — Personificando um administrador, quem entrou altera os próprios privilégios e o próprio grupo: a trava do D288 compara só com quem a sessão mostra — CORRIGIDO em 04/10/2026

**Achado no item SESSAO-SENHA-EMAIL**, ao consertar a irmã do D294 (ninguém ativa nem desativa a si
mesmo), que tinha o mesmo furo e passou a perguntar a `CrudController::ehASiMesmo` (fim do D315). Lido
no código; nada executado.

- A regra 1 do D288 -- ninguém altera os próprios privilégios nem o próprio grupo -- recebe quem pede
  como o `usuarioId`, que na personificação é a pessoa assumida (X), e não quem entrou (A):
  `UsuarioService::recusaDoPrivilegio` e `recusaDoGrupo` (`$quemPede`), chamados pela janela do ERP
  (`permissoesSalvarAction`), pela edição (`editarAction`) e pela API
  (`Adm\PrivilegiosController::recusa`); e a remoção na janela do ERP (`permissoesRemoverAction`) e o
  `proprio` da API (`Adm\PrivilegiosController::ehQuemPede`) comparam com o `usuarioId` direto.
- **O efeito:** A, personificando um administrador X, muda os próprios privilégios e o próprio grupo
  -- inclusive tira de si o grupo de administrador. Para conceder a si o que não tinha, A já é
  administrador (o grupo dele concede tudo): o dano é contornar a regra, e não subir de nível.
- Não medido: personificação não deixa rastro (D127).

**O conserto é técnico, e tem molde:** passar `ehASiMesmo` (ou o par de `loginDaSessao`) à regra nas
quatro portas, como no D294, com um caso de personificação de verdade por porta (o arnês do
`D315PersonificacaoTest`). Fica fora do D127: a autoria das escritas na personificação (o teto "só
concede o que tem" lido de X) continua com o dono.

#### CORRIGIDO em 04/10/2026 -- o item GRUPOS-F0

**O conserto:** `UsuarioService::recusaDoPrivilegio` e `recusaDoGrupo` ganharam o último parâmetro,
`$aSiMesmo`, e as portas passam `CrudController::ehASiMesmo` do alvo -- que olha também quem entrou
(`loginDaSessao`): a janela do ERP (`permissoesSalvarAction`), a edição (`editarAction`, a troca de
grupo) e a API (`Adm\PrivilegiosController::recusa`). A remoção na janela do ERP e o `proprio` da API
(`ehQuemPede`) perguntam a `ehASiMesmo` direto, e os `concediveis` da API ficam vazios para quem entrou
-- a tela não oferece o que o servidor recusa. O teto "só concede o que tem", lido de quem a sessão
mostra, continua como está (o D127, com o dono).

**Provado** nos dois bancos: `D315PersonificacaoTest`, com a personificação de verdade (a `SessaoAcl` do
`autenticarComo`): o `PUT` da API e a janela do ERP recusam a quem entrou, e a linha não nasce;
`PrivilegiosPelosControllersTest`, porta a porta e com o controle (em outra pessoa, grava): o `PUT`, a
exclusão, a leitura (`proprio` e `concediveis`), salvar e remover na janela; e `UsuarioAtivacaoTest`, a
troca de grupo pela edição. Mutantes, um de cada vez: cada porta sem quem entrou, o `ehQuemPede` e os
`concediveis` de volta, e o serviço ignorando o parâmetro, nos dois métodos -- oito, vermelhos. O
`conferir-autorizacao-privilegiada.php` passou a exigir `ehASiMesmo` nas portas: vermelho com ele arrancado
da API e da janela do ERP; a edição o usa também para o estado, e por isso só o caso pega a troca de grupo
sem ele.

### D387 — A retirada de produto que não é da unidade caía com 500: o D152 escreveu que a recusa ficara limpa, e o ramo dela seguia chamando `getQuantidade()` no nulo — CORRIGIDO em 04/10/2026

**Achado no item ESTOQUE**, ao levar o piso da retirada ao serviço (D126). O D152 trocou o `find` cru
de `ProdutosController::retiradaAction` por `selecionarProduto()` e registrou que "a recusa é limpa".
Não era: a recusa morava no `else` da conferência
`if (!empty($produto) && $produto->getQuantidade() >= ...)`, e o `else` montava a frase com
`$produto->getQuantidade()` — no nulo. Produto de outra franquia, ou id inexistente, seguia caindo com
**500** (*"Call to a member function getQuantidade() on null"*, medido em processo sobre o HEAD).

**O conserto:** o nulo tem ramo próprio e responde `{sucesso: false, mensagem: "Produto não
encontrado."}`. Provado por `EstoqueSobTravaTest::testARetiradaDeProdutoQueNaoSeAchaRecusaSemErro`,
vermelho antes (o erro acima) e verde depois, nos dois bancos; e pelo mutante que devolve o ramo
antigo.

**Visto e não mexido:** a irmã morta, `retiradaPdvAction` (D152: sem chamador, rota viva, remover é
decisão do dono), tem o mesmo `else` no nulo e chama `ProdutoService::retiradaPdv()`, **que não
existe** — passado das guardas, o pedido cai com 500 por método indefinido, antes de escrever
qualquer coisa. Fica com a decisão do D152.

### D388 — A venda, o cancelamento da venda e a edição do produto gravam o saldo do estoque sem a trava do produto — REGISTRADO em 04/10/2026 — CORRIGIDO em 07/10/2026

**Achado no item ESTOQUE**, ao pôr o lançamento, o cancelamento de movimentação e a retirada sob a trava
do produto (D126). A trava só põe em fila quem a pede. Três escritas gravam `produtos.quantidade` sem
ela, e são DOIS defeitos diferentes:

**1. A venda e o cancelamento da venda — corrida dentro do pedido.**

- `VendaPagamentoService::finalizar()` — a baixa da venda (`quantidade - vendida`);
- `VendaService`, no cancelamento da venda — a devolução (`quantidade + vendida`).

Os dois gravam o número inteiro a partir do que leram no começo do pedido: uma movimentação que
termine entre a leitura e a gravação é apagada por cima — a venda grava "10 - 1" quando o banco já
tinha 6. A janela é a duração do pedido. Lido no código; não medido: nada guarda o saldo de antes. É o
mesmo ponto que o D228 deixou para o caixa ("a venda grava `valor_final` sem a trava").

**2. A edição do produto — formulário velho, sem corrida nenhuma** (corrigido na revisão do item: a
primeira versão deste registro a pôs junto da venda, como corrida de milissegundos).

`Form\Produto::setFormData()` preenche o campo `quantidade` — editável, sem `readonly` no
`editar.phtml` — com o saldo do momento em que a página abre, e `ProdutoService::atualizar()` grava o
POST, porque `quantidade` está em `CAMPOS_DA_EDICAO`. Passo a passo: o gerente abre a edição com 10 no
estoque; pela aba "Estoque" sai 5 (ou uma venda baixa 3); minutos depois ele salva só um preço novo, e
o saldo volta a 10. A movimentação continua na lista, e cancelá-la depois soma mais 5. A janela é o
tempo em que a página fica aberta, com um usuário só. E a edição grava qualquer valor, abaixo do saldo
inclusive, sem piso e sem linha de movimentação — embora a própria tela diga que *"a atualização da
quantidade deve ser feita na aba Estoque"*.

**São duas perguntas ao dono**, no lote de 04/10/2026. A da venda: levar a trava à venda, trocar a
gravação por uma conta no banco (`quantidade = quantidade - ?`), ou deixar — as duas primeiras mexem
em toda venda das 32 lojas. A da edição: tirar `quantidade` dela (o campo fica só de leitura), gravar
só quando o gerente mudou o número, ou deixar.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, perguntas 20 e 21): na 20, a opção (b), "Gravar
por conta no banco (quantidade = quantidade - ?)" -- a baixa da venda e a devolução do cancelamento
deixam de gravar o número lido no começo do pedido, sem trava e sem segurar a linha durante a venda; na
21, a opção (a), "Tirar a quantidade da edição" -- o campo fica só de leitura, `quantidade` sai de
`ProdutoService::CAMPOS_DA_EDICAO`, e ajustar o saldo passa a ser só pela aba "Estoque", com registro.
~~As duas implementações estão na fila.~~ (feitas em 07/10/2026, logo abaixo)

#### O conserto — 07/10/2026

As duas partes, como o dono decidiu.

**1. A venda e o cancelamento da venda, por conta no banco.** `ProdutoRepository::baixarDoEstoque()` e
`devolverAoEstoque()` gravam `quantidade = COALESCE(quantidade, 0) - ?` (e `+ ?`) num `UPDATE` do DQL,
sem trava e sem segurar a linha; `VendaPagamentoService::finalizar` e `VendaService::cancelar` as chamam
no lugar do `setQuantidade(getQuantidade() -/+ vendida)`. Quem estiver com a linha presa pela aba
"Estoque" (`ProdutoService::comOProdutoTravado`) termina primeiro, e a conta da venda vem por cima do
saldo dele. O que a venda sem concorrência deixa é o de antes:

- **O nulo vale zero, como valia.** `null - 1` dá -1 no PHP, e `NULL - 1` dá NULL no banco; o `COALESCE`
  mantém o -1. No `franquiabv_dev`, 2 produtos de estoque têm o saldo nulo (1 `probar`, 1 `proemp`).
- **Continua sem piso** (a decisão escrita no `VendaPagamentoService`).
- **Nada mais se gravava junto, e nada passa a se gravar:** a venda e o cancelamento nunca escreveram em
  `produtos_estoque` nem a `data_alteracao` do produto (o caso confere a contagem de movimentações).
- **A quantidade vendida é inteira:** nenhuma das 261.132 linhas de venda de produto de estoque do
  `franquiabv_dev` é fracionada, e a conta no banco dá o número que a conta no PHP dava.
- **O `flush` por item da baixa saiu:** o `flush` da venda, logo abaixo no mesmo método, grava o resto
  como antes; no cancelamento, os `flush` seguintes e o do fim.
- **A entidade em memória.** O `UPDATE` do DQL não passa por ela: depois dele, a que o pedido tem na mão
  ficaria com o saldo de antes. Ela não regravaria o saldo num `flush` — o Doctrine só grava o campo que
  mudou —, mas quem a lesse no mesmo pedido, ou fizesse a conta velha sobre ela, partiria do número
  errado. O `refresh` logo depois do `UPDATE` a põe igual ao banco (o produto não tem alteração pendente
  ali: a venda e o cancelamento só leem dele o tipo). Nada depois do `UPDATE`, nos dois métodos e nas
  quatro portas que os chamam (`VendasPagamentosController`, `Adm\ComandasController`,
  `VendasController`, `Adm\VendasController`), chama `setQuantidade` no produto, e nenhuma resposta delas
  mostra o saldo.

**2. A quantidade fora da edição do produto.** `quantidade` saiu de `ProdutoService::CAMPOS_DA_EDICAO`:
o POST da edição não grava saldo nenhum, nem o velho nem outro. `Form\Produto::quantidadeSoDeLeitura()`
põe o campo `readonly`, sem o asterisco, com o saldo do banco — também na volta com erro, que antes
redesenharia o número do POST —, e `ProdutosController::editarAction` a chama no GET e na volta. O aviso
em vermelho da tela (*"a atualização da quantidade deve ser feita na aba Estoque"*) passa a ser verdade.
**O cadastro de produto novo continua aceitando a quantidade inicial** (conferido: `insert()` hidrata o
POST inteiro, e o formulário do novo não passa pela função nova). **A API nova e a tela nova já seguiam a
regra, e não mudaram:** o `PUT /adm/api/produtos/:id` recusa `quantidade` com 422 (*"A quantidade muda
pelo estoque, e não aqui."*, `RegraDoCatalogo`, caso no `RegraDoCatalogoTest`), e a edição do catálogo no
`frontend/` não mostra o campo — só a nota (caso no `CatalogoLigado.teste.tsx`); a quantidade só nasce
no cadastro, lá também.

**Provado** por `tests/Caracterizacao/D388SaldoPorContaNoBancoTest.php`, 10 casos verdes nos dois bancos:
a venda que leu 10, com a aba "Estoque" tirando 4 no meio, deixa 5 (e não 9); o cancelamento que leu 8,
com 5 tirados no meio, deixa 5 (e não 10); sem concorrência, dois itens do mesmo produto, outro produto
e um serviço dão o saldo de antes, e o cancelamento o devolve, sem linha em `produtos_estoque`; o nulo dá
-1 e 1; a entidade em memória lê o saldo do banco depois da baixa e da devolução, e um `flush` com ela
mudada em outro campo não regrava o saldo; os dois métodos do repositório, chamados direto, fazem a
conta sobre o saldo do banco, só na linha do produto, e devolvem a entidade relida; pela tela do ERP, despachada em processo, o formulário aberto
com 10 e salvo depois de uma saída de 5 pela aba "Estoque" deixa 5, o POST com 999 não muda nada, o campo
sai `readonly` com o saldo do banco no GET e na volta com erro, e o cadastro do produto novo grava a
quantidade inicial. **A página inteira**, pelo `ViewRenderer` da aplicação ~~(um caso temporário, apagado
depois)~~ (virou caso do arquivo na revisão, abaixo): o `<input name="quantidade">` sai com `readonly` e o
saldo; o do produto novo, sem.

**10 mutantes, um de cada vez, vermelhos nos dois bancos** (os arquivos voltaram, conferidos por sha1
contra a cópia do conserto): a baixa de volta ao ler-subtrair-gravar (a corrida e a entidade); a
devolução de volta (idem); a baixa sem o `COALESCE` e a devolução sem ele (o nulo); sem o `refresh` (a
entidade e o caso do repositório); a baixa sem o `WHERE` do produto (o caso sem concorrência e o do
repositório); `quantidade` de volta em
`CAMPOS_DA_EDICAO` (o formulário velho e o 999); o controller sem chamar a função nova, a função sem o
`readonly` e a função sem o saldo do banco (o caso da tela).

**Os vizinhos** (derivados pelo grep dos testes que chamam o finalizar, o cancelamento, as portas da venda e
do produto -- o D316, o D317 a D320, o D380, o D384, o `EstoqueSobTravaTest` do D126, o
`PagamentoTrocoDebitoTest` da baixa, o D284, o catálogo e as APIs de comanda, venda e produto), com os 9
casos novos de então: 802 execuções numa rodada, verdes nos dois bancos (4.015 e 4.014 asserções, 4
puladas, as que pedem a cópia). O décimo caso, o do repositório chamado direto, entrou depois, pelo
portão da cobertura por par: sem ele, os dois pares novos ficavam "sem menção" (148 para 150). O
arquivo, com os 10, verde nos dois; e os três mutantes do repositório (sem `refresh`, sem `WHERE`, sem
`COALESCE`) rodaram de novo sobre os 10, vermelhos nos dois.

**Os portões:** `conferir-tudo.sh` com o vermelho conhecido do D50 e a cobertura por par, que pediu a
base de 320 para 322 pares (os dois métodos novos, declarados no roteiro; tirando-os da lista, o sha1
volta ao de 320) e a do ISVenda de 109 para 111 chamadas; depois disso, verde.

**O que fica de fora:** a corrida com dois pedidos HTTP ao mesmo tempo não foi medida — o saldo mudado no
banco depois de o pedido ler o produto é o que se mede no lugar; e a página no navegador (a máscara
`numero` do JavaScript sobre o campo só de leitura) não foi aberta. O caixa da venda (`valor_final` sem a
trava) é o D228, e não muda aqui.

#### A revisão do conserto — 07/10/2026

A verificação achou quatro pontos, todos de gravidade baixa, todos confirmados no código; nenhum mudava o
que a venda, o cancelamento ou a edição gravam.

- **A prova da tela não passava pela tela.** O caso da edição montava o campo com o `FormText` à mão, sem
  o `editar.phtml`; e o do produto novo conferia `new Form\Produto()`, sem despachar o `novoAction`. Um
  `<input>` escrito à mão na view, no lugar do `formElement`, deixava o campo editável e os 10 casos
  verdes; chamar a função nova também no `novoAction` deixava o cadastro novo só de leitura, e os 10
  verdes. Agora os dois casos renderizam a página inteira pelo `ViewRenderer` da aplicação, com o que a
  ação entregou: a edição no GET e na volta com erro (o campo `readonly`, o saldo do banco, o rótulo
  "Quantidade" sem asterisco, o aviso em vermelho), e o caso novo
  `testATelaDoProdutoNovoMostraAQuantidadePreenchivel`, o novo no GET e na volta com erro (sem `readonly`,
  o número digitado, o rótulo com o asterisco).
- **O próprio D388 dizia "As duas implementações estão na fila."** sob o título CORRIGIDO: riscado, com a
  data, como nos outros decididos e feitos.
- **O D126 dizia no presente** que a venda, a devolução e a edição apagam a movimentação do meio, e que
  era "duas perguntas ao dono": riscado, com o CORRIGIDO e o que mudou.
- **Dois comentários vizinhos diziam que o `atualizar()` de produto grava o estoque:** o do
  `Adm\ProdutosController::atualizarAction` (a API) e o do `EdicaoDoCatalogoTest` ("fora da tela de
  serviço: é da de produto"). Os dois dizem agora que o saldo não passa por nenhum `atualizar()`.

**Depois da revisão:** o arquivo, 11 casos verdes nos dois bancos (85 asserções em cada). **Sete
mutantes, um de cada vez, vermelhos nos dois bancos** (os arquivos voltaram, conferidos por sha1): o
`novoAction` chamando a função nova no GET e na volta, e só na volta com erro (o caso do novo, cada um na
sua asserção); o `editar.phtml` com o campo escrito à mão (o caso da edição); a função sem tirar o
asterisco (idem); e, de novo sobre a prova nova, os três de antes da tela -- o controller sem chamar a
função, a função sem o `readonly` e sem o saldo do banco. **A lacuna, medida:** o arquivo de antes da
revisão, reconstruído no rascunho, fica verde (10 casos, 64 asserções, no MySQL) contra o novo só de
leitura, contra o campo escrito à mão e contra o asterisco que ficou. Os vizinhos, a mesma lista do
conserto com os 11 casos: 804 execuções numa rodada, verdes nos dois bancos (4.042 e 4.041 asserções, 4
puladas). Os portões: `conferir-tudo.sh` com 55 ok e o vermelho conhecido do D50; `contar-debitos.php`
e `conferir-debitos-repetidos.php` com saída 0. O front não mudou.

### D389 — A descrição da despesa não tinha teto, e a normalização da casa a aumentava: 100 caracteres viravam 120 e a gravação caía com 500 — CORRIGIDO em 04/10/2026

**O débito 4 do desenho de Contas a Pagar** (`docs/f3-telas-restantes/tela-saidas.md`, seção 2.3), que
ganha número no commit que o conserta. `Saida::setDescricao()` passa o texto por
`IdealizeUtil::normalizarNome()`, que põe um espaço depois de cada ponto e iniciais maiúsculas; a
coluna é `varchar(100)` e o MySQL roda estrito. A API e o filtro do ERP não tinham teto -- só o
`maxLength` do campo, que mede o digitado. **Medido no teste:** "NF.12" vinte vezes, 100 caracteres,
grava 120. **E a sangria passa pela mesma porta:** o motivo da retirada com "financeiro" vira a
descrição, e um motivo de 100 com pontos derrubava a retirada inteira -- medido, o mutante que tira o
corte cai no `flush`.

**O conserto:** uma regra só, `ISFinanceiro\Regra\RegraDaSaida`, que mede o texto **como vai ser
gravado**: a API (`conferirSaida`, 422 com o tamanho que ficaria) e o formulário do ERP (`SaidaFilter`,
o mesmo teto) recusam; a sangria (`CaixaService::registrarRetirada`) **corta** o texto já normalizado
em 100, sem recusar a retirada -- a decisão 5 do desenho --, e o motivo da retirada fica inteiro. Duas
coisas medidas no caminho: **o espaço que o ponto final ganha não conta** -- o MySQL e o PostgreSQL
aparam espaço a mais no fim sem erro, e a API grava os 100 de "a…a." (provado nos dois bancos); e a
regra recusa o **emoji**, que a tabela `utf8mb3` do MySQL não guarda (a regra do `RegraDoConvenio`).

**Provado:** `RegraDaSaidaTest` (a regra, o formulário do ERP, a sangria pura e pela porta de verdade --
`CaixaService::retirada` com os dois motivos longos) e `SaidaApiEscritasTest` (a criação e a alteração
pela API), nos dois bancos. Mutantes, cada um vermelho: o teto arrancado; `>` virando `>=`; o teto
medido antes de normalizar; o teto contando o espaço que o banco apara; a sangria sem o corte; a
sangria cortando o motivo em vez do texto normalizado; o formulário do ERP sem a regra; a API sem a
regra; o emoji aceito.

**Fica de fora:** a API do caixa não tem teto para o **motivo** da retirada
(`Controller\Adm\CaixasController`), e o motivo de mais de 100 cai na coluna da retirada -- o D393.

### D390 — A API de Contas a Pagar aceitava tipo desativado e gravava na despesa excluída, e o ERP aceitava qualquer id de tipo — CORRIGIDO em 04/10/2026

**O débito 7 do desenho de Contas a Pagar**, mais o irmão do ERP que a leitura achou.

- **O tipo:** a API procurava o tipo sem olhar a situação, e o formulário do ERP só conferia "não
  vazio" -- o `<select>` oferece os ativos, mas o POST com o id de um tipo desativado gravava, e o de
  um tipo que não existe caía na chave estrangeira. **Medido em processo:** o desativado gravou (302, o redirecionamento
  depois de salvar), e o inexistente saiu com erro. Hoje os 5 tipos da cópia estão ativos (a M14 do
  desenho): latente.
- **A despesa excluída:** `saidaDaRota()` não olha a situação, e o `PUT` gravava nela; lançar parcela
  nela respondia 422 *"Confira valor, vencimento e número"*, quando o motivo era o estado. Nenhuma
  despesa excluída na cópia (a M1 do desenho).

**O conserto:** a API exige tipo cadastrado **e ativo** (422 no campo); o `SaidaFilter` do ERP recebe a
lista que o `<select>` oferece e recusa o resto (*"Escolha um tipo da lista."*);
`SaidaService::updateIdentificacao()` recusa a despesa excluída, sob a trava da despesa, e as duas
portas leem a recusa -- 409 na API, *"Esta saída foi excluída e não pode ser alterada."* no ERP; lançar
parcela na excluída é 409 *"A saída foi excluída, e não recebe parcela."*.

**Provado:** `SaidaApiEscritasTest` (o tipo desativado na criação e na alteração; o `PUT` e o
lançamento na excluída, 409; o controle, 200), `SaidasDoErpTest` (o tipo desativado e o inexistente
recusados na identificação, vermelho antes: 302 e erro; a despesa excluída) e `SaidaFinanceiroTest` (o
serviço), nos dois bancos. Mutantes, cada um vermelho: o tipo desativado aceito na API; o serviço
gravando na excluída (três casos, um por porta); o lançamento na excluída de volta a 422; o filtro do
ERP sem a lista.

**A revisão do item (04/10/2026)** achou o irmão do lançamento no ERP: a tela das parcelas abre a
despesa excluída pela URL, o serviço recusa sob a trava, e a frase era a de validação (*"Confira o
valor, a data..."*), com valor e data certos. Agora é *"Esta saída foi excluída e não recebe
parcela."*; o valor que não serve, na ativa, continua com a frase de antes (`SaidasDoErpTest`, os dois
casos, e o mutante que tira a frase nova).

### D391 — A lista antiga de Contas a Pagar imprimia a descrição crua — CORRIGIDO em 04/10/2026

**O débito 8 do desenho de Contas a Pagar**, a família do D203: `index.phtml` imprimia
`$parcela['descricao']` sem escape nos dois ramos -- com o link (quem escreve) e sem (quem só lê). A
busca por `getDescricao()` do D203 não a achou porque ali a linha é arranjo. A API e a sangria gravam o
`<` que vier; o `StripTags` é só do formulário do ERP. Hoje nenhuma descrição nem motivo tem `<` ou `&`
(a M13 do desenho).

**O conserto:** `escapeHtml` nos dois ramos. **Provado** por `SaidasDoErpTest`, que renderiza a view de
verdade com o que a ação entrega, com `<b>Aluguel</b> & cia` na descrição, nos dois ramos; e pelos dois
mutantes, um por ramo, cada um vermelho.

### D392 — Quatro consultas mortas de Contas a Pagar liam a rede inteira, e uma lia um campo que não existe — CORRIGIDO em 04/10/2026

**O débito 10 do desenho de Contas a Pagar.** `SaidaParcelaRepository::listagemBalancete`,
`selecionarTotalPorAnoMesPrevisto` e `selecionarTotalPorAnoMesRealizado` não filtravam a unidade, e
`SaidaParcelaPagamentoRepository::selecionarPagamentosPorMesAno` usava `sai.local`, campo que a
despesa não tem (a família do D134). Nenhuma tinha chamador -- conferido por busca no repositório
inteiro (módulos, testes, roteiros, telas e o front), sem contar o próprio desenho.

**O conserto:** apagadas, com uma nota no lugar de cada uma. **Provado:** **1.838 execuções (13 puladas) verdes no MySQL e 1.838 no PostgreSQL** (a suíte inteira, contra as fixtures) depois da
remoção, e os portões que leem consultas (`conferir-injecao-dql.php`,
`conferir-booleanos-em-consulta.php`, `conferir-recorte-por-unidade.php`, `conferir-fosseis.php`)
verdes.

### D393 — A retirada do caixa pela API não tem teto para o motivo, e o de mais de 100 caracteres cai na coluna — REGISTRADO em 04/10/2026 — CORRIGIDO em 07/11/2026: o teto de 100 e o caractere de quatro bytes, pela API e pelo ERP

**Achado pelo desenho de Contas a Pagar** (débito 4: *"a API do caixa não tem teto de motivo"*) e
deixado de fora do item FINANCEIRO-S1, que é do financeiro. `Controller\Adm\CaixasController` só
confere que o motivo não é vazio (`trim`), e `caixas_retiradas.motivo` é `varchar(100)`: no MySQL
estrito, a retirada com motivo de 101 caracteres cai no `flush` com 500. **Lido, não exercitado.** A
sangria com "financeiro" já não cai pela descrição da despesa (o D389 corta); cai pela coluna da
própria retirada.

**O conserto** (técnico, sem decisão): o teto de 100 na API do caixa, com 422 no campo, e um caso em
processo, vermelho antes.

#### CORRIGIDO em 07/11/2026 -- o teto e o emoji nas duas portas da retirada (a fatia 110, TEXTO-NAS-REGRAS)

**Reproduzido antes do conserto:** a retirada pela API com motivo de 101 caracteres caiu no MySQL com o erro 1406
(`Data too long for column 'motivo'`) e no PostgreSQL com o "value too long" -- o mutante M18. **A API**
(`Adm\CaixasController::retiradaAction`) confere o motivo aparado, que é o gravado: mais de 100 caracteres (`mb_strlen`)
é 422 com "Até 100 caracteres." no campo; e o emoji, pela mesma coluna `utf8mb3` (o erro 1366, a classe do D421), é
"O motivo não guarda emoji.". **A irmã na tela antiga** (`CaixasController::validarFormRetirada`): o campo dela não
tem `maxLength`, e o serviço grava o motivo como veio -- o de 101 caracteres caía igual. Ganhou *"- Motivo com até 100
caracteres"* e *"- Motivo sem emoji"*; o espaço a mais no fim não conta (o banco o apara). Os casos: o
`TextoNasRegrasTest` (a API, com o controle dos 100 com espaço nas pontas) e o `D208RetiradaNoFinanceiroTest` (a tela
antiga); os mutantes M18 a M21, vermelhos nos dois bancos. **Fica de fora:** o `<` no motivo da retirada (não é uma
das seis do D495), que segue para a descrição da despesa da sangria pelo corte (`descricaoDaSangria`) sem a
conferência do sinal -- está no "Falta" do D495.

**A verificação de 07/11/2026:** a tela antiga media o motivo depois do `rtrim` do PHP, que tira também a tabulação e
a quebra de linha do fim -- e o serviço as grava cruas: cem letras e um `\n` passavam e davam 500 (o 1406 do MySQL, o
"value too long" do PostgreSQL). Agora só o espaço do fim não conta (`rtrim(..., ' ')`, como a `RegraDaSaida`); caso
no `D208RetiradaNoFinanceiroTest` (o `\n` e a tabulação), o mutante N5.

### D394 — A lista antiga de clientes muda o estado dos dois clientes marcadores, que a API nova recusa — REGISTRADO em 04/10/2026 — CORRIGIDO em 07/10/2026

**Achado ao converter o `ativarRemover` de clientes para POST (o D199).** A API nova recusa com 409
(`sentinela`) ativar ou desativar o cliente avulso (id 1) e o "sem cadastro" (id 2) -- são marcadores
do sistema, e não pessoas (o D18); `Adm\ClientesController::mudarEstado` confere os dois ids. A ação do
ERP antigo, `ClientesController::ativarRemoverAction`, não confere: grava o estado pedido em qualquer
id. E a lista antiga só esconde o 2 (`c.id != 2`, em `ClienteRepository::listagemIndex`): o avulso
aparece nela, com o X ou o "Ativar".

**Medido no `franquiabv_dev`, só leitura:** os dois marcadores estão **inativos**, e o avulso tem 2.752
vendas no histórico. Mudar o estado dele pela tela antiga não foi exercitado, e o efeito no PDV não foi
medido.

**O conserto** (técnico, sem decisão: a regra é a da API): a ação antiga recusar os dois ids, com o 404
das outras recusas, e um caso em processo, vermelho antes.

**Feito em 07/10/2026**, junto com a resposta 24 do dono (o D199): `ClientesController::ativarRemoverAction`
recusa os dois ids com o 404 das outras recusas, antes da regra da unidade -- e por isso também para o
administrador --, e a lista antiga não desenha mais o "Remover" nem o "Ativar" para eles (o avulso continua
aparecendo nela, sem o botão). A API segue com o 409 `sentinela`, e a recusa dela vem antes da guarda nova
da unidade. **Provado** nos dois bancos (`DesativarClienteSoDaUnidadeTest`): os dois ids, nos dois
sentidos, pelo administrador, 404 e o estado intacto; na API, o avulso pedido por quem é de outra
franquia continua 409 `sentinela`; e a lista não desenha o botão do avulso nem para o administrador.
Mutantes, vermelhos: a ação sem a recusa, e a lista sem esconder.

**A verificação (07/10/2026) achou a terceira porta**, e o texto acima dizia que nenhuma mudava o estado
do avulso: a edição. `editarAction` só barra o id 2, e `salvarAction` não barra nenhum dos dois antes de
gravar; as duas entregavam o POST cru ao hidratador, e o `status=0` dele desativava o avulso pela página
`editar/1` e os dois pelo modal. Fechada pelo D438 (a edição grava só os campos da tela) e **provada** nos
dois bancos (`D438EdicaoDoClienteSoGravaOsCamposDaTelaTest`): a página `editar/1` e o modal com o id 1 e
com o id 2, nos dois sentidos, e o estado fica. Agora nenhuma porta muda o estado dos dois; o efeito no PDV
de mudá-lo continua não medido. **Fora do D394, que é o estado:** a edição do ERP ainda reescreve o NOME e
o TELEFONE dos dois -- a API recusa com 409 `sentinela` --; registrado no D439.

### D395 — O editar do molde escolhia pelo `id` escondido no corpo qual molde gravar, e não pelo da rota — CORRIGIDO em 04/10/2026

**Achado pelo desenho de Grupos e privilégios** (`docs/f3-telas-restantes/tela-acl.md`, D-ACL-4; lido, e
reconferido no código em 04/10/2026). `PrivilegiosController::editarAction` só conferia que o molde da
rota existia; quem gravava era `PrivilegioService::update($data)`, que pegava o molde pelo `$data['id']`
do campo escondido e hidratava o POST inteiro nele -- grupo e funcionalidade inclusive. É o defeito dos
cupons (D264 a D270): o `id` oculto escolhia QUAL molde gravar. O formulário tem `Csrf`, então era o
próprio administrador quem precisava mexer no campo.

**O conserto:** o controller passa ao serviço o molde que achou pela rota, e `update(Privilegio, array)`
grava só `ler` e `escrever` nele -- o par não muda (o D374). O hidratador saiu do serviço, e com ele a
entrada do `PrivilegioService` em `ALVOS_CONHECIDOS` do `conferir-escritores-do-horario.php`.

**Provado** nos dois bancos (`PrivilegiosDoErpTest`): o POST no molde A com o `id` do molde B, outro
grupo e outra funcionalidade no corpo grava só o nível de A, no mesmo par, e B fica como estava. O
mutante que volta a escolher o molde pelo `id` do corpo: vermelho.

### D396 — Remover um molde de privilégio que não existe caía com 500: o `getReference()` nunca é nulo — CORRIGIDO em 04/10/2026

**Achado pelo desenho de Grupos e privilégios** (D-ACL-5; lido). `PrivilegioService::delete` pegava o
molde por `getReference()` -- que devolve um proxy, nunca nulo -- e logo chamava `getGrupo()`, que manda o
Doctrine buscar o que não existe: o `if ($privilegio)` de baixo era sempre verdadeiro, e o duplo clique
(ou o segundo envio) dava 500 em vez de "já removido".

**O conserto:** `find`, e `false` quando não existe; o `removerAction` responde 404.

**Provado** nos dois bancos: `PrivilegiosDoErpTest` (o segundo POST no mesmo molde dá 404) e
`PrivilegioPropagacaoTest` (o serviço devolve `false`, sem exceção). O mutante que volta ao
`getReference`: erro nos dois casos.

### D397 — O molde do grupo e a propagação às pessoas eram gravados em dois `flush`, sem transação — CORRIGIDO em 04/10/2026

**Achado pelo desenho de Grupos e privilégios** (D-ACL-7; lido). `PrivilegioService::insert` e `update`
gravavam o molde com um `flush` e a propagação com outro, sem transação: uma falha no meio deixava o
molde novo e as pessoas com o velho -- e, como a propagação só toca quem está em dia com o molde de antes,
a edição seguinte já não as alcançava.

**O conserto:** uma porta de escrita só, `PrivilegioService::gravarMolde`, que grava (ou apaga) o molde e
propaga numa transação; as gravações de cada pessoa (`gravarPrivilegio`, com a transação dela) ficam
aninhadas, e a falha de qualquer uma desfaz o molde.

**Provado** nos dois bancos (`PrivilegioPropagacaoTest`, com os pontos de salvamento ligados, como em
`FidelidadeTudoOuNadaTest`): um ouvinte do `onFlush` recusa a gravação da linha da pessoa no meio da
propagação, e o molde volta ao que era, com a pessoa intacta. Mutantes: sem a transação, e sem o
`rollback` -- vermelhos.

### D398 — O "Novo" dos moldes aparecia a quem só lê, e os nomes de grupos e funcionalidades saíam sem escape nas duas telas — CORRIGIDO em 04/10/2026

**Achado pelo desenho de Grupos e privilégios** ("os menores", seção 2.2). O botão "Novo" de
`privilegios/index.phtml` aparecia a quem só tem `configuracoes/acl/ler` e caía num 404; e os nomes do
grupo e da funcionalidade (`privilegios/index.phtml`) e do grupo (`grupos/index.phtml`) iam crus à
página, a família do D203. Hoje só os 10 administradores abrem as duas telas, e os nomes são dado
semeado -- o risco era pequeno, e o conserto, de uma linha.

**O conserto:** o "Novo" só com `$acl['escrever']`, e os três nomes com `escapeHtml` -- em quatro `echo`:
o grupo sai por dois em `privilegios/index.phtml`, o do link de editar (quem escreve) e o do `else` (quem
só lê). A funcionalidade 68,
que nenhuma ação confere (o mesmo item do desenho), fica para o selo "não guarda nada" da tela nova -- não
há o que consertar na tela antiga.

**Provado** nos dois bancos (`PrivilegiosDoErpTest`, as listas renderizadas com um nome hostil trocado no
banco): nenhum nome cru, cada um escapado uma vez na tabela; quem só lê não vê o "Novo" nem o X. Mutantes:
o "Novo" sem a guarda e cada um dos três escapes arrancado -- vermelhos. **A revisão (04/10/2026)** achou
o quarto `echo`, o grupo no ramo de quem só lê, sem caso: trocá-lo de volta por `echo $privilegio['grupo']`
deixava a suíte verde. O caso novo renderiza a lista de quem só lê com o nome hostil; o mutante: vermelho.

### D399 — A adesão contava o voto a cada reenvio da etapa 5, em opção excluída ou de outra pergunta, dava 500 no id que não existe, e perdia o voto de quem gravava no meio — PARCIAL em 04/10/2026: a adesão; o link público espera a P5 — CORRIGIDO em 12/10/2026

**Achado pelo desenho de Pesquisas** (`docs/f3-telas-restantes/tela-pesquisas.md`, DP-8, "média"; a perda do
voto é achado novo; lido, e reconferido no código em 04/10/2026). A etapa 5 da adesão soma 1 no contador
da opção escolhida, e:

- `salvarDadosAction` não confere se o cliente já aderiu (as etapas seguintes passam por ele), e o voto
  somava a cada reenvio da etapa 5 -- 25.471 votos para 25.452 etapas 5 na cópia (consultas C3 e C7);
- a resposta só precisava vir preenchida, e o serviço só conferia a pesquisa: contava voto em opção
  excluída e em opção de outra pergunta; o id que não existe dava 500 (`getReference`, e depois
  `getPergunta()` no que não existe);
- o voto era ler, somar e gravar (`setTotal(getTotal() + 1)`), sem trava: duas adesões no mesmo instante
  gravavam o mesmo total, e a entidade que já estava na memória gravava o número de antes.

**O conserto** (a fatia A, sem decisão): o voto só conta no pedido que faz o cliente aderir -- a linha do
cliente relida com `PESSIMISTIC_WRITE` antes da pergunta "já aderiu?" (`FidelidadeService::aderirComOVoto`);
a resposta tem de ser uma das opções ativas da pergunta, na validação; e o voto é um `UPDATE ... total =
total + 1` da opção ativa da pergunta (`PesquisaRespostaRepository::contarVoto`), que devolve 0 no que não
serve.

**Provado** nos dois bancos (`AdesaoComPesquisaTest`): o reenvio de quem já aderiu não conta; a opção
excluída, a de outra pergunta e a que não existe pedem a resposta de novo, sem 500, e o serviço chamado
direto não as conta; com a opção na memória do Doctrine com 5 votos e mais 3 somados no banco por fora,
a adesão grava 9. Mutantes: quem já aderiu votando, a validação aceitando qualquer resposta, o `UPDATE`
sem a situação e sem a pergunta, e o voto de volta ao `getReference` com o ler-somar-gravar -- vermelhos.

**A trava do cliente, provada na revisão do mesmo dia.** A primeira versão deste registro dizia que provar
a trava "pediria dois pedidos ao mesmo tempo, em duas conexões", e o mutante sem ela sobreviveu; o
repositório já tinha o instrumento, em sete classes (`CaixaSobTravaTest`, `DevedorSobTravaTest`, ...).
Agora, em `AdesaoComPesquisaTest`: a primeira leitura da adesão é o `FOR UPDATE` da linha do cliente; com
OUTRA CONEXÃO prendendo a linha de um cliente da fixture e a espera em 1 s, a adesão desiste com o erro
de trava do `FOR UPDATE`; e, FORA DA TRANSAÇÃO DO TESTE, a adesão abre a sua, grava (a outra conexão vê) e
solta a trava -- dentro da do teste, arrancar o `beginTransaction` deixaria tudo verde, e na produção o
`refresh` com trava fora de transação lança `TransactionRequiredException`, 500 em toda etapa 5. A trava
e a transação moram num ajudante só (`FidelidadeService::comOClienteTravado`), que a pontuação da etapa
também usa (o D406). Mutantes: o ajudante sem a trava, nos dois bancos; a adesão fora do ajudante; o
ajudante sem a transação, nos dois bancos -- vermelhos. A corrida com dois pedidos HTTP de verdade
continua não medida: a trava presa é o que se mede no lugar dela.

**Resta:** o link público avulso (`/pesquisadesatisfacao`, `PesquisasController::responderAction` e
`PesquisaService::responder`), que continua como era -- o "enquanto isso" da P5, que não muda nada:

- aceita POST repetido na mesma sessão, e soma pelo ler-somar-gravar;
- o voto continua com o `find` e só confere a pesquisa: o id de uma opção EXCLUÍDA, ou de OUTRA pergunta da
  mesma pesquisa, ainda soma -- `contarVoto` só é chamado pela adesão;
- exige `count($perguntas) == count($data)`, com todas as perguntas ativas (`selecionarPerguntas`): uma
  pergunta ativa sem opção prende o link para sempre -- o D339, nessa porta.

Fechá-lo, mantê-lo ou trazê-lo para a tela nova é a P5 do desenho, que espera o dono.

#### 12/10/2026 — O LINK PÚBLICO AVULSO FECHOU — pela resposta 107 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 107** (a opção "a"): *"Fechar o link público avulso da pesquisa da adesão -- a página deixa de existir, e
o 'copiar link' sai da lista. A etapa 5 da adesão continua."*

##### O conserto

Saíram as duas rotas (`isconfiguracao-pesquisa-satisfacao` e `-agradecimento`, de `module.config.php` e de
`rotas-publicas.php`), as duas ações (`PesquisasController::responderAction` e `agradecimentoAction`), as duas telas
(`pesquisas/responder.phtml` e `agradecimento.phtml`), o voto delas (`PesquisaService::responder`, o ler-somar-gravar),
a busca pelo hash (`PesquisaRepository::selecionarPorIdHash`, sem outro chamador), o "copiar link" e o modal dele da
lista (`pesquisas/index.phtml`) e as três funções de JavaScript (`pesquisaModalLink` e `pesquisaModalLinkCopiar` em
`mod-configuracoes.js` e `.min.js`, `responderPesquisa` em `fidelidade_cadastro.js` e `.min.js`). `/pesquisadesatisfacao`,
com ou sem o id e o hash, é o 404 de qualquer endereço que não existe. A etapa 5 da adesão continua, e é a única porta
de voto (`PesquisaRespostaRepository::contarVoto`). A coluna `pesquisas.hash` continua gerada, e ninguém mais a lê.

##### Provado

- `TelasDasPesquisasDoErpTest`, nos dois bancos: os dois casos das páginas que saíram viraram a prova de que elas não
  existem -- o roteador da aplicação não casa `/pesquisadesatisfacao` (com o id e o hash da pesquisa do caso, e sem) nem
  o agradecimento, e casa a adesão (o controle) --, e a lista não tem mais o link, o botão nem o modal (com o controle de
  que ela tem linha).
- Por HTTP, nos dois bancos de contrato, o `ensaiar-rotas-publicas-do-erp.php`: o caso 10 passou a levar a capacidade
  (uma pesquisa ativa inventada, com o hash de 30 caracteres, tirada no fim) e é 404; o 11 também. 23 de 23.
- Mutantes, no MySQL, cada um vermelho: a rota de volta (o caso da rota) e o "copiar link" de volta na lista (o da
  lista). Os portões `conferir-rotas-sem-login.php`, `conferir-guarda-do-erp.php` (PUBLICA de 14 para 12),
  `conferir-scripts-servidos.php` e `conferir-cobertura-por-par.php` (o par que saiu) ficaram em dia.

##### A verificação do mesmo dia: o que ainda falava da página

- **A frase do título com `<` ou `>`** dizia ao operador *"o título aparece na página pública da pesquisa"*
  (`RegraDaPesquisa::FRASE_DO_SINAL_DE_TAG_NO_TITULO`, que o `PesquisaFilter` mostra) -- a página que saiu; e a etapa 5
  da adesão imprime a pergunta e as opções, e não o título. A recusa fica (a regra de `texto()`, o D250); a frase passou
  a dizer a regra: *"Sem < nem >, como na pergunta e nas opções da pesquisa."* O
  `PesquisasPelosControllersTest::testOTituloComSinalDeTagDizPorQue` confere que ela não cita página pública nenhuma,
  nos dois bancos; com a frase de antes, vermelho (o mutante, nos dois).
- **O desenho da tela** (`docs/f3-telas-restantes/tela-pesquisas.md`) ainda dava a P5 como aberta e o "copiar link" na
  tela antiga: as passagens ganharam a resposta, riscadas ou com a nota de 12/10/2026.


### D400 — Pôr uma pesquisa na adesão tirava a marca de todas antes de gravar, fora de transação, e carimbava a data de todas — CORRIGIDO em 04/10/2026

**Achado pelo desenho de Pesquisas** (DP-4, "média"; lido). `removerPesquisasPlanoFidelidade` era um `UPDATE`
sem `WHERE`, executado na hora e com o `autocommit` ligado, antes do `flush` da pesquisa: com 101
caracteres no título ou um emoji -- o `maxLength` era só do HTML --, o `flush` dava 500 DEPOIS de a marca
ter saído de todas, e a adesão ficava sem pergunta (o D338 por outro caminho); e a data de alteração de
todas as pesquisas era carimbada -- no PostgreSQL, a 1 e a 2 têm a mesma (consulta C5).

**O conserto:** `PesquisaService::marcarParaAdesao`, numa transação: a pesquisa relida com trava, as
marcadas lidas com trava (`PesquisaRepository::travarAsMarcadas`, no lugar do `UPDATE` sem `WHERE`), e
só elas saem e são carimbadas, com a que entra, num `flush` só; o carimbo continua vindo do PHP (o D42).
E o título com o teto da coluna e sem emoji no filtro do formulário (`PesquisaFilter`).

**Provado** nos dois bancos (`PesquisasPelosControllersTest`): a troca deixa uma marcada só, carimba a que
entra e a que sai, e não as outras; o título de 101 caracteres e o com emoji são recusados no campo, com
a marca onde estava. Mutantes: as marcadas sem o `WHERE` (todas carimbadas), o título sem teto e com
emoji -- vermelhos.

**As travas e a transação, provadas na revisão do mesmo dia** (a primeira versão dizia "pediria duas
conexões", e os mutantes delas nem estavam na lista): em `PesquisasPelosControllersTest`, a primeira
leitura da troca é o `FOR UPDATE` da pesquisa que entra, e as marcadas são lidas com `FOR UPDATE`; e, FORA
DA TRANSAÇÃO DO TESTE, a troca abre a sua, grava (a outra conexão vê uma marcada só) e solta as duas
travas -- dentro da do teste, o `flush` único já é atômico, e arrancar o `beginTransaction` deixaria tudo
verde. Mutantes: a pesquisa relida sem trava, as marcadas sem trava (nos dois bancos) e a troca sem a
transação -- vermelhos. A unicidade da marca, no banco, segue no D29.

**24/10/2026 (a revisão da fatia 35):** a trava da pesquisa que entra e a das marcadas não serializavam duas
trocas em pesquisas DIFERENTES -- no PostgreSQL ficavam as duas marcadas, e no MySQL uma travava na outra.
Ver o D598: a troca agora trava todas as pesquisas em ordem de id antes de ler.

### D401 — A pergunta e a opção da tela antiga de pesquisas iam cruas ao banco: 151 caracteres, emoji ou ordem fora da lista davam 500, e a mensagem do driver, com o SQL, ia à tela — CORRIGIDO em 04/10/2026

**Achado pelo desenho de Pesquisas** (DP-10, "baixa"; lido). `perguntaSalvarAction` não tinha `try`: a pergunta
de 151 caracteres, o emoji (as tabelas são `utf8mb3`) e a ordem fora dos `<select>` davam 500.
`respostaSalvarAction` devolvia `$e->getMessage()`, e no DBAL a mensagem do driver traz o SQL e os
parâmetros; a opção nova de uma pergunta que não existe caía na chave estrangeira.

**O conserto:** a `RegraDaPesquisa` antes de gravar -- a pergunta de 1 a 150 caracteres e a opção de 1 a
100, contados depois do `trim`, sem emoji, sem `<` nem `>` e sem quebra de linha; a ordem de 1 a 15 e de
1 a 10 --, com a frase do campo na resposta, que a tela antiga já mostra; a opção nova confere que a
pergunta existe; e o `catch` devolve uma frase fixa.

**Provado** nos dois bancos (`PesquisasPelosControllersTest`, `RegraDaPesquisaTest`): cada texto e ordem que
não serve volta com a frase, sem gravar; a pergunta que serve grava aparada; a opção de uma pergunta que
não existe diz que não a achou. Mutantes: a pergunta e a opção sem a regra, e a opção nova sem conferir
a pergunta -- vermelhos.

### D402 — A ordem das perguntas e das opções da pesquisa empatava, e a lista dizia "N produtos localizados" — CORRIGIDO em 04/10/2026

**Achado pelo desenho de Pesquisas** (DP-12, "cosmético"). As três opções de hoje têm ordem 1, e as
consultas ordenavam só por ela (`Pesquisa::getPerguntas`, `PesquisaPergunta::getRespostas`,
`PesquisaPerguntaRepository::selecionarPerguntas`): os dois bancos devolvem 1, 2, 3 por acaso (C12). E a
contagem da lista dizia "produtos".

**O conserto:** ordem e id nas três -- a ordem que se vê hoje não muda --; e "pesquisas localizadas"
(`IdealizeUtil::itensLocalizados` ganhou a palavra).

**Provado:** a lista renderizada diz "pesquisa", e o mutante de volta a "produto" fica vermelho
(`TelasDasPesquisasDoErpTest`). **A ordem não tem caso que falhe antes:** os dois bancos já devolvem na
ordem do id, e o `conferir-ordenacao-empates.php` não lê o `Criteria` das entidades -- passou antes e
depois.

### D403 — O editar dos avisos gravava o aviso do `id` escondido no corpo, e o dia inválido, o título longo e o emoji davam 500 — CORRIGIDO em 04/10/2026

**Achado pelo desenho de Mensagens** (`docs/f3-telas-restantes/tela-mensagens.md`, M4 e M7; lido). O serviço
usava o `$data['id']` do POST -- o editar do aviso 10 gravava o 11 --; o `createFromFormat` devolvia
`false` no dia fora do formato, e o tipo `date` do Doctrine dava 500 ao gravar (e "31/02" virava "03/03",
calado); o título acima de 100 caracteres e o emoji (`utf8mb3`) davam 500.

**O conserto:** o id da rota, somado pelo controller ao que a regra devolve; o filtro com o teto da coluna
no título, 500 caracteres no texto (o maior dos 74 avisos da cópia tem 235; a decisão 6 do desenho),
sem emoji, e a data que existe (`RegraDaMensagem::diaDoErp`). O dia vazio continua aceito, e o aviso sem
dia continua aparecendo todo dia: é a pergunta 4 do desenho, que espera o dono.

**Provado** nos dois bancos (`MensagensPeloControllerTest`): o `id` de outro aviso no corpo não muda o
outro; o título de 101, o texto de 501, o emoji no título e no texto, o "31/02/2027" e "amanha"
recusados no campo, sem gravar, no cadastro e na edição; o dia vazio grava. Mutantes: o id do corpo, e
cada validador arrancado -- vermelhos.

### D404 — Quem tivesse o privilégio de Mensagens mudava os avisos do mural da rede inteira, e não só o administrador — CORRIGIDO em 04/10/2026

**Achado pelo desenho de Mensagens** (M8; a pergunta 5, "quem escreve aviso?", com a recomendação "só o
administrador", que "não trava nada"). O aviso não tem unidade dona, e o "para todos" aparece no topo da
agenda de cerca de 364 pessoas; o cadastro, o editar e o ativar/desativar só pediam `escrever`. Medido no
desenho: a funcionalidade 57 tem 0 linhas de privilégio -- só os 10 administradores escrevem.

**O conserto:** as três ações recusam quem não é administrador, com a frase "Só o administrador altera
aviso: ele aparece na agenda de outras franquias." -- o molde do D255 dos convênios --, e entram três
linhas no `conferir-recorte-por-unidade.php`. Não tira nada de ninguém.

**E a lista de quem só lê** (a revisão do mesmo dia; a primeira versão deixava o recorte para a tela nova,
mas ele é do M8, dentro da F0): quem não é administrador vê só o aviso "para todas" e o que tem a unidade
da sessão entre os alvos -- a decisão 2 do desenho; hoje ninguém além dos administradores tem `ler`. A
guarda entra como a quarta linha dos avisos no `conferir-recorte-por-unidade.php`.

**Provado** nos dois bancos (`MensagensPeloControllerTest`): quem não é administrador, com `escrever`, não
cadastra, não altera, não desativa e nem abre os formulários -- 302 com a frase; o administrador faz os
três; quem só lê vê o aviso da sua unidade e o "para todas", e não o de outra unidade, e o administrador
vê os três. Mutantes: cada uma das três guardas arrancada, e o recorte da lista arrancado no controller
(nos dois bancos) e no repositório -- vermelhos.

### D405 — A página pública da adesão imprimia o primeiro nome do cliente sem escape, e o nome do amigo indicado chega a ela pelo WhatsApp da unidade — CORRIGIDO em 04/10/2026

**Achado pela revisão do item MARKETING-CONSERTOS** (04/10/2026; lido no código, e medido no contêiner com a
entidade em memória, sem banco). O topo da página da adesão (`fidelidade/cadastro.phtml`) dizia
`Olá <?php echo $cliente->getPrimeiroNome(); ?>`, cru -- o D337 escapou a pergunta e as opções da mesma
página, e não o nome. O nome é texto digitado: na etapa 1, pela própria pessoa (a primeira palavra); e na
etapa 6, por quem indica o amigo -- `FidelidadeService::salvarDados` cria o cliente do amigo com o
`amigo_nome`, que a validação não confere, e `setNome` só tira as aspas e normaliza a caixa
(`<svg/onload=...>` sobrevive como `<Svg/Onload=...>`). Se a unidade tem o WhatsApp ligado, o próprio
sistema manda ao amigo o link `/fidelidade?id=...&hash=...`, e a página que ele abre recolhe nascimento,
e-mail e endereço. Hoje, 0 nomes com `<` ou `>` no `franquiabv_dev`: latente.

**O conserto:** `escapeHtml` no primeiro nome. O nome que começa por espaço (ou vazio) faz
`getPrimeiroNome()` devolver uma lista, e o `echo` dela imprimia "Array": sai vazio.

**Provado** nos dois bancos (`TelasDasPesquisasDoErpTest`): o cliente do caso nasce com uma marca no nome, e
a adesão renderizada mostra "Olá &lt;u&gt;nome&lt;/u&gt;," e não a marca. Mutante: o nome de volta ao
`echo` cru -- vermelho.

### D406 — Dois envios da mesma etapa da adesão ao mesmo tempo gravavam dois históricos de 10 pontos: a pergunta "já pontuou?" ia sem trava — CORRIGIDO em 04/10/2026

**Achado pela revisão do item MARKETING-CONSERTOS** (04/10/2026; lido, não executado). Fora do DP-8: o voto
da etapa 5 passou a contar uma vez sob a trava do cliente (o D399), mas a trava soltava ANTES de o
controller chamar `FidelidadeService::salvarPontuacaoCadastro`. Ela procura o histórico "Completou a etapa
N do cadastro de adesão." e, se não acha, insere 10 pontos -- sem trava --, e soma o crédito lendo,
somando e gravando. Dois envios da mesma etapa (o duplo clique) liam os dois "ainda não": dois históricos,
ou um crédito por cima do outro. A tabela do D72 (consulta de 149.109 combinações repetidas) já tinha **2**
pares repetidos nessa família, valendo 20 pontos: a corrida acontece.

**O conserto:** a pontuação roda sob a mesma trava do cliente, numa transação, pelo mesmo ajudante da
adesão (`FidelidadeService::comOClienteTravado`): a linha do cliente é relida com `PESSIMISTIC_WRITE` antes
da pergunta. Os pontos do INDICADOR (a etapa 3 de quem foi indicado dá a etapa 6 a quem indicou) saem na
transação dele, depois: com a trava dele pedida dentro desta, no MySQL (REPEATABLE READ) a pergunta dele
leria a foto de antes da trava. A ordem antiga -- os pontos do indicador antes de a origem do indicado
virar "Indicação" -- muda; a falha no meio continua deixando o indicador sem os pontos, como antes.

**Provado** nos dois bancos (`AdesaoComPesquisaTest`): a primeira leitura da pontuação é o `FOR UPDATE` do
cliente, e a pergunta "já pontuou?" vem depois; a mesma etapa não pontua de novo; com outra conexão
prendendo o cliente, a pontuação desiste com o erro de trava do `FOR UPDATE`; e fora da transação do
teste ela grava (a outra conexão vê o histórico) e solta a trava. Mutantes: a pontuação fora do ajudante,
e o ajudante sem a trava e sem a transação -- vermelhos. **Não coberto:** o crédito continua sem a trava
nos outros escritores (o PDV, a comissão da indicação, o cancelamento) -- o D383.

### D407 — A capa dos assistentes não conferia o token, conferia o tipo pelo nome do arquivo e dizia "Salvo" quando falhava — CORRIGIDO em 04/10/2026

Achado pelo desenho dos Assistentes (`docs/f3-telas-restantes/tela-assistentes.md`, Dη; lido no código).

- `AssistentesAdminController::capaAction` não lia o campo `security`, embora o formulário o tivesse e o
  JavaScript da tela o mandasse (`public/layout/scripts/mod-configuracoes.js`, o `ajaxForm` envia o
  formulário inteiro). O cookie `SameSite=Lax` já barrava o POST vindo de outro site; faltava a guarda
  que as outras telas têm.
- O tipo era a extensão do nome: `.jpeg` era recusado, e qualquer arquivo chamado `.jpg` passava.
- `AssistenteAdminService::capa` devolvia `true` mesmo quando o `move_uploaded_file` falhava, e a tela
  dizia "Salvo com sucesso.".

**O conserto:** a ação confere o token (o grupo de validação só com `security`) antes de olhar o arquivo, e
responde "O formulário expirou" sem ele; o tipo é o que `getimagesize` lê nos bytes -- JPEG, com qualquer
nome --; e o serviço devolve `false` quando não guardou, com a frase "Não foi possível guardar a capa".

**Provado** nos dois bancos (`AssistentesPelosControllersTest`): sem o token, nada; um PNG e dois textos
chamados `.jpg` e `.JPG`, recusados pelo conteúdo; um JPEG chamado `.jpeg` passa a conferência e chega ao
serviço, onde o `move_uploaded_file` falha (o arquivo não veio de um envio de verdade) -- e a resposta diz
que falhou, sem "Salvo". Mutantes: sem o `isValid`, o tipo pelo nome e o `true` na falha -- vermelhos.
*Revisão (04/10/2026):* o envio de verdade foi medido por HTTP, contra o banco de contrato (o ensaio do
D409): sem o token, "expirou"; com ele, o JPEG chamado `.jpeg` é guardado, em 800x800.

### D408 — O editar dos assistentes gravava o assistente do `id` escondido no corpo, e o `alterStatus` dizia `true` para o id que não existe — CORRIGIDO em 04/10/2026

Achado pelo desenho dos Assistentes (Dθ; lido no código). `AssistenteAdminService::update` usava o `id` do
POST: o editar do assistente 1 com `id=2` no corpo regravava o 2. E o `alterStatus` lia por
`getReference`, que nunca devolve nulo: o `if` sempre passava.

**O conserto:** o editar soma o id da rota ao que a regra devolve (`RegraDoAssistente::doFormularioDoErp`
não lê `id`), e o serviço procura a linha pela rota, com trava; o `alterStatus` usa `find`, devolve
`false` para o que não existe e grava o estado como 1 ou 0 -- a coluna é `integer`.

**Provado** nos dois bancos: o editar com o `id` de outro no corpo muda só o da rota
(`AssistentesPelosControllersTest`); o `alterStatus` do id que não existe é `false`, e o do que existe grava
0 e 1 (`AssistenteVinculosTest`). Mutantes: o id do corpo e o `getReference` de volta -- vermelhos.

### D409 — Desde a migração dos envios, a capa dos assistentes e a capa e o certificado da VIP Class não eram recortados: guardava-se o arquivo cru — CORRIGIDO em 04/10/2026

**Achado na F1 dos Assistentes, e medido no contêiner.** Em 19/08/2026 (`55475261`, os envios passaram ao
`Armazenamento`), os três envios de imagem passaram a ser processados num `tempnam()` -- e o `tempnam()`
não tem extensão. O `Canvas::grava` escolhe o formato pela extensão do destino; sem ela, avisa
*Undefined array key "extension"*, devolve `false` e não grava nada. O que se guardava era o envio cru:
uma imagem de 300x200 entrava e saía com 300x200, e não com os 800x800 da capa. No certificado da VIP
Class o que pesa é a proporção, e não o tamanho: o PDF estica a imagem para a A4 deitada
(`PdfVipClassCertificado::gerarPDF`, `Image(..., 0, 0, 842, 595)`) e escreve o nome por cima, em pontos
da página. 2000x1414 é essa proporção; o envio cru de outra proporção saía esticado, e com o recorte ganha
a margem branca do preenchimento. *(Revisão, 04/10/2026: a primeira versão dizia que o nome era impresso
"nas coordenadas" de uma imagem de 2000x1414 -- o tamanho em pixels não importa.)* O código da
produção é o de antes desta árvore (a cópia de 28/07/2026), então o defeito deve ser só deste repositório
-- e iria junto no corte. Não conferido na produção.

**O conserto:** `ISBase\Util\Imagem::recortarEmJpeg`, que grava o recorte num irmão com `.jpg` e o devolve
ao lugar do arquivo, e diz se o arquivo ficou com o tamanho pedido. Os três lugares que o `grep` acha
(`AssistenteAdminService::capa`, `VipClassService::capa` e `::certificado`) passaram a chamá-lo; a capa
dos assistentes devolve `false` quando o recorte falha (o D407), e as duas da VIP Class mantêm o retorno
de antes.

**Provado** (`RecorteDaImagemTest`, só disco e GD): o JPEG de 300x200 sai com 800x800 e com 2000x1414, e o
PNG sai JPEG. Mutante: o `Canvas` gravando no `tempnam()` sem extensão -- vermelho.

*Revisão (04/10/2026): a chamada nos três serviços.* A primeira entrega provou só o ajudante: arrancar a
chamada de qualquer um dos três serviços deixava a suíte verde, porque o único caso que chegava a um deles
parava no `move_uploaded_file`. O envio passou a morar num passo só (`receberEnvio`, nos dois serviços), e
o `RecorteNosServicosTest` troca só esse passo por uma cópia: a capa do assistente e a do curso saem em
800x800 e o certificado em 2000x1414, guardados num `Armazenamento` de disco temporário, com a coluna
apontando para o arquivo novo. Mutantes, vermelhos nos dois bancos: sem o recorte na capa do assistente,
na capa do curso e no certificado, e o certificado no tamanho da capa. **E por HTTP**, pelo aplicativo de
verdade (nginx e php-fpm, o `move_uploaded_file` de verdade), contra o banco de contrato e sem pedir o
abrir: um JPEG de 300x200 enviado em multipart sai guardado em 800x800 (a capa do assistente e a do
curso) e em 2000x1414 (o certificado); com o recorte arrancado da capa do assistente e do certificado, o
mesmo ensaio acusa os dois 300x200. Na VIP Class, o `false` do recorte continua ignorado (guarda-se o
envio cru, em silêncio), e a tela segue conferindo o tipo pelo nome e dizendo "Salvo" na falha: é o
DV-9 do desenho da VIP Class (`tela-vipclass.md`), fora deste item.

### D410 — O estado dos assistentes tinha duas portas: o campo "Status" do formulário ia cru ao serviço, e a lista só sabia desativar — CORRIGIDO em 04/10/2026

Achado pelo desenho dos Assistentes (Dκ; lido no código). O editar tinha um `<select>` "Status" que ia
cru ao `update` -- a mesma porta do D294 e do D259 dos convênios: salvar o nome desfazia a desativação
feita na lista. O cadastro ignorava a escolha e forçava 1. E a lista só oferecia "remover" ao ativo:
reativar só se fazia por aquele campo.

**O conserto,** o molde do D259: o campo saiu do formulário e das duas telas; o cadastro nasce ativo e a
edição não toca no estado; e a lista ganhou o "Ativar" do inativo, um formulário POST com `estado=ativo`
para o `ativarRemover` que o D199 já tinha posto em POST.

**Provado** nos dois bancos: editar o inativo com `status=1` no corpo o deixa inativo, e o ativo com
`status=0` o deixa ativo (`AssistentesPelosControllersTest`); o serviço ignora `status` nos dados
(`AssistenteVinculosTest`); o editar e o novo renderizados sem o campo, e o inativo da lista com o
formulário de ativar (`TelasDosAssistentesDoErpTest`); e o `D199AtivarRemoverPorPostTest` passou a tratar
os assistentes como tela que ativa (o "Ativar" é formulário, o POST com `estado=ativo` ativa). Mutantes:
o serviço gravando o `status` dos dados, o "Ativar" arrancado da lista, o `ativarRemover` aceitando GET e
alternando -- vermelhos.

### D411 — O nome do assistente não tinha teto no servidor: 101 caracteres ou um emoji terminavam em erro do banco — CORRIGIDO em 04/10/2026

Achado pelo desenho dos Assistentes (Dλ; lido no código). O limite de 100 era só o `maxLength` do campo,
e a tabela é `utf8mb3` com `nome varchar(100)`: o POST direto com 101 caracteres, ou o emoji, dava erro do
banco, como no D250 e no D257. A descrição (`text`) não tinha teto nenhum.

**O conserto:** o filtro confere o nome de 1 a 100 caracteres e a descrição até 2.000 (a maior das 4 de
hoje tem 184), e recusa o caractere de 4 bytes nos dois, com a frase no campo
(`RegraDoAssistente::MAIOR_NOME`, `MAIOR_DESCRICAO` e `CABE_NA_COLUNA`).

**Provado** nos dois bancos (`AssistentesPelosControllersTest`): o nome vazio, o de 101, o emoji no nome, a
descrição de 2001 e o emoji nela -- recusados no campo, no cadastro e na edição, e nada grava. Mutantes:
sem o teto do nome, sem o da descrição e com o emoji aceito -- vermelhos.

### D412 — As telas dos assistentes tinham os textos da VIP Class, de onde foram copiadas — CORRIGIDO em 04/10/2026

Achado pelo desenho dos Assistentes (Dμ). O caminho de volta do abrir levava à VIP Class (`vipClassEdu`),
o filtro da lista se chamava "Nome do curso", e a lista dizia "Nenhuma assistente localizada".

**O conserto:** o caminho de volta é a lista dos assistentes; o rótulo é "Nome"; e a frase vai no
masculino, como no menu ("Nenhum assistente localizado", "Um assistente localizado", "N assistentes
localizados").

**Provado** nos dois bancos (`TelasDosAssistentesDoErpTest`, as telas renderizadas). Mutantes: cada um dos
três textos de volta -- vermelhos.

*Revisão (04/10/2026): o resto da tela.* A frase da lista passou ao masculino, e a mesma tela seguia no
feminino da VIP Class: o botão "Nova", a situação "Ativa"/"Inativa", o filtro "Ativas"/"Inativas", a
trilha "Nova" do novo e o aviso "Ativada/Removida com sucesso." do `ativarRemover`. Agora "Novo",
"Ativo"/"Inativo", "Ativos"/"Inativos" e "Ativado/Removido com sucesso." Provado nos dois bancos
(`TelasDosAssistentesDoErpTest::testATelaFalaDoAssistenteNoMasculino`,
`AssistentesPelosControllersTest::testOAvisoDoEstadoENoMasculino`) e pelo aplicativo de verdade (o ensaio
HTTP do D409). Mutantes: cada um dos cinco de volta -- vermelhos.

### D413 — A lista da administração dos assistentes mandava o filtro da URL cru à consulta: no PostgreSQL, uma letra dava 500 — CORRIGIDO em 04/10/2026

**Achado na F1 dos Assistentes, e medido** (um caso descartável, em processo, no PostgreSQL das fixtures):
`?status=x`, `?grupo=abc` e `?unidade=abc` derrubavam a lista (*Error producing an iterator*, a coluna
inteira contra texto). É a família do D361 -- que juntou oito listas de oito desenhos, e esta não estava
entre elas -- e do D279. O link da paginação também concatenava os filtros sem codificar.

**O conserto,** o molde da linha de Mensagens do D361: a situação é "0" ou "1", o grupo e a unidade são os
da lista do próprio formulário (`RegraDoAssistente::idsOferecidos`); o que não serve vale "sem filtro", e
nunca 500. O link sai dos filtros conferidos, por `http_build_query`. *(Revisão, 04/10/2026:)* a página
NÃO era parte do defeito: o `Laminas\Paginator` já faz `(int)` e leva o 0 a 1
(`setCurrentPageNumber`), e a `pagina` nunca foi à consulta. O `max(1, (int) ...)` ficou, só deixa o tipo
escrito; o caso "a página que não é número" saiu, porque passava com ou sem o conserto.

**Provado** nos dois bancos (`AssistentesPelosControllersTest`): os quatro filtros que não servem são
ignorados -- a lista volta como sem eles, e nada vai ao link --; os que servem continuam filtrando; e a
busca com `&` sai codificada. Mutantes: a situação, o grupo e a unidade crus (no PostgreSQL, o erro deste
débito, medido) e o link sem codificar -- vermelhos.

### D414 — A cota-parte extra aceitava pacote, o percentual cru e um segundo cadastro do mesmo par — CORRIGIDO em 04/10/2026

Achado pelo desenho do resto de Usuários (`docs/f3-telas-restantes/tela-usuarios-resto.md`, o D-G 3 e 4;
o 1 e o 2 -- a agenda e a Cota-parte recalculada sem a extra -- são a pergunta 5 do desenho, e ficam).

- **O tipo:** a janela "Cota-parte" do usuário oferecia produto de qualquer tipo (`ProdutoRepository::popularCombobox`,
  a lista dos cupons), pacote inclusive, e o cálculo da cota-parte (`VendaProdutoService`) só lê a extra
  de serviço (`ser`, `lavavip`) e de produto (`probar`, `proemp`): a extra num pacote era gravada e nunca
  valia -- e o pacote não entra na cota-parte por decisão do dono (o D310, a resposta 16).
- **O percentual** só era conferido como não vazio: "-5", "101" ou texto iam ao banco.
- **O par:** um segundo cadastro do mesmo (pessoa, produto) criava outra linha, e o cálculo a lê com
  `setMaxResults(1)` e sem ordem (`UsuarioComissaoExtraRepository::selecionarPorUsuarioProduto`).
- **Medido na cópia** (consulta C6 do desenho, refeita): 35 linhas; 12 de pacote, todas de inativos; 11 de
  serviço (4 de ativos), 12 de bar (8 de ativos); nenhuma fora de 1 a 100, nenhum par repetido, nenhuma de
  outra unidade.

**O conserto** (sem decisão): a `RegraDaCotaParte` (`module/ISConfiguracao/src/Regra/RegraDaCotaParte.php`,
pura) -- os quatro tipos que o cálculo lê, o produto ativo, o percentual inteiro de 1 a 100 (a lista da tela,
`Arrays::porcentagens()`) -- e `UsuarioService::outraExtraNoPar`, chamados pelo
`UsuariosController::comissaoExtraSalvarAction`, com a frase de cada recusa na janela; o percentual vai
ao banco como inteiro. A lista da janela (`comissaoAction`) passou a oferecer só os quatro tipos
(`RegraDaCotaParte::produtosDaExtra` sobre `paraCupom`, que já trazia só os ativos da unidade). As 35 linhas
de hoje ficam como estão: a de pacote continua não valendo, e editar uma delas pede outro produto.

**Provado:** `RegraDaCotaParteTest` (sem banco: os tipos, o ativo, as pontas iguais às da lista, e a lista
sem pacote nem insumo) e `CotaParteExtraDoErpTest`, nos dois bancos, despachado em processo: os quatro
tipos gravam; o pacote e o serviço inativo são recusados; 101, -5, fração e texto são recusados (o zero já
era "obrigatório"); o segundo cadastro do par é recusado, a edição da mesma linha passa, e levar a linha de
um produto para o de outra é recusado; e a janela não oferece o pacote. Mutantes, um de cada vez,
vermelhos nos dois bancos: o pacote aceito, o 101 aceito, o inativo aceito, o par repetido aceito, a regra
do produto e a do percentual fora do controller, e a lista de antes. O `conferir-unicidade.php` já conta
os pares repetidos de `usuarios_comissoes_extras` (0); o índice único é esquema, e fica.

### D415 — Onde a pessoa aparece e o percentual base da comissão não eram conferidos contra a lista no servidor — CORRIGIDO em 04/10/2026

Achado pelo desenho do resto de Usuários (o D-J). `visivel_agenda`, `visivel_pdv` e `visivel_dashboard`
(`UsuarioDados`) e `comissao_produto` e `comissao_servico` (`UsuarioComissao`) tinham
`disable_inarray_validator`: a lista era só da tela, e o servidor gravava qualquer texto -- um
`visivel_agenda` fora dos quatro tira a pessoa da agenda pelo motivo errado (as fixtures antigas tinham
`sim`), e o percentual é dinheiro que sai da venda. A extra (`UsuarioComissaoExtra`) não tinha validação
nenhuma no servidor: é o D414.

- **Medido na cópia:** nenhum `visivel_*` fora da lista; nos percentuais base, só valores de 1 a 100 e
  zeros (644 em serviço, 643 em produto -- de antes do setter, que grava nulo; a lista nunca ofereceu o
  zero). Ninguém fica preso.

**O conserto:** os cinco campos passaram a `SelectDaLista`, que confere a lista no servidor com a frase em
português ("Opção fora da lista."). A lista da agenda é a de `RetratoDoHorario::ONDE_APARECE`, a mesma da
API (o D345).

**Provado** nos dois bancos (`UsuarioDadosTest`): a lista da agenda é a da API; os valores da lista passam;
`sim` e `todas` na agenda, `2` no PDV e `x` no painel são recusados com a frase; o percentual 101, -5, 0 e
texto recusados, e 1, 33 e 100 aceitos. Mutantes, vermelhos nos dois bancos: o `disable_inarray_validator`
de volta na agenda, no PDV e no percentual.

### D416 — "Só aplicativo" (`ext`) põe a pessoa no aplicativo e fora da grade do ERP: o aplicativo marca horário que o balcão não vê — REGISTRADO em 04/10/2026; a tela nova o diz — CORRIGIDO em 12/10/2026: a tela antiga não oferece mais o "Só aplicativo" (a resposta 112)

Achado pelo desenho do resto de Usuários (o D-L). O aplicativo lista quem tem `ext` ou `ext_int`
(`UnidadeRepository.php`, a consulta dos colaboradores do aplicativo), e a grade do ERP, quem tem `ext_int`
ou `int` (`UsuarioRepository::popularCombobox` com a agenda, `RetratoDoHorario::NA_AGENDA`): com `ext`, o
cliente marca pelo aplicativo e a recepção não vê o horário na grade.

- **Medido** (consulta C1 do desenho): 13 linhas com `ext`, nenhuma ativa.

**O que já vale (04/10/2026, o USUARIOS-F0):** a aba Dados da tela nova diz, de quem tem `ext` e está
ATIVO, "não: só no aplicativo, e o balcão não vê na grade o que ele marca" (o D345). De quem está inativo
-- os 13 de hoje --, o aviso não vale (o aplicativo também só lista `status = 1`), e a ficha diz "não: está
inativo (o cadastro marca só no aplicativo)" (a revisão do D345). **O que fica:** a tela nova não oferece
`ext` quando passar a gravar os Dados (a F3 do desenho, com o mutante "o `ext` aceito"); a tela antiga
continua oferecendo, e os 13 que o têm ficam como estão -- tirar a opção da antiga é decisão do dono, e o
desenho não a pediu.

**Decidido pelo dono em 12/10/2026 (a resposta 112, opção a, a recomendada):** tirar a opção "Só aplicativo" da tela
antiga de usuários; os 13 inativos ficam como estão; se um deles for reativado, a tela nova mostra o aviso.

#### O conserto (12/10/2026, o item PDV-E-CADASTRO)

`UsuarioDados::ondeAparece`: a lista do "Visível agenda" da tela antiga sai sem o "Externa (app)" (`ext`) no cadastro
novo e na ficha de quem está em outra agenda, e o servidor recusa o valor (a `SelectDaLista` do D415, "Opção fora da
lista."), para todos -- o administrador também. **Na ficha de quem já o tem** (os 13 da cópia, todos inativos), a opção
continua: sem ela o `select` abriria em "Selecione", e o campo, obrigatório, não deixaria editar nem reativar sem trocar
o valor -- e a resposta diz que eles ficam como estão. Reativado um deles, a tela nova já mostra o aviso (`naAgenda` em
`Usuarios.tsx`, desde o D345). A tela nova não grava os Dados ainda; quando gravar (a F3 do desenho), não oferece o
`ext`, o que o desenho já pede.

**Provado** nos dois bancos, em série: `UsuarioDadosTest` (12 casos; um novo: o cadastro novo recusa o `ext`, a ficha de
quem está na Interna não o oferece, e a de quem já o tem o oferece e o aceita; e o caso do D415 diz agora a lista da API
sem o `ext`) e `D204EdicaoDeUsuarioNoErpTest` (6; dois novos, pelo controller: o `ext` no POST de quem não o tem volta
com o campo marcado e não grava, pelo gerente e pelo administrador; e quem já o tem edita e é reativado sem trocar).

**Falsificado** (`medir-mutantes-pdv-cadastro.py`, no rascunho do item: um mutante por vez, o arquivo devolvido e
conferido pelo sha256 depois de cada um, contra os 181 casos do filtro do item; o mesmo vermelho no MySQL e no PostgreSQL):

| mudança | vermelho |
|---|---|
| o "Externa (app)" oferecido a todos | 3: o POST de quem não o tem (`D204`), a lista do cadastro novo e o caso novo do `UsuarioDadosTest` |
| tirado também de quem já o tem | 2: quem já o tem edita e reativa (`D204`) e o caso novo do `UsuarioDadosTest` |

### D417 — Na alteração pelo ERP, o `responsavel_id` pula a conferência do telefone e não torna ninguém dependente — REGISTRADO em 05/10/2026; a pergunta foi ao lote — CORRIGIDO em 07/10/2026

Achado ao consertar o D234 na tela antiga. O modal de cliente do ERP manda o
`responsavel_id` também na ALTERAÇÃO, e `validarFormulario` pula a conferência do
telefone repetido para qualquer um não vazio. Dois caminhos chegam aí:

- **a ficha do dependente** devolve o responsável gravado (`pesquisarAction`), e é
  por isso que o dependente se edita sem o telefone dele contar como repetido;
- **a troca do telefone de quem NÃO é dependente** para o número de outro cliente:
  o modal recusa, oferece "cadastrar como dependente", e o reenvio com o
  `responsavel_id` passa -- mas `ClienteService::update()` não o grava (o
  `ClassMethodsHydrator` procura `setResponsavelId`, que não existe). O cliente
  fica com o número do outro e sem ser dependente de ninguém. Lido no código, e
  não medido.

A API decide pelo que está GRAVADO, e não pelo pedido: na alteração, o telefone
só é conferido quando muda e não é conferido para quem já é dependente; o 409 não
oferece o dependente (`Adm\ClientesController::conferirRepetidos`). Trazer o ERP
para essa regra muda o que o balcão vê no segundo caminho -- a oferta do modal
deixaria de servir para quem edita --, e por isso não foi feito sem o dono.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 27): a opção (a), "Como a API" -- na
alteração pelo ERP, decidir pelo que está gravado (o dependente gravado e o telefone que não muda
passam; o resto é recusado com "Este telefone já é do cliente N"), e o modal deixa de oferecer o
dependente na edição. A implementação está na fila.

**O conserto (07/10/2026).** A regra da API saiu de `Adm\ClientesController::conferirRepetidos` para
`RegraDoCadastroDeCliente::confereOTelefoneNaAlteracao` -- o dependente gravado e o telefone que não muda
passam --, com a frase em `fraseDoTelefoneRepetido`, e as três portas da alteração a chamam: o `PUT` da API,
o modal (`ClientesController::salvarAction`, pelo `validarFormulario`) e a página `editar`. No ERP decide-se
pelo cliente gravado com o `id` do pedido -- o mesmo que `update()` grava --, e o `responsavel_id` do pedido
não conta. A recusa é "Este telefone já é do cliente N.", e a resposta do modal não traz mais
`telefone_existente` nem `responsavel_id`: o JavaScript do modal (`mod-cadastros.js` e `mod-vendas.js`) só
abre a pergunta "cadastrar como dependente" com `telefone_existente`, e por isso deixa de oferecê-la na
edição sem mudar uma linha. O cadastro novo continua como era (o D234). E a página `editar` perdeu uma
recusa: quem salvava sem mexer num número repetido gravado antes da regra (D32) era barrado com "Telefone já
cadastrado" -- agora passa, como na API.

**Provado** nos dois bancos (`D417TelefoneNaAlteracaoDoErpTest`, 8 casos): no modal, o número de outro é
recusado com a frase e sem a oferta, o reenvio com o `responsavel_id` do dono também (nada gravado: nem o
número, nem o responsável), o dependente gravado passa sem o campo e continua dependente, o telefone que não
muda passa, e o cadastro novo continua oferecendo o dono; na página, a mesma frase, o telefone que não muda
passa, e o dependente gravado passa (o controle). Mutantes, cada um sozinho e restaurado por sha1, todos
vermelhos: o `validarFormulario` de antes (4 casos), o `validarTelefone` de antes na página (2), a regra sem
o "não muda" (2: o modal e a página), sem o dependente gravado (2) e a alteração lendo de novo o
`responsavel_id` do pedido (1). O `PUT` da API, que passou a chamar a regra, foi conferido pelo ensaio de
recusas: 35l (o número de outro, 409), 35m (o repetido que não muda, 200) e 35s (o dependente, 200), na
rodada de 202 de 202.

**A verificação (07/10/2026)** apontou que o `PUT` só tinha a prova do ensaio HTTP, que roda à mão e só no
MySQL: tirar o `!` da chamada da regra deixava a caracterização verde nos dois bancos. O mesmo arquivo
ganhou três casos da API, em processo (`PUT` com o corpo inteiro): o número de outro é 409 com a mesma
frase, o motivo `telefone_repetido` e o dono, e nada gravado; o telefone que não muda passa; o dependente
gravado passa e continua dependente. Verdes nos dois bancos. Mutantes, cada um sozinho e restaurado por
sha1, vermelhos: a chamada sem o `!` (3 casos), sem a regra -- sempre confere -- (2) e com outra frase na
alteração (1). E pelo aplicativo de verdade, os casos 35 do ensaio de recusas (rodada parcial, 33 de 33)
no MySQL e também no PostgreSQL, onde ainda não tinham rodado.

**Não visto no navegador:** o modal e a página foram despachados em processo; o JavaScript não mudou.

### D418 — A edição da campanha confere o estado lido e grava sem condição, e grava o `status` que vier no POST — REGISTRADO em 05/10/2026 — DECIDIDO em 25/10/2026 (257 c), EM ABERTO: o conserto na 92a — PARCIAL em 25/10/2026: a tela antiga grava só de `aguardando` ou `agendado`, decidido no banco, e o `status` numa lista fechada; falta a porta nova (o PUT da 92d) — CORRIGIDO em 26/10/2026

Achado ao consertar o D303. A edição (`NotificacoesController::salvarAction`, pelo
`conferirAudiencia`) confere que a campanha está em `aguardando` ou `agendado` e
DEPOIS grava pela entidade (`NotificacaoService::salvar`), sem condição: se a rotina
de envio assumir a campanha entre as duas coisas, a edição regrava o estado que veio
no formulário por cima de `enviando`, e o fim da rodada -- que agora só sai de
`enviando` (o D303) -- deixa a campanha no estado da edição. E o `status` do POST só
é conferido como não vazio: a tela oferece os dois editáveis, e quem monta o pedido
à mão grava qualquer texto (`enviado`, `cancelado`, outro).

**O conserto provável** é o do D303: o estado numa lista fechada (os editáveis) e a
gravação condicional. Não foi feito aqui por ser a edição, e não o cancelamento.

**DECIDIDO em 25/10/2026 pela 257 (c)** (*"A tela nova ganha 'pausar' e 'retomar' para todos que fazem
campanhas: o gerente passa a ter o que hoje só o administrador tem"*). O conserto continua o deste débito -- o `status` numa
lista fechada (os dois editáveis) e a gravação condicional. **A tela antiga pausa como hoje**: o `<select>` da situação
segue só ao administrador, como a pergunta dizia ("A tela antiga continua pausando como hoje"); a opção (c) é da tela nova,
que ganha os botões "Pausar" e "Retomar" para todos que escrevem, condicionais no banco, com a retomada só com a hora por
vir. (A primeira versão deste parágrafo levava o `<select>` a todos também na tela antiga; a verificação da 40b a
corrigiu, por ir além da letra.) **Falta tudo do código**: vai à fatia 92a (o ERP antigo) e à 92d (a API), pelo desenho,
seção 2.

**PARCIAL em 25/10/2026 (a fatia 92a, NOTIFICACOES-ERP-ANTIGO).** Na tela antiga: o `status` da edição só entre os dois
editáveis (`RegraDaCampanha::doFormularioDoErp`, "- Status: só Aguardando ou Agendado"; o serviço recusa de novo, como
última porta), e a gravação condicional, `NotificacaoRepository::editarSeAindaNaoSaiu` -- um `UPDATE ... WHERE id = :id
AND status IN ('aguardando', 'agendado')`, e a tela ouve `FRASE_JA_SAIU` quando a rotina assumiu a campanha entre a
leitura e a gravação. O MySQL conta só as linhas mudadas, e a edição que manda o que já está gravado no mesmo segundo da
anterior dava 0: no 0 a linha é relida, e ainda editável quer dizer que casou. A edição deixou de regravar o canal e o
destinatário. O `<select>` da situação continua só ao administrador, como hoje (a 257 (c) é da tela nova), e a porta
antiga, como hoje, não confere o papel de quem manda o `status` dentro da lista (a decisão 11 do desenho). Provado em
`CampanhaAudienciaTest` (o administrador pausa e retoma; o `status` fora da lista grava o de antes; a rotina que assume a
campanha no meio da edição a deixa `enviando`; a edição sem mudança diz que gravou) e `RegrasDoMarketingTest`, nos dois
bancos, com os mutantes vermelhos nos dois -- menos o da releitura do 0, que só se prova no MySQL (o PostgreSQL conta as
linhas casadas, e lá o mutante fica verde). Na verificação da 92a, o caso da edição sem mudança passou a fazer as duas
gravações no começo de um segundo e a conferir que não atravessaram a virada: a `data_alteracao` não tem fração, e as
duas de lados da virada mudavam a linha, o MySQL contava 1, e o mutante passava verde naquela rodada. **Falta**: o PUT, a pausa e a retomada da API nova, condicionais, na 92d.

**CORRIGIDO em 26/10/2026 (a fatia 92d, NOTIFICACOES-API-ESCRITA).** A porta nova também: o `PUT
/adm/api/notificacoes/:id` altera campo a campo, recusa o `status` (e o `id`, a lista, o canal, o destinatário e a unidade)
com o nome (422, `RegraDaCampanha::conferir`), e grava pelo mesmo `NotificacaoRepository::editarSeAindaNaoSaiu`
condicional, agora sem tocar a situação (o `status` nulo: a alteração que regravasse a situação lida desfaria a pausa
feita no meio); a rotina que assumiu a campanha entre a leitura e a gravação ganha, e a resposta é o 409 `ja_saiu`. A pausa
e a retomada da 257 (c) têm os endereços delas, `POST .../:id/pausa` e `.../:id/retomada`, condicionais no banco
(`NotificacaoRepository::pausar` e `::retomar`, este só com a hora por vir, 422 `hora_passada`), para todos que escrevem
nas campanhas. Provado no `NotificacaoApiTest`, nos dois bancos, com a corrida da rotina simulada por um serviço que
assume a campanha antes da gravação, e os mutantes (a gravação sem condição, o `status` aceito no PUT, o PUT que regrava a
situação, a pausa e a retomada sem condição) vermelhos nos dois. O que resta à tela nova são os botões (a 92e), e não este
defeito: as duas portas que gravam já não gravam sem condição nem o `status` que vier.

**A verificação da 92d (26/10/2026) achou três restos, consertados na mesma fatia.** (1) **A edição da tela antiga
desfazia a pausa da tela nova**: o formulário aberto como "agendado" mandava "agendado" no `status` -- o gerente nem vê o
`<select>`, e o manda assim mesmo --, e, salvo depois de alguém pausar a campanha na tela nova, a tirava da pausa sem
aviso, e ela saía na hora marcada (a retomada feita na tela nova era desfeita do mesmo jeito). Agora a edição antiga manda
também a situação que leu (o campo escondido `status_lido`, que o `mod-marketing.js` preenche; o layout pede o
`?cache=166`), e o servidor só muda a situação gravada quando o `<select>` mudou (`RegraDaCampanha::situacaoDaEdicaoAntiga`;
o pedido sem o campo grava como antes). (2) **O PUT campo a campo desfazia a alteração gravada no meio**: ele lia a
campanha antes de qualquer trava e completava os campos que não vieram com o que leu; duas alterações juntas -- duas
pessoas, duas abas -- perdiam o título de uma ou levavam o dia remarcado de volta. Agora o `NotificacaoService::agendar`
relê a campanha sob a linha da unidade travada (`NotificacaoRepository::camposDaEdicao`, com `FOR UPDATE`) e, se ela já não
é a que o PUT leu (`RegraDaCampanha::mesmaLeitura`), recusa: 409 `alterada`, e nada se grava. (3) **O PUT, a pausa e a
retomada da cancelada diziam `ja_saiu`** ("já começou a ser enviada"): agora `ja_cancelada`, também quando outra pessoa a
cancelou entre a leitura e o clique. Provado no `NotificacaoApiTest` e no `CampanhaAudienciaTest`, nos dois bancos, com um
mutante vermelho para cada.

### D419 — Depois do D303, a campanha que falha no primeiro destinatário fica "enviando" e a tela não a cancela; e a retomada segue sem dono entre dois nós — REGISTRADO em 05/10/2026 — DECIDIDO em 25/10/2026 (258 a), EM ABERTO: o encerrar na 92b — PARCIAL em 26/10/2026: a rotina relê a situação a cada mensagem e o `encerrarParada` condicional existe; faltam o endereço (92d) e o botão (92e), e a retomada sem dono entre dois nós — PARCIAL em 26/10/2026: o endereço do encerrar (92d); faltam o botão (92e) e a retomada sem dono entre dois nós — PARCIAL em 26/10/2026: o botão do encerrar (92e); resta a retomada entre dois nós — PREPARADO em 07/11/2026: a retomada entre dois nós fica com a trava das campanhas no banco (a do D44), que vale no PostgreSQL, onde há mais de um nó

Achado pela revisão adversarial do conserto do D303 (05/10/2026), feita só de leitura porque o
`marketingEnvios` manda WhatsApp e push de verdade e não pode rodar aqui. A revisão concluiu que o
envio em si ficou equivalente ao do HEAD, linha a linha, e que o cancelamento durante a rodada
melhorou (antes a campanha cancelada saía inteira). Ficaram dois pontos:

- **A falha no primeiro destinatário.** O `assumirEnvio` grava `enviando` (e o `ultimoEnvio`) antes do
  primeiro envio. Se o envio ou a leitura do destinatário lançar exceção logo no primeiro, a campanha
  fica `enviando` e a rodada seguinte a retoma (o `ultimoEnvio` já é passado) -- mas, se a exceção se
  repetir a cada rodada, ela trava a fila e a tela não a cancela (o cancelar só sai de `aguardando` ou
  `agendado`). No HEAD ela ficava `agendado` e cancelável, e travava a fila do mesmo jeito. A revisão
  procurou uma exceção que se repita (EvolutionApi, WahaApi e OneSignal não lançam; unidade é NOT NULL)
  e não achou. Liberar o cancelamento de `enviando` reabriria a corrida que o D303 fechou; o conserto
  provável é a rodada marcar a campanha que falhou (um estado `falhou`, ou um contador de tentativas)
  -- mudança de estado da campanha, para quando a tela de Notificações for desenhada.
- **A retomada sem dono.** A trava da rotina é um `flock` por máquina (D44): com dois nós de aplicação
  rodando o cron, duas rodadas retomam a mesma campanha `enviando` e mandam em dobro. Igual ao HEAD; o
  D303 só fechou essa porta para a `agendado`, onde o assumir é atômico.

**A primeira parte, DECIDIDA em 25/10/2026 pela 258 (a)** (*"A tela mostra 'parada', e o gerente a
encerra: o que saiu fica contado, e quem faltou conta como 'não recebeu'"*), com o encerrar condicional (só sem mensagem
nova desde que a tela leu) e a rotina relendo a situação a cada mensagem -- o "estado `falhou`" que este débito imaginava
não é preciso: a `parada` é calculada (sem envio há uma hora no horário, com a rede inteira sem envio), e a encerrada se
reconhece pela `data_alteracao` posterior ao `ultimo_envio`, sem coluna nova -- pelo último, e não pelo primeiro envio,
porque a campanha deste débito, que cai no primeiro destinatário, tem o `primeiro_envio` nulo (só o envio que deu certo o
grava), e o `ultimo_envio` vem do `assumirEnvio`. **Falta o código**: a releitura e o `encerrarParada` na fatia 92b, o
endereço na 92d e o botão na 92e (`docs/f3-telas-restantes/tela-notificacoes.md`, seções 1 e 2). **A segunda parte (a
retomada sem dono entre dois nós) continua** como estava: não depende da tela.

**PARCIAL em 26/10/2026 (a fatia 92b, NOTIFICACOES-ROTINA-DO-DIA).** O `NotificacaoService::enviarCampanha` relê o estado
da campanha no banco antes de cada destinatário (`NotificacaoRepository::statusGravado`, uma consulta pela chave) e para
quando ele não é mais `enviando`; a gravação do envio não regrava o estado (a entidade continua `enviando` em memória, e o
Doctrine grava só o campo mudado). O `NotificacaoRepository::encerrarParada($id, $ultimoEnvioLido)` grava `enviado` e a
`data_alteracao` só na `enviando` cujo `ultimo_envio` é o lido -- com um envio depois da leitura, nada se grava. Provado no
`tests/Caracterizacao/CampanhaDoDiaTest.php` nos dois bancos (o encerrar entre dois envios para a rodada no seguinte; o
encerrar com um envio depois da leitura não casa), com os mutantes (a releitura tirada do laço; o `encerrarParada` sem o
`ultimo_envio`). **Falta**: o endereço `POST /adm/api/notificacoes/:id/encerramento` com a conta da `parada` (a 92d), o
botão (a 92e), e a segunda parte, a retomada sem dono entre dois nós, que não depende da tela.

**A verificação da 92b (26/10/2026)** achou o pior caso que o desenho já nomeava, e que o teste escondia: o encerrar casa
enquanto a mensagem de um cliente está saindo (o `ultimo_envio` ainda é o de antes, porque o `flush` vem depois do
`$enviar`), e o `flush` dessa mensagem gravava depois o `ultimo_envio` dela na linha já `enviado` -- posterior à
`data_alteracao` do encerrar, e pela decisão 17 do desenho a lista da 92c não a reconheceria como encerrada à mão. O caso
do teste encerrava com uma hora escrita no futuro (23:59 de hoje), e a ordem saía certa por isso. Agora, depois do
`flush`, a rotina relê o estado; se não é mais `enviando`, o `NotificacaoRepository::guardarAMarcaDoEncerrar` devolve o
`ultimo_envio` ao que o encerrar leu (só na `enviado` com a `data_alteracao` depois dele; o fim da rodada que retoma não
casa), e a rodada para. A mensagem fica contada no total e na linha de envio dela ("o que saiu fica contado"). O caso
refeito encerra pelo relógio de verdade do encerrar, numa parada que a rotina retoma, e confere o total, a linha de envio,
o `ultimo_envio` lido e a `data_alteracao` depois dele; um caso novo encerra durante um envio que falha (só a releitura do
começo do laço o pega). Nos dois bancos, com os mutantes (o conserto tirado; a marca sem a `data_alteracao`; a releitura
tirada do laço).

**A parada na API (26/10/2026, a fatia 92c).** A `/adm/api/notificacoes` diz `parada` da `enviando` sem envio dela nem da
rede na última hora do horário das campanhas (a noite não conta, nem no envio dela nem no da rede;
`RegraDaCampanha::situacaoDaQueSai`, com o último envio da rede lido junto com o trecho da fila, uma leitura por página e
só quando a página tem uma `enviando` ou uma atrasada fora do prazo -- `NotificacaoRepository::enviosDaRedeAntesDe`), `na_fila`
quando a rede mandou nessa hora e ela não, e `saindo` com envio dela; a `enviando` sem `ultimo_envio` (a de antes do
D303) é parada. O `podeEncerrar` é só da parada de WhatsApp com o `ultimo_envio` que o encerrar condicional compara, e
a "encerrada à mão" (a `data_alteracao` depois do ÚLTIMO envio, a decisão 17) é uma situação própria, com o
`encerradaEm`. Provado no `NotificacaoApiTest`, nos dois bancos, com os mutantes (sem a janela do horário, no envio dela
e, desde a verificação, no da rede; sem olhar a rede; a encerrada sem a `data_alteracao`; pelo primeiro envio; o encerrar
do push; e, da verificação, o retomar e o encerrar sem o privilégio de escrever, o retomar do push, e o encerrar da
`enviando` sem `ultimo_envio`). **Continua faltando** o endereço do
encerrar (a 92d), o botão (a 92e) e a retomada sem dono entre dois nós.

**PARCIAL em 26/10/2026 (a fatia 92d, NOTIFICACOES-API-ESCRITA).** O `POST
/adm/api/notificacoes/:id/encerramento` com `{ultimoEnvioLido}` encerra só a `parada`, pela mesma conta da lista (o
`podeEncerrar` de `RegraDaCampanha::oQueSeFaz` sobre a `NotificacaoService::situacoes`; 409 `nao_parada` na que está saindo
ou esperando a vez), pelo `encerrarParada` condicional da 92b com o relógio do serviço (409 `voltou_a_sair` quando saiu
mensagem desde a leitura). Provado no `NotificacaoApiTest` nos dois bancos, com os mutantes (o encerrar sem o lido, sem a
conta da parada). **Falta**: o botão (a 92e) e a segunda parte, a retomada sem dono entre dois nós, que não depende da
tela.

**PARCIAL em 26/10/2026 (a fatia 92e, NOTIFICACOES-TELA-LISTA).** A tela nova das campanhas
(`frontend/src/telas/Notificacoes.tsx`, `NotificacoesLigado.tsx`) mostra a `parada` com o selo e a frase ("nenhum envio há
mais de uma hora, e a rede inteira também parou de mandar", com o N de M e o último envio), e o botão "encerrar" -- neutro,
que pergunta no próprio botão com o número ("Os 37 que ainda não receberam ficam sem a mensagem, e contam como 'não
receberam'. Encerrar não se desfaz."), e confirma em `.destrutiva` -- só onde o recurso dá o `podeEncerrar`. O encerrar
manda o `ultimoEnvio` que o recurso deu, como veio; o 409 `voltou_a_sair` diz a frase do servidor e relê a campanha, e o
botão some com ela. Provado no `NotificacoesLigado.teste.tsx` e no `formularioDaNotificacao.teste.ts`, com os mutantes (o
encerrar fora da parada, a pergunta sem o número, o 409 sem a releitura, o corpo sem o lido). **Continua faltando** a
segunda parte, a retomada sem dono entre dois nós (a trava por máquina, o D44), que não depende da tela.

**PREPARADO em 07/11/2026 (a fatia 112 da fila dos débitos de pé, MARKETING-CAMPANHAS-E-SORTEIOS): a segunda parte já não
estava de pé como escrita.** A triagem propunha uma trava por linha (um `UPDATE` condicional com dono e prazo na campanha
`enviando`, no molde do `assumirEnvio`). Conferido antes de fazer, como as notas da fatia mandam: a causa que este débito
dá -- *"a trava da rotina é um `flock` por máquina (D44)"* -- foi consertada na fatia 105 (07/11/2026, o D44 PREPARADO): o
`marketingEnvios` pede a trava pelo `CronJobService::acquireLock`, que passa a conexão do EntityManager, e no PostgreSQL a
`TravaDeExecucao` pede também o `pg_try_advisory_lock` da chave `marketingEnvios`. A rodada de outra máquina, que tem o
arquivo dela e divide só o banco, desiste com "Processo já em execução." antes de ler a fila: **só uma rodada da rede roda
de cada vez, e é ela quem retoma a `enviando`**. E não há outra porta: o `NotificacaoService::enviarCampanha` só é chamado
pelo `marketingEnvios` (grep em `module/`). Mais de um nó só existe com o PostgreSQL (`docs/alta-disponibilidade.md`, o
laboratório `docker-compose.ha.yml` é Patroni/Spilo); no MySQL de produção a aplicação é uma máquina só até o corte, e o
`flock` basta. Por isso a marca é PREPARADO, a mesma do D44: o que fecha a retomada entre nós só vale com a aplicação no
PostgreSQL.

**A trava por linha não entrou, e por quê.** Ela pediria duas colunas novas em `notificacoes` (o dono e o prazo), com a
migração no passo 0, as duas cópias locais e a leitura fora do mapeamento enquanto a base do teste do BVIP não as tiver (o
molde do D473) -- para fechar uma porta que a trava da rotina já fecha. E usar o `ultimo_envio` como prazo, sem coluna nova,
quebraria a 258 (a): a rodada que retoma gravaria o `ultimo_envio` ao assumir, e a campanha que cai sempre no primeiro
destinatário nunca chegaria a "parada" (a conta é "sem envio há uma hora"), e o gerente não a encerraria mais.

**Provado** no `CampanhaDoDiaTest::testARetomadaSoSaiDeUmaRodada`, nos dois bancos, com a rotina de verdade e o WhatsApp de
mentira (anota e responde que mandou; nenhum provedor é chamado): uma `enviando` de ontem com dois clientes; outra rodada
segura a trava das campanhas -- nesta máquina (a outra conexão, nos dois bancos) ou na outra, só no banco (no PostgreSQL; no
MySQL o caso pula, como o do D44) --, e a desta responde "Processo já em execução.", não manda nada e não toca na campanha
(o estado, o total, a lista, o primeiro e o último envio e as linhas de envio iguais); solta a trava, a rodada seguinte a
retoma e manda uma vez a cada cliente (`enviado`, 2 envios, 2 linhas); a de depois não manda de novo. **Mutantes**, um por
vez e devolvidos pelo sha1: a conexão tirada do `acquireLock` (vermelho o da outra máquina, no PostgreSQL) e a rodada que não
olha a trava (vermelhos os dois). **Não provado**: duas máquinas de verdade (a outra sessão do banco faz o papel dela); o
PgBouncer em modo transação, que quebraria a trava de sessão (o mesmo vão do D44).

**A verificação da fatia 112 (07/11/2026)** conferiu o argumento no código (o `enviarCampanha` só é chamado pelo
`marketingEnvios`, no `CronJobService`; o `acquireLock` passa a conexão do EntityManager) e o aceitou: não há regra de negócio
decidida no lugar do dono. **O que a trava por linha fecharia e a da rotina não fecha fica nomeado aqui**: o PgBouncer em
modo transação (a trava de sessão não vale; o `docs/f1-2-decisao-saas.md` o cita como caminho possível) e a conexão que cai no
meio da rodada (o banco solta a trava; a rodada que perdeu a conexão para no próximo `statusGravado`, e a mensagem que estava
saindo, sem a gravação dela, pode ir de novo pela outra -- lido no código, não medido). Os dois são o vão do D44, e não deste
débito: **se o corte puser o PgBouncer em modo transação, a trava por
linha (o dono e o prazo na `notificacoes`, no molde do `assumirEnvio`) volta a ser o conserto daqui**, junto com o do D44.

### D420 — `DataHora::dataValida` derruba o PHP 8 com texto que não é número, e quinze chamadas dependem dela para recusar — REGISTRADO em 05/10/2026; a pergunta foi ao lote — CORRIGIDO em 06/10/2026

Achado em 05/10/2026, escrevendo o "dia que se vê" do remarcar da agenda antiga (D216).

`dataValida` confere o tamanho (10) e as três partes do `explode('/')`, e passa as
partes cruas ao `checkdate(int, int, int)`. Com texto que não é número, o PHP 8 não
converte: lança `TypeError`. Medido no contêiner (PHP 8.1.34):

| entrada | resposta |
|---|---|
| `26/12/2026` | `true` |
| `31/02/2026` | `false` |
| `aa/bb/cccc` | `TypeError: checkdate(): Argument #1 ($month) must be of type int, string given` |
| `1a/02/2026` | `TypeError: ... Argument #2 ($day) ...` |

Quem chama para RECUSAR a data ruim recebe um 500 no lugar da recusa. São **quinze
chamadas, em onze linhas** (o grep de `dataValida(` em `module/`, fora a definição e o
dia do remarcar), todas com o POST cru: o cadastro de cliente e a fidelidade
(`data_nascimento`, uma cada), o fechamento da agenda (`RegraDeFechamento`,
`data_inicio` e `data_fim`, duas linhas), os relatórios de caixas (duas, início e fim
na mesma linha), de produtos (quatro, em duas linhas) e de vendas (duas, numa linha),
a lista de vendas (duas linhas) — e o **cadastro do aplicativo**
(`AplicativoController`, `data_nascimento`), que é do contrato congelado (D5). (A
primeira redação dizia "onze chamadas": contava linhas; a verificação de 05/10/2026
recontou.) Não é buraco: exige sessão (ou o aplicativo), e quem manda texto no campo
de data é quem monta o pedido à mão. É o 500 no lugar da frase. O dia do remarcar
(D216) confere a forma `dd/dd/dddd` antes de chamar, e por isso não entra na conta.

**Direção:** no próprio ajudante, `ctype_digit` nas três partes antes do `checkdate`
— conserta as quinze de uma vez. Não foi feito aqui porque uma delas é o contrato do
aplicativo: a resposta dele a um nascimento `aa/bb/cccc` passaria de 500 para "data
nascimento inválida". Foi ao lote como pergunta.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 29): a opção (a), "Consertar no
ajudante, as quinze de uma vez" -- `ctype_digit` nas três partes antes do `checkdate`; o aplicativo, a
um nascimento `aa/bb/cccc`, passa a responder "data nascimento inválida" no lugar do 500, a única
mudança no contrato, e só no pedido malformado. ~~A implementação está na fila.~~ (feita em 06/10/2026,
logo abaixo)

#### O conserto — 06/10/2026

No item ERP-BOTOES-E-DATAS, pela resposta 29 do dono. No próprio ajudante, as quinze de uma vez:
`dataValida` confere `ctype_digit` nas três partes antes do `checkdate`, e passa a ele os inteiros. A
recontagem das chamadas, pelo grep de `dataValida(` em `module/`, deu as mesmas quinze em onze linhas, mais
o dia do remarcar (D216), que já conferia a forma. Duas coisas a mais, no mesmo espírito, e ditas aqui:

- **o que não é texto é `false`** (`is_string`): o `data_nascimento[]=` de um POST montado à mão era o
  `TypeError` do `explode`, a mesma classe de 500. O nulo, que já era `false`, deixa de dar o aviso de
  depreciação do PHP 8.1;
- **o `ctype_digit` recusa também ` 1/02/2026`, `1 /02/2026` e `+1/02/2026`**, que o PHP 8 lê como número e
  o ajudante aceitava. Não eram datas que alguém grava: o `createFromFormat('d/m/Y')` de quem chama devolve
  `false` para as três (medido no contêiner).

**O que muda para quem usa:** nada na tela -- o seletor manda `dd/mm/aaaa`. O pedido montado à mão com
texto no campo de data recebe a frase de cada chamada ("- Data inválida" no cadastro de cliente do ERP,
"data nascimento inválida" no aplicativo, a data de hoje nos relatórios e na lista de vendas) no lugar do
500. As datas de verdade, válidas e inválidas, respondem como antes.

**Provado:**
- `D420DataValidaTest` (27 execuções, verdes nos dois bancos): as datas de verdade pelo calendário -- o 31/02,
  o 29/02 em 2024 (bissexto), em 2026 (não), em 2000 (divisível por 400) e em 1900 (por 100, e não por 400),
  o dia zero, o mês 13 e o ano zero (9); o que não é data é `false`, sem derrubar o PHP -- `aa/bb/cccc`,
  `1a/02/2026`, `01/1a/2026` e `01/02/2a26` (a letra só no mês e só no ano, da revisão do item),
  `0x/02/2026`, `1e/02/2026`, as três de espaço e sinal, a vazia, a nula e o arranjo (12); e
  duas das quinze despachadas em processo, com `aa/bb/cccc`, `1a/02/2026` e o 31/02 de controle: o modal de
  cliente do ERP (`clientes/salvar`) responde só "- Data inválida" e não grava, e o cadastro do aplicativo
  (`/api/clientes/novo`) responde `{sucesso: false, mensagem: "data nascimento inválida"}` e não grava (6).
- Mutantes, um de cada vez, no MySQL, restaurados pelo sha256: sem o `ctype_digit`, 11 vermelhos -- 8 com o
  `TypeError` do `checkdate` (as quatro de letra, e as duas chamadas despachadas com as duas de letra) e as
  3 de espaço e sinal; sem o `is_string`, o arranjo, com o `TypeError` do `explode`. **Na revisão do item**,
  dois que passavam verdes nos 25 casos de antes -- nenhuma entrada tinha letra só no mês ou só no ano, e o
  `(int)` engolia o texto (`01/1a/2026` virava o 1 de janeiro): sem só o `ctype_digit` do mês, 1 vermelho
  (`01/1a/2026`); sem só o do ano, 1 (`01/02/2a26`).
- Por HTTP, contra o banco de contrato: `POST /api/clientes/novo` com `aa/bb/cccc` e `1a/02/2026` responde
  200 com a frase; com o ajudante sem o `ctype_digit`, 500 ("Não foi possível atender a este pedido."); o
  31/02 é 200 com a frase nos dois; nenhum cliente gravado.
- A suíte de contrato, nos dois bancos, 67 de 67, sem instantâneo mudado: o contrato grava só o cadastro
  sem dados, e o pedido malformado não tem instantâneo.
- Os vizinhos que chamam o ajudante -- o fechamento da agenda antiga, o cadastro de cliente, a adesão da
  fidelidade (D184), a remarcação da tela antiga, os relatórios e a lista de vendas --, verdes nos dois
  bancos (922 execuções em 66 arquivos, com os da barreira de origem do D315).

**O que fica:** a forma. O ajudante confere o tamanho e não a posição das barras: `1/02/02026`, `01/2/02026`
e `001/2/2026` têm dez caracteres e três partes de dígitos, e passam -- e o cadastro de cliente (no ERP e no
aplicativo), o fechamento da agenda antiga e os relatórios respondem 500. É o D432, com a pergunta no lote,
e a tabela de cada chamada está lá. (A primeira redação daqui dava as três formas como "medidas em
processo" no cadastro; só a primeira tinha sido -- as outras duas, na mesma rodada, eram a cascata do
`EntityManager is closed`. A revisão do item mediu cada uma num processo próprio, e as três dão o 500.)

**Fechado em 08/10/2026** (o D432, pela resposta 39 do dono): o ajudante confere a forma `dd/mm/aaaa` por
expressão regular, que toma o lugar do `strlen`, do `explode` e do `ctype_digit` daqui. Os casos do
`D420DataValidaTest` seguem verdes; os mutantes do `ctype_digit` descritos acima falam de um código que não
existe mais -- quem prende as letras agora é o `[0-9]` da expressão: trocado por `.`, 9 casos daqui ficam
vermelhos (ver o conserto no D432).

### D421 — O motivo do fechamento com emoji passa pelas duas conferências e morre no MySQL — REGISTRADO em 05/10/2026 — CORRIGIDO em 07/11/2026: a `RegraDeFechamento::problemaDoMotivo` passa pela `RegraDoTexto`, na API e no ERP

Achado em 05/10/2026, pondo o teto de 100 caracteres no motivo da tela antiga (D215).

`agendas_fechamentos.motivo` é `varchar(100)` em `utf8mb3` (lido no esquema do
`franquiabv_dev`), que só guarda até o U+FFFF; e o MySQL do contêiner roda com
`STRICT_TRANS_TABLES` (lido no `@@GLOBAL.sql_mode`). Um motivo com emoji passa pela
`RegraDeFechamento` (tela antiga) e pela `conferirFechamento` da API — as duas contam
caracteres —, e o INSERT do fechamento seria recusado com o erro 1366: um 500. É a
classe do D224, que a observação do marcar e do remarcar já recusa com
`RegraDaRemarcacao::FORA_DO_QUE_A_COLUNA_GUARDA`. No PostgreSQL a coluna guarda o
emoji, e o defeito não existe.

**Não medido**: lido no esquema e no modo do banco, e não reproduzido no fechamento.
**Direção:** a mesma conferência do D224 nas duas portas (*"- Motivo sem emoji"* na
antiga, 422 com `campos.motivo` na API). Não entrou junto porque passa da tela antiga
para a API, e o item era a tela antiga.

#### CORRIGIDO em 07/11/2026 -- as duas portas pela mesma peça (a fatia 110, TEXTO-NAS-REGRAS)

**Reproduzido antes do conserto:** com o motivo `"Treinamento 😀"`, o `fechar` da API caiu no MySQL com o erro 1366
(`Incorrect string value ... for column 'motivo'`), e no PostgreSQL gravou -- o mutante M17. O conserto: a
`RegraDeFechamento::problemaDoMotivo` (a `RegraDoTexto` de uma linha, com o teto da coluna: o vazio, os 100
caracteres, o emoji -- o mesmo limite de `RegraDaRemarcacao::FORA_DO_QUE_A_COLUNA_GUARDA`, que a peça tem como
`CABE_NA_COLUNA` --, o sinal e o controle, os dois do D495), chamada pela tela antiga (*"- Motivo sem emoji"*, e as
linhas do sinal e do controle) e pela `conferirFechamento` da API (422 em `campos.motivo`: "O motivo não guarda
emoji."). As frases de antes do vazio e do teto não mudaram. Os casos: o `FechamentoDaTelaAntigaTest` (o emoji, o
sinal e a tabulação, sem gravar, mesmo com o "confirmar") e o `TextoNasRegrasTest` (a API); os mutantes M16 e M17,
vermelhos nos dois bancos.

### D422 — Na agenda antiga, o check-in de quem tem "Agendas unidades" vira também o horário do cliente em outra franquia, e desfaz a chegada que ela registrou — REGISTRADO em 05/10/2026; a pergunta foi ao lote — CORRIGIDO em 07/10/2026

Achado em 05/10/2026, na verificação do TESTES-DOS-CONSERTOS-A (o D190).

`AgendasController::checkinAction` pega os horários do cliente no dia
(`selecionarAgendaCliente`, sem unidade) e, desde o D190, deixa só os que passam por
`agendaDaMinhaUnidade`. Para quem tem "Agendas unidades" — e para o administrador, que o
recebe pelo `Acl` —, todos passam. O `AgendaService::checkin` é interruptor
(`setCheckin(!getCheckin())`) em cada um. Então, com o cliente às 10h na unidade A e às
15h na B, no mesmo dia, e a B já tendo registrado a chegada dele, o clique no check-in das
10h, na grade da A, marca a A e **desfaz a chegada da B**.

A outra porta da mesma ação, `Adm\AgendamentosController::checkinAction` (a tela nova, por
`EscritasDaAgenda::registrarChegada`), tem outra regra: só a família do horário clicado, só
na unidade da sessão (404 mesmo para o administrador), e nunca desfaz — marca só os que
estão sem chegada.

O D190 fechou o recorte para quem **não** tem o privilégio. O que acontece com quem tem
nunca foi decidido: o caso que o diz (`D190CheckinEmDuasFranquiasTest`, os dois de "Agendas
unidades") foi escrito, na primeira versão, como "o comportamento de antes, para quem
pode", e passou a dizê-lo como fato, com outro caso que mostra a chegada desfeita.

**Não foi consertado:** é regra de negócio, e as saídas mudam o que a tela antiga faz para
quem enxerga a rede — limitar à unidade do horário clicado, à da grade pedida, à da sessão
como a API, ou deixar como está. Foi ao lote como pergunta; quando houver resposta, os dois
casos marcados mudam junto.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 22): a opção (a), "Só a unidade do
horário clicado" -- quem enxerga a rede marca a chegada na unidade do horário clicado sem tocar na da
outra franquia. A implementação estava na fila, e entrou em 07/10/2026 (abaixo). Os dois casos marcados do
`D190CheckinEmDuasFranquiasTest` mudaram junto.

**O conserto, em 07/10/2026** (a implementação que estava na fila). `AgendasController::checkinAction`
acha, entre os horários do cliente naquele dia, o horário clicado -- o `agendamento_id` que a grade
desenhou (`$horario['id']` no `tabela.phtml`) -- e, para quem enxerga a rede (`enxergaARede()`, o primeiro
termo de `unidadePermitida`, tirado para um método só), vira só os horários do cliente na unidade dele, com
`agendaDaMinhaUnidade` valendo por cima. Não decidem nem a unidade da sessão nem a da grade aberta
(`unidade_id`). Quem não tem "Agendas unidades" segue **exatamente** como antes: os horários do cliente
naquele dia na unidade da sessão, e o `agendamento_id` não decide nada para ele (só conta para o cliente 2,
o "Sem cadastro", dentro da consulta). O interruptor fica (o check-in de quem já chegou desmarca); a tela
nova (`EscritasDaAgenda::registrarChegada`) não mudou.

**Uma consequência que a regra traz, e que o caso prende:** para quem enxerga a rede, o horário clicado
que não está entre os do cliente naquele dia não vira nada, e a resposta é "Não foi possível localizar o
horário: ele pode ter sido remarcado ou desmarcado. Atualize a agenda e tente de novo." O caminho comum
até aí é a **remarcação** com o modal aberto -- pelo aplicativo, pelo arrastar ou pelo modal de outro
caixa: `AgendaService::agendar` grava linhas novas, com outro id, e `desmarcar` apaga a clicada --; o outro
é o desmarcar. A tela só troca a grade quando dá certo (`agendaCheckin` em `mod-cadastros.js`), e a grade
velha fica até o `agendaAtualizar` de dois minutos: por isso a frase manda atualizar. Antes, o clique
virava os horários do cliente na rede inteira. Sem horário clicado não há unidade, e cair na da sessão ou
na da grade seria escolher por ele uma das saídas que o dono não escolheu; o lote de 07/10/2026 leva o
caso ao dono como ciência, sem pergunta. Não se sabe se algum pedido real chega assim (a produção não foi
olhada). Pela leitura, e não medido: o aviso de chegada por WhatsApp (`AgendaService::checkin`) sai pela
unidade do último horário da lista, que para quem enxerga a rede podia ser o da outra franquia; agora é
sempre a do clicado.

**A verificação, no mesmo dia, desfez uma parte e corrigiu o registro.** A primeira versão do conserto
recusava o horário clicado que sumiu também para quem **não** tem "Agendas unidades" (e o clique forjado
no horário de outra franquia), chamando isso de "leitura direta da opção (a)" -- mas a resposta 22 tratou
de quem enxerga a rede, o item mandava que os outros seguissem como hoje, e o roadmap dizia "para quem não
tem 'Agendas unidades' nada muda" sem que fosse verdade. Eles voltaram ao comportamento de antes, e dois
casos o prendem. O registro citava só o horário desmarcado; o caminho comum é o remarcado, e o caso agora o
reproduz. E a verificação achou o modal que o operador confirma listando os horários da unidade da sessão,
e não os que o clique alcança (D436, ao lote), e o dia do cenário do arquivo caindo, em 09/11/2026, no
horário fixo da fixture (D437, consertado neste arquivo).

**Provado** em `D190CheckinEmDuasFranquiasTest`, 15 casos verdes no MySQL e no PostgreSQL. Os dois que
diziam o fato de antes mudaram para a regra nova -- com "Agendas unidades", o clique na minha marca só a
minha (`[true, false]`, antes `[true, true]`), e a chegada que a outra registrou fica (`[true, true]`,
antes `[true, false]`) --, e entraram: o clique no horário da outra, com a sessão na minha, mexe só na
outra (a unidade não é a da sessão); o mesmo clique com o POST dizendo que a grade aberta é a minha mexe só
na outra (não é a da grade); o horário remarcado com o modal aberto, que para quem enxerga a rede recebe a
recusa e não vira nada, e para quem não enxerga marca o das 11h como antes; o clique forjado no horário da
outra franquia, que para quem não enxerga a rede vira o da minha, como antes, e não o da outra; e a escolha
do dia do cenário (D437).

**Falsificado**, um mutante por vez, nos dois bancos, restaurado e conferido pelo sha256 (os da
verificação, sobre o código de agora; os da primeira rodada que miravam a recusa para quem não enxerga a
rede saíram com ela):

| mutante | vermelho |
|---|---|
| sem o recorte pela unidade alcançada (só `agendaDaMinhaUnidade`, como antes) | os quatro casos de "Agendas unidades" da escrita |
| quem enxerga a rede fica na unidade da sessão | o clique no horário da outra, e o da grade aberta |
| quem enxerga a rede fica na unidade da grade aberta (`unidade_id`) | o da grade aberta |
| o clicado que sumiu cai na unidade da sessão | o remarcado, com "Agendas unidades" |
| o clicado que sumiu cai na unidade da grade aberta | o remarcado, com "Agendas unidades" |
| a regra do clicado, e a recusa, para todos | o remarcado e o clique forjado, sem "Agendas unidades" |
| a primeira versão inteira (para todos, e o clicado de outra franquia recusado) | os mesmos dois |
| a escolha do dia devolve o dia pedido sem olhar | o da escolha do dia |

### D423 — A etapa da adesão pública não era conferida, e cada etapa inventada rendia mais 10 pontos — CORRIGIDO em 05/10/2026

Achado em 05/10/2026, na verificação do TESTES-DOS-CONSERTOS-A (o D184).

`POST /fidelidade/salvarDados` é rota pública; quem prova quem é, é o par `id` + `hash`
que o cliente recebe no link de adesão. A `etapa` é campo oculto do mesmo formulário, e
não era conferida:

| etapa | antes |
|---|---|
| `-1`, `0`, `-2`, ... | a validação compara com `==` e não entra em ramo nenhum; o serviço grava sem mudar nada; a pontuação recusava só o que passasse de 6 (`!($etapa <= 6)`) — **10 pontos** a cada uma, sem limite, e `pontuacao: true` na resposta |
| `04`, `4.0`, ` 4` | valem como a etapa 4 na validação (`"04" == "4"` no PHP 8) e gravam a profissão — e **pontuam de novo**: a pergunta "já pontuou?" é pela descrição, que levava a etapa como veio ("Completou a etapa 04 ...") |
| `7` | nada gravado, `sucesso`, `pontuacao: false`, `proxima_etapa: 8` |
| `abc`, vazia, arranjo | `TypeError` no `$request['etapa'] + 1`: 500 na rota pública — e a vazia **pontua antes** de estourar (`"" <= 6` é verdadeiro no PHP 8) |
| sem a etapa | avisos de chave indefinida (a classe do D180), e **10 pontos**, com a descrição "Completou a etapa  do cadastro" |

Medido em 05/10/2026 no banco de teste, com as duas guardas novas arrancadas juntas: `-1`,
`0`, `04`, `4.0`, ` 4` e a etapa ausente respondem `pontuacao: true`; e no contêiner, por
`php -r`, as comparações da tabela.

O caso do D184 que dizia "pontua uma vez só" passava, e a propriedade era falsa fora da
grafia "4".

**O conserto, nas duas pontas:** `FidelidadeService::etapaDaAdesao()` aceita o texto exato
que a página manda ("1" a "6", `fidelidade_cadastro.js`) e o inteiro que o próprio serviço
passa (a etapa 6 do indicador), e recusa o resto. O `salvarDadosAction` a pergunta junto
com o par — a etapa fora dos seis responde "Dados de segurança inválidos." antes de tocar
no cliente — e o `salvarPontuacaoCadastro` também, e escreve a descrição com o inteiro: a
frase da etapa 4 é a de sempre, e não há segunda. O fluxo da página não muda: ela só manda
os seis.

**Provado** em `D184SalvarDadosDaAdesaoTest`, nos dois bancos: as dez etapas que a página
não manda (`-1`, `0`, `7`, `04`, `4.0`, ` 4`, `abc`, vazia, arranjo, nenhuma) recebem a
recusa e não gravam nem pontuam; e o serviço, chamado direto, não pontua as inventadas,
pontua a `4` uma vez, e o inteiro `4` e o `04` não pontuam de novo. **Falsificado**, um
mutante por vez e restaurado byte a byte: sem a pergunta no controller, os dez casos da
rota ficam vermelhos (sete falhas e três erros — o `TypeError` do `abc`, o vazio e o
arranjo); com a guarda antiga (`!($etapa <= 6)`) no serviço, o caso do serviço.

**Ninguém o usou na cópia:** no `franquiabv_dev` (MySQL), dos 128.012 históricos
"Completou a etapa ...", **zero** têm descrição fora das seis frases — contado em
05/10/2026, só a contagem, com o arquivo em `utf8mb4`.

### D424 — Pedir `index` a um controller do ERP que não tem `indexAction` responde 500, e não 404 — REGISTRADO em 05/10/2026 — CORRIGIDO em 07/11/2026: o `index` sem tela é 404 (a fatia 115)

Achado em 05/10/2026, no ensaio dos cinco destinos do D105 (o caso 5).

O link morto da tela inicial do grupo 9, `/mod-relatorios/vendas/index`, não dava 404,
como o D105 escreveu: dava **500**. O alias `vendas` existe — é o do PDV, no `ISVenda`, e
os aliases do gerenciador de controllers valem em todos os prefixos —, e
`ISVenda\Controller\VendasController` não tem `indexAction`. O Laminas tem um padrão
(`AbstractActionController::indexAction`, que devolve "Placeholder page"), e o molde que
ele pede, `is-venda/vendas/index`, não existe: o `PhpRenderer` lança `RuntimeException`, e
a página é "An error occurred" (no PostgreSQL de contrato, com a exceção visível).

**Medido por HTTP, com sessão, no banco de contrato:** `/mod-relatorios/vendas/index` e
`/mod-vendas/vendas/index` respondem 500; `/mod-vendas/vendas/naoexiste`, 404. A ação que
não existe cai no `notFoundAction`; a `index` cai no padrão, que existe.

**A classe, pela leitura:** 15 controllers concretos do ERP (fora de `Adm`) não têm
`indexAction` próprio nem molde `index.phtml` — `AplicativoController`,
`CronJobController` e `VisagismoController` (ISApi), `EnderecosController`,
`FidelidadeController`, `PesquisaNpsController` e `TesteEstiloController` (ISCadastro),
`AppConfiguracoesController`, `AutenticacaoController` e `WhatsAppController`
(ISConfiguracao), `FranquiasController` (ISRelatorio), e `CaixasController`,
`VendasController`, `VendasPagamentosController` e `VendasProdutosController` (ISVenda).
Medidos, só os dois caminhos acima.

**Por que importa pouco, e por que importa:** o padrão não lê nada nem grava nada — é um
500 sem dado. Mas é um 500 onde devia haver 404: ele aparece no log de erros como
defeito, um link errado para `index` vira "An error occurred" em vez da página de "não
encontrado", e quatro daqueles controllers respondem rotas públicas (a adesão, a
pesquisa NPS, o teste de estilo, a entrada) — se a rota deles alcança a ação `index`
sem login, **não foi medido**.

**O conserto provável:** um `indexAction()` no `CrudController` que devolve o
`notFoundAction()` — os controllers que têm o seu continuam com ele. Pede rodar os
portões de rota (`conferir-rotas-orfas.php`, `conferir-rotas-sem-login.php`,
`conferir-guarda-do-erp.php`) e o caso por HTTP dos dois caminhos acima (404 depois). O
ensaio do D105 aceita 404 ou 500 no link morto do caso 5, para não ficar vermelho quando
este sair.

#### CORRIGIDO em 07/11/2026 (a fatia 115, ROTAS-404-E-401)

**O conserto provável foi o feito:** `ISBase\Controller\CrudController::indexAction()` devolve
`notFoundAction()->setTerminal(true)`. Os controllers que têm o seu continuam com ele; os que não têm deixam de cair no
"Placeholder page" do Laminas. **A lista, medida em processo pelo `ControllerManager`** (e não pela leitura de 05/10/2026):
das 109 classes registradas, 25 herdavam o `indexAction` de marcador -- 22 delas filhas do `CrudController` (os 14 da
leitura de 05/10 que seguiam sem o seu -- o `VendasController` ganhou o dele em 07/10 --, sete da `/adm/api` e o
`VipClassMaterialController`, que só se alcançam pelas rotas nomeadas, nenhuma com a ação `index`), e três fora dele (as da
API v4, também só pelas rotas nomeadas). Ao `index` só se chega pelas seis curingas, que levam aos 12 apelidos do D14, e
nenhum dos doze tinha molde `index.phtml` que o de marcador desenhasse: o 404 não tira tela de ninguém. As rotas públicas daqueles
controllers (a adesão, a pesquisa NPS, o teste de estilo, a entrada) não têm `:action`: só as seis curingas, todas atrás da
sessão, alcançam o `index` -- a pergunta "sem login?" de 05/10 tem resposta, não.

**Provado** pelo caso do D14 (acima, no D14) nos dois bancos e pelo caso 6 do `ensaiar-links-consertados-do-erp.php` por
HTTP, que mede o 500 com o conserto arrancado; o caso 5 do mesmo ensaio exige agora o 404 do `/mod-relatorios/vendas/index`.
O `conferir-guarda-do-erp.php` ganhou a ação na lista, como COTO (o corpo só recusa), e o `conferir-rotas-orfas.php`
passou a acusar a rota que aponte para o `index` sem tela do `CrudController` (o D621, abaixo).

### D425 — A faixa do dia aceitava 31/02 e respondia pelo 3 de março — CORRIGIDO em 06/10/2026

Achado em 06/10/2026 ao estender `AgendaRepository::diaInteiro()` aos seis
`DATE(age.data)` por cliente (o fim do D189), e não procurando por ele: antes de pôr a
faixa no check-in da tela antiga, era preciso saber o que a forma nova faz com uma data
que a forma antiga recusava.

**O defeito.** `diaInteiro()` monta o início do dia com
`DateTimeImmutable::createFromFormat('Y-m-d H:i:s', ...)` e só recusava quando ele
devolvia `false`. Mas `createFromFormat` **não** devolve `false` para um dia que não
existe: `'2026-02-31'` vira `2026-03-03`, e o único sinal é um aviso em
`getLastErrors()` ("The parsed date was invalid"), medido no PHP 8.1.34 do contêiner.
A faixa então respondia pelo dia 3 de março, em silêncio.

**A forma antiga recusava.** `DATE(age.data) = '2026-02-31'` é erro no MySQL 8.4
("Incorrect DATE value", 1525) e no PostgreSQL. Ou seja: desde 21/09/2026, quando o D189
pôs a faixa em `selecionarPorColaboradorData` e `selecionarPorUnidadeData`, uma data
impossível deixou de ser erro e passou a ser **outro dia** — o oposto de "muda a forma,
não o resultado". Os chamadores que mandam texto do pedido por `DataHora::stringDate()` —
que monta 'AAAA-MM-DD' por posição, sem conferir o calendário — alcançam isso: a **grade**
da agenda antiga (`AgendasController::getAgenda` → `GradeDaAgendaService::montar` →
`selecionarPorColaboradorData(..., stringDate(...))`, `GradeDaAgendaService:89`), pelo
`datasolicitada` ou `data` do POST nas ações que não passam pelo `diaQueSeVe`
(`atualizar`, `fechar`, `abrir`, `agendar`, `desmarcar`, `checkin`); os horários do
colaborador na mesma tela (`AgendasController:527`, `609` e `731`); o aplicativo
(`AplicativoController::agendaSelecionarAction`, linha 347); e, desde este conserto, o
check-in (`selecionarAgendaCliente`, `AgendasController:956`) e a comanda aberta pela agenda
(`servicosMarcadosPorClienteData`, `VendasController:307`). A `AgendasController:663`, que
uma versão anterior deste parágrafo listava, **não** alcança: ela passa
`dateToString3($agenda->getData())`, uma data já gravada. As APIs novas
(`Adm\AgendasController`, `Adm\AgendamentosController`) conferem com `checkdate` antes e
também não chegam aqui. Se algum pedido de verdade chegou com uma data assim, **não foi
medido**. E o check-in (`selecionarAgendaCliente`), que agora
também passa pela faixa, inverteria os agendamentos do cliente nesse outro dia — por
isso a conferência entrou antes da troca, e não depois.

**O conserto.** `diaInteiro()` lê `getLastErrors()` e trata aviso ou erro como data
ilegível: `InvalidArgumentException`, como já fazia com o que não se lia. É o
resultado de antes (erro), e a regra que o próprio método já escrevia — "data ilegível
não vira silêncio".

**Provado:** `DiaDoClienteNaAgendaTest::testODiaQueNaoExisteNoCalendarioRecusa` planta
um horário em 3 de março e pede o check-in de 31 de fevereiro; com a conferência
arrancada (só `$inicio === false`), o caso fica vermelho no MySQL e no PostgreSQL.

### D426 — A comanda aberta pela agenda recebe os serviços na ordem que o banco escolher: no PostgreSQL ela muda com o plano — REGISTRADO em 06/10/2026; a pergunta foi ao lote — PARCIAL em 07/10/2026: a comanda tem a ordem escrita; o desempate voltou ao lote — CORRIGIDO em 08/10/2026

Achado em 06/10/2026 pela prova de equivalência do fim do D189, e não procurando por ele.
Trocados os seis `DATE(age.data)` pela faixa, os seis métodos devolvem **os mesmos ids**
que antes em todas as 24.790 chamadas da amostra, nos dois bancos. A **ordem** é a mesma
em todas no MySQL; no PostgreSQL, muda em 36.

**De onde vêm as 36.** Oito são empates do `ORDER BY age.data` — dois horários do mesmo
cliente na mesma data e hora — em `horariosMarcadosPorClienteData` (no dia pedido, a forma
que nenhum chamador usa: 6) e `agendaPorClienteUnidadeData` (2). As outras 28 são de
`servicosMarcadosPorClienteData`, que **não tem `ORDER BY` nenhum**.

**Não é acaso de execução: é o plano.** Nas 28 chamadas da comanda que mudaram, repetidas
duas vezes em cada forma, cada forma devolveu sempre a mesma ordem (28 de 28) e nunca a da
outra (0 de 28). No `EXPLAIN`, a forma antiga estima 19 linhas no índice de cliente e o
PostgreSQL entra pelos produtos da unidade; a nova estima 1, e ele entra pela agenda. Sem
`ORDER BY`, a ordem é a da tabela por onde o plano entra.

**E o MySQL de produção tem uma ordem, que ninguém escreveu.** Na mesma amostra, as 845
chamadas da comanda com dois serviços ou mais vieram, todas, **na ordem do id do
agendamento** — e só 675 delas na ordem da hora (`data`, depois `id`). Nas 28 que mudaram no
PostgreSQL, a ordem do MySQL é a da forma nova em 12 e a da antiga em 16: nenhuma das duas
formas reproduz a produção. Quem migrar sem escrever a ordem herda a do plano.

**Por que importa: o dinheiro.** `VendaService::comandaAbrirAgenda` percorre os serviços
nessa ordem e, para cada um, pergunta se o cliente ainda tem sessão do pacote daquele
produto (`selecionarQuantidadeServicoDisponivel`). Com dois horários do mesmo serviço em
barbeiros diferentes, a ordem decide **qual item sai como sessão do pacote** (valor zero) e
qual sai pago — e a comissão vem do item. No `agendaPorClienteUnidadeData` (o fechamento do
pagamento marca a saída de todos) e no `horariosMarcadosPorClienteData` (a lista na tela), a
ordem dos empates é só aparência.

**O conserto provável, e por que ele não entrou junto:** escrever a ordem. A da produção
é `ORDER BY age.id` (845 de 845 na amostra) — acrescentá-la não mudaria nenhuma delas no
MySQL, e fixaria o PostgreSQL nela. A da agenda seria `ORDER BY age.data, age.id` (o primeiro horário do dia
gasta o pacote), e mudaria 170 das 845 no MySQL. Qual serviço gasta a sessão do pacote é
regra que o código nunca decidiu, e a pergunta foi ao lote de 06/10/2026, com a
recomendação de `age.id` (a produção de hoje, escrita). Nos três métodos com
`ORDER BY age.data`, o desempate por `age.id` é o do D72, e entra com a resposta.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 23): a opção (a), "A ordem em que os
horários foram marcados (o id)" -- a de hoje na produção, agora escrita: não muda nada no MySQL e fixa o
PostgreSQL nela. A implementação estava na fila. Entram juntos o `ORDER BY age.id` em
`servicosMarcadosPorClienteData`, o desempate por `age.id` nos três métodos ordenados só pela hora (o
D72) e um caso com dois horários do mesmo serviço. -- Em 07/10/2026 entraram a ordem da comanda e o caso;
o desempate, não (abaixo).

**O conserto da comanda, em 07/10/2026.** `servicosMarcadosPorClienteData` ganhou `ORDER BY age.id`:
a comanda aberta pela agenda recebe os serviços na ordem em que os horários foram marcados, nos dois
bancos.

**Medido na cópia antes de ficar, só com `SELECT` e só contagens** (o `franquiabv_dev` do MySQL e o do
PostgreSQL, este com o papel `bvip_app`, RLS e fuso `America/Sao_Paulo`), na mesma amostra do D189 --
369 dias, 4.059 triplas (cliente, unidade, dia) --, o repositório do HEAD contra o de hoje:

| método | chamadas | com 2+ linhas | MySQL: ordem mudou | PostgreSQL de hoje × MySQL de hoje: ordem difere |
|---|---:|---:|---:|---:|
| `servicosMarcadosPorClienteData` (as raízes) | 4.059 | 413 | **0** | **0** |
| `servicosMarcadosPorClienteData` (todos) | 4.059 | 432 | **0** | **0** |

As 845 com dois serviços ou mais são as do registro acima, e nenhuma mudou de ordem no MySQL; os ids são
os mesmos em todas, nos dois bancos.

**O desempate dos irmãos NÃO entrou, e voltou ao lote.** A resposta trazia junto o `, age.id` nos métodos
ordenados só por `age.data`. São quatro, e não três como este registro e a pergunta diziam:
`horariosMarcadosPorClienteData`, `selecionarAgendaCliente`, `selecionarProximoAgendamentosCliente` e
`agendaPorClienteUnidadeData`. Com o desempate, na mesma amostra, a ordem mudava em **136 chamadas no
MySQL** (5, 13, 113 e 5) -- e a decisão partia de "não muda nada no MySQL". Medido de novo sem ele: das
840 chamadas com dois horários do cliente na mesma data e hora, o MySQL de hoje devolve 704 na ordem do id,
22 na inversa e 114 em nenhuma das duas; e o PostgreSQL, sem desempate, discorda do MySQL em 383 delas. Não
há ordem escrita que reproduza a do MySQL. O código voltou ao do HEAD nos quatro, e a pergunta foi ao lote
de 07/10/2026, com a recomendação de desempatar pelo id (só a aparência de dois horários no mesmo minuto
muda). Os dois métodos da grade (`selecionarPorColaboradorData` e `selecionarPorColaboradorDataHora`)
também ordenam só por `age.data`; não são o dia do cliente, não entraram na pergunta nem na medida, e ficam
como estavam. O `conferir-ordenacao-empates.php` não os vê: lê só listagens paginadas.

**A premissa do dinheiro não vale, e virou o D435.** Este registro dizia que, com dois horários do mesmo
serviço, a ordem decidia qual item saía como sessão do pacote e qual saía pago. Hoje não decide: a
comanda pergunta "há sessão?" item a item contra o saldo gravado, que só cai no finalizar, e com uma sessão
os **dois** itens saem como sessão. A ordem do id passa a decidir isso só se o dono escolher descontar as
sessões já lançadas na mesma venda (o D435, em pergunta).

**Provado** em `D426OrdemDosServicosDaComandaTest`, 4 casos verdes no MySQL e no PostgreSQL: o cliente do
pacote da fixture com dois horários do mesmo serviço no dia, em barbeiros diferentes -- o marcado primeiro
às 15h, o marcado depois às 10h e gravado antes na tabela, para que a ordem do id discorde da hora e da
gravação. A consulta devolve o marcado primeiro antes, nas duas formas; a comanda aberta pelo serviço de
verdade tem como primeiro item o barbeiro dele; com uma sessão, os dois itens apontam para o pacote e valem
zero (o fato do D435); e o SQL que chega ao banco termina em `ORDER BY <raiz>.id ASC`.

**Falsificado**, um mutante por vez, restaurado e conferido pelo sha256:

| mutante | MySQL | PostgreSQL |
|---|---|---|
| sem o `ORDER BY age.id` | só o caso da forma -- os de resultado passam, porque é a ordem que o MySQL já dava | os três da comanda e o da forma |
| `ORDER BY age.data, age.id` (a opção "b") | os três da comanda e o da forma | os três da comanda e o da forma |

**Decidido pelo dono em 07/10/2026** (pergunta 46 da página do lote): a opção (a), "Desempatar pelo id"
-- "Os dois bancos dão a mesma ordem, a de quem foi marcado primeiro." Os métodos que ordenam os horários
do cliente só pela hora ganham o desempate pelo id. A implementação está na fila (outro item); nada mudou
no código neste. E na pergunta 45 (o D435), a opção (a), "A comanda e o PDV descontam as sessões já
lançadas na mesma venda": a ordem do id passa a decidir qual item sai como sessão do pacote.

**O conserto do desempate, em 08/10/2026** (o item AGENDA-SEGUNDA-RODADA). Os quatro métodos que listam os
horários do cliente só pela hora passaram a `ORDER BY age.data, age.id`: `horariosMarcadosPorClienteData` (o
modal "HORÁRIOS MARCADOS", pelo dia e de hoje em diante), `selecionarAgendaCliente` (o check-in da tela
antiga), `selecionarProximoAgendamentosCliente` (o aplicativo e a API V4) e `agendaPorClienteUnidadeData` (o
finalizar marca a saída). Para quem usa, muda só a ordem de dois horários do cliente no mesmo minuto: a de
quem foi marcado primeiro, nos dois bancos.

**A lista saiu do código, e não da memória.** O `grep` dos `ORDER BY` sobre a agenda em `module/` acha nove
(sem os comentários; o HEAD também tinha nove), todos no `AgendaRepository` -- nenhum SQL cru sobre `agendas`,
nenhum `findBy` ordenado. Dos nove, os quatro acima são os do cliente só pela hora; `servicosMarcadosPorClienteData` já é `age.id` (07/10),
`selecionarPorUnidadeData` já é `hora, id`, `parceiroAgenda` já desempata, e os dois da grade
(`selecionarPorColaboradorData` e `selecionarPorColaboradorDataHora`) são do colaborador, ficaram fora da
pergunta e não mudaram. (A primeira versão deste parágrafo dizia "oito", com a mesma lista; a verificação do
item contou de novo.)

**Provado** em `D426DesempateDosHorariosDoClienteTest`, 3 casos verdes no MySQL e no PostgreSQL: o cliente
90001004 com dois horários às 10h do mesmo dia, em barbeiros diferentes, o marcado primeiro gravado depois
(no dia livre do `DiaLivreNaAgenda`). As cinco chamadas devolvem o marcado primeiro antes; com ele uma hora
mais tarde, a hora manda; e o SQL que chega ao banco termina em `ORDER BY <raiz>.data ASC, <raiz>.id ASC` nas
cinco. O caso do `D426OrdemDosServicosDaComandaTest` que dizia o fato do D435 mudou junto com ele (abaixo,
no D435).

**Falsificado**, um mutante por vez, restaurado e conferido pelo sha256:

| mutante | MySQL | PostgreSQL |
|---|---|---|
| sem o `, age.id` nos quatro | só o caso da forma: neste cenário o MySQL já devolve a ordem do id | o da forma e o do mesmo minuto, nas cinco chamadas |
| sem o `, age.id` só em `horariosMarcadosPorClienteData` | o da forma | o da forma e o do mesmo minuto, só nas duas chamadas dele |
| só em `selecionarAgendaCliente` | o da forma | o da forma e o do mesmo minuto, só na chamada dele |
| só em `selecionarProximoAgendamentosCliente` | o da forma | o da forma e o do mesmo minuto, só na chamada dele |
| só em `agendaPorClienteUnidadeData` | o da forma | o da forma e o do mesmo minuto, só na chamada dele |

**Não medido:** a amostra da cópia de novo, com o código novo (as 136 listas que mudam no MySQL e as 383 em
que o PostgreSQL discordava vêm da medida de 07/10, feita com o mesmo `, age.id`).

### D427 — O total de agendamentos do mês por colaborador pergunta `MONTH()` e `YEAR()` da data, e no PostgreSQL lê o histórico inteiro dele — MEDIDO em 06/10/2026 — CORRIGIDO em 08/11/2026: a consulta saiu com as mortas do D485, e as irmãs com chamador passaram à faixa do mês

Achado em 06/10/2026 pela revisão do POSTGRES-COMPAT (o fim do D189), e não procurando
por ele: o D189 falava em "seis `DATE(age.data)`", os seis foram trocados pela faixa, e no
mesmo arquivo ficou um irmão que nenhum registro citava — o próprio `AgendaRepository` o
usava como contraexemplo no comentário de `parceiroAgenda`.

**O sítio.** `AgendaRepository::totalAgendamentoPorMesAnoColaborador` filtra
`age.colaborador = :colaborador AND MONTH(age.data) = :mes AND YEAR(age.data) = :ano`. É o
par `(colaborador, data)` do índice do D189 — no PostgreSQL,
`agendas_FK_AGENDAS_colaborador (inquilino_id, colaborador, data)` —, e com a função em
volta da coluna a `data` não serve de chave. Quem chama é
`CronJobService::relatoriosDashboardColaboradoresAtualizar` (linhas 748 e 749): duas vezes
por colaborador (origem `app` e `web`), a cada rodada da rotina.

**Medido no PostgreSQL da cópia** (`franquiabv_dev`, papel `bvip_app`, RLS ativo, fuso
`America/Sao_Paulo`), só `EXPLAIN ANALYZE` de `SELECT`, no colaborador com mais linhas no
histórico (88.604) e no mês com mais agendamentos `web` dele:

| | linhas lidas no índice | páginas | tempo | total |
|---|---:|---:|---:|---:|
| `MONTH()`/`YEAR()` (a tradução de hoje: `CAST(EXTRACT(...) AS integer)`) | 88.604 (o histórico inteiro) | 28.664 | 91 ms | 591 |
| a faixa `[1º do mês, 1º do mês seguinte)` | 646 | 246 | 0,4 ms | 591 |

Uma medida só, de um colaborador; o tempo da primeira carrega o cache frio, e por isso o
número que vale é o das páginas. No MySQL não foi medido: lá o índice ainda é só de
`colaborador`, e o `ALTER` do D189 espera a janela.

**Por que não entrou junto com os seis.** Não era um deles, e a rotina não muda de classe
de custo trocando só ele: no mesmo laço, por colaborador, ela chama
`VendaProdutoRepository::totalServicosVendidosPorMesAnoColaborador` e
`::totalProdutosVendidosPorMesAnoColaborador`, `VendaRepository::selecionarTotalClientes` e
`::selecionarTotalVendas` e `ClienteRepository::fidelizacao` — todos com `MONTH()`/`YEAR()`
sobre `ven.dataCriacao`, `cli.dataCriacao` ou `cli.ultimaVisita`. E cada troca pede o que os
seis tiveram: a equivalência na cópia, nos dois bancos, e casos de fronteira.

**O tamanho da família, contado e não lido.** No `module/`, 117 linhas de código (fora
comentário) põem `DATE()`, `MONTH()`, `YEAR()`, `DAY()` ou `DAYOFWEEK()` em volta de uma
coluna, em 25 repositórios — 36 no `VendaRepository`, 19 no `ClienteRepository`, 9 no
`VendaProdutoRepository`; na agenda, só esta. Nem todas viram faixa: `MONTH()`/`DAY()` de
`dataNascimento`, `dataContratacao` e `dataInauguracao` perguntam o dia do ano (o
aniversário), e não um período. Quais estão em coluna com índice, e quais pesam, **não foi
medido**.

**O conserto provável deste:** a faixa do mês, como o `diaInteiro()` faz com o dia
(`[AAAA-MM-01 00:00:00, primeiro do mês seguinte)`, aritmética em UTC), com a equivalência
medida na cópia — inclusive os meses do horário de verão, porque, se uma troca caiu num
dia 1º, a meia-noite dele não existe no fuso da sessão do PostgreSQL, como nos dias que a
amostra do D189 incluiu — e casos de fronteira (o último segundo do mês, a meia-noite do
dia 1º) nos dois bancos.

#### CORRIGIDO em 08/11/2026 -- a consulta saiu (o D485), e as irmãs com chamador passaram à faixa do mês

**A fatia 125 da fila** (CONSULTAS-MORTAS-E-PORTOES-DE-DQL). O sítio deste débito, `AgendaRepository::totalAgendamentoPorMesAnoColaborador`,
não tinha chamador desde 13/10/2026 (a rotina das 23:30 saiu com o Dash. Colaboradores, a resposta 137 do dono) e **saiu do
código** com as do D485. O comentário de `parceiroAgenda` que o citava como contraexemplo diz agora "o antigo".

**As irmãs que o corpo citava**, "no mesmo laço": os dois de `VendaProdutoRepository` saíram também (o D485), e a
`ClienteRepository::fidelizacao` já tinha saído (25/10/2026). As duas de `VendaRepository` que **ainda têm chamador** --
`selecionarTotalClientes` e `selecionarTotalVendas` -- moram em outra rotina viva, a dos atendimentos das 02:10
(`CronJobService::relatoriosAtendimentosAtualizar`), que as chama por colaborador e mês junto com
`selecionarServicosProdutosVendidos` e `selecionarCategoriaServicosProdutosVendidos`, e por ano com
`UsuarioRepository::selecionarUsuariosComVendas`. Pelo argumento deste corpo (trocar só uma não muda a classe de custo da rotina),
**as cinco passaram à faixa**: `DataHora::faixaDoMes($mes, $ano)` e `::faixaDoAno($ano)`, `[1º do mês, 1º do seguinte)` em texto, sem
`DateTime` e sem fuso, no molde do `diaInteiro()`; o "cliente novo" de `selecionarTotalClientes` também (`cli.dataCriacao`). A
`selecionarTotalClientesUnidade` (a rotina do Dash. Faturamento) e as duas de `pro.tipo = 'ser'` por unidade ficaram como estavam:
outra rotina, e a A4 de `spec-atendimentos.md` aposenta as duas rotinas depois do corte.

**A equivalência, medida na cópia** (`franquiabv_dev`, só `SELECT`; o PostgreSQL no fuso `America/Sao_Paulo`, onde as duas colunas
são `timestamptz`): mês a mês, a contagem pela função e pela faixa, em todo o histórico -- **136 meses de `vendas` (2.269.254 linhas
no MySQL, 2.269.221 no PostgreSQL) e 135 de `clientes` (242.597 nos dois), nenhum mês diferente**, nenhuma data nula. As trocas do
horário de verão do Brasil desde 2015 caíram em domingos de outubro e novembro, nunca num dia 1º, e os meses delas estão na conta.

**As fronteiras, que a cópia não tem de propósito:** o `D427FaixaDoMesNosAtendimentosTest` planta, em 2031 (sem fixture nenhuma),
vendas no último segundo da véspera, na meia-noite do dia 1º, no último segundo do mês e na meia-noite do dia 1º seguinte, e
cadastros de cliente nas mesmas pontas, e afirma as somas (potências de dez, que dizem quais entraram), as contagens, o ano pelas
duas pontas e que nenhuma das cinco consultas manda `MONTH(`, `YEAR(` ou `EXTRACT(` ao banco. Os mutantes, nos dois bancos: F1 (`<=`
no fim do total), F2 (`>` no início dos serviços), F3 (dezembro terminando no dia 31 às 00:00), F4 (`<=` no cliente novo), F5 (`<=`
no fim do ano) vermelhos pelas somas e contagens; F6 (a categoria de volta a `MONTH()`/`YEAR()`, equivalente) vermelho só pelo
caso da função na data -- no PostgreSQL, também como erro do `MONTH()` sobre o parâmetro de texto.

**Não medido:** o tempo da rotina antes e depois. Pelo plano, o MySQL passa a poder usar o `idx_vendas_data_status` e o PostgreSQL o
`(inquilino_id, data_criacao, status, comanda_temp)`; se usam, com o colaborador como entrada pelo `vendas_produtos`, é o
`EXPLAIN` que diria. **Fica fora** a família do parágrafo "o tamanho da família" (as 117 linhas com função na data em 25
repositórios): não era este sítio, e cada uma pede a sua equivalência.

### D428 — Abrir as vendas realizadas consolida o NPS da noite dentro do GET — REGISTRADO em 06/10/2026 — PARCIAL em 06/10/2026: nunca mais a rede; a unidade escolhida espera o dono — PARCIAL em 08/10/2026: a regra está dada; falta o texto de dois comentários — CORRIGIDO em 11/10/2026

**Achado pelo terceiro sinal do inventário** (o D199, "PARCIAL em 06/10/2026"): `vendas/indexVendas`
passou a sair como ação que muta, por `CronJobService::npsContabilizar`. Lido no código, não medido por
HTTP.

- **O que acontece:** `VendasController::indexVendasAction` -- a lista de vendas realizadas, um GET --
  chama `$this->sm->get(CronJobService::class)->npsContabilizar($request['unidade'])` antes de montar a
  tela (`module/ISVenda/src/Controller/VendasController.php:75`). É a rotina da noite: consolida as
  pesquisas de NPS ainda não contabilizadas, grava `nps_resultados_unidades` e
  `nps_resultados_colaboradores` e marca a pesquisa como contabilizada.
- **A unidade vem do filtro para o administrador.** O campo tem a opção vazia ("Selecione"); com ela,
  `npsContabilizar('')` não filtra unidade (`if (!empty($unidadeId))`) e consolida as pendentes da REDE
  dentro do pedido de quem abriu a lista.
- **A trava é a mesma da rotina** (`TravaDeExecucao::obter('npsContabilizar')`): se a lista a segura
  quando a rotina da noite começa, a rotina desiste daquela rodada -- é o que o
  `CronTravaSimultaneaTest` prova da rotina contra ela mesma.
- **É a família do D199, do D264 (cupons) e do D334 (sorteios):** escrita dentro do GET de uma lista. A
  diferença é o tamanho do que roda.

**O que não foi feito:** nada mudou. Tirar a chamada muda quando o NPS aparece consolidado na tela (hoje,
ao abrir a lista; sem ela, depois da rotina), e isso é decisão de quem usa: a pergunta foi ao lote, com
as opções. Antes de mexer, medir quantas pesquisas pendentes a rede tem num dia e quanto o pedido leva
com a opção vazia.

**Decidido pelo dono em 06/10/2026** (lote de perguntas, pergunta 17): a opção (b), "Manter, mas só para
a unidade de quem abre" -- o gerente continua vendo o NPS na hora, e o administrador com a unidade em
branco não dispara a consolidação da rede. A implementação está na fila. Antes dela, a medida que o lote
pedia: quantas pesquisas ficam pendentes num dia, e quanto o pedido leva com a unidade em branco.

#### PARCIAL em 06/10/2026: nunca mais a rede; a unidade escolhida espera o dono

**O conserto.** `indexVendasAction` chama `npsContabilizar` sempre com UMA unidade: a do filtro, ou,
com ele em branco, a da SESSÃO -- e não chama quando não há nenhuma das duas (a rotina sem unidade é a da
rede, e quem a roda é o cron). Para quem não é administrador nada muda: a unidade do filtro já era a da
sessão, e o gerente continua vendo o NPS consolidado ao abrir a lista. O administrador com a unidade em
branco -- o caso do débito -- consolida a unidade da sessão dele, e não mais a rede. O administrador que
escolhe outra unidade continua consolidando a escolhida, como antes: é o caminho que espera o dono
(abaixo). A rotina não é da noite: roda de dez em dez minutos (`docs/f1-6-agendamento.md`), e segue
consolidando a rede (`CronJobController::npsContabilizar`, sem unidade).

**A medida que o lote pedia**, na cópia, só `SELECT`, sem dado pessoal: 37.957 pesquisas, **nenhuma
pendente**. Nos 90 dias até a última pesquisa da cópia (16/08/2026), a rede recebeu 9,9 pesquisas por
dia em média e 30 no pico; uma unidade, no máximo 10 num dia; e numa janela de dez minutos -- o
intervalo da rotina -- a rede inteira, no máximo 6. Os administradores ativos são de três unidades (1, 7
e 59), duas delas lojas com pesquisas (4.519 e 514). **O tempo do pedido não foi cronometrado:** com zero
pendentes, a consolidação na lista custa uma consulta e o pedido da trava, com a unidade em branco ou
não; medir o caso cheio pediria gravar pendentes no banco.

**A trava.** A lista e a rotina usam a mesma (`TravaDeExecucao::obter('npsContabilizar')`), e a lista a
segura pelo tempo de consolidar as pendentes que ela pega. **Em nenhum caminho por mais tempo do que
antes:** para quem não é administrador e para o administrador que escolhe uma unidade, a mesma unidade
de antes; para o administrador com a unidade em branco, a rede virou a unidade da sessão dele, um
subconjunto. A primeira versão deste conserto, na mesma noite, mandava a unidade da sessão também quando
o administrador escolhia outra -- uma unidade no lugar de outra, que podia segurar a trava por mais
tempo, se a dele tivesse mais pendentes, e que decidia pelo dono um caminho que a resposta não cobria. A
verificação a devolveu, e esse caminho ficou como era.

**Provado:** `D428NpsDaListaDeVendasTest`, 5 casos nos dois bancos, com a lista despachada em processo
como GET (`ControllerEmProcesso`) e duas pesquisas pendentes no mês corrente, uma da unidade da sessão e
uma de outra: o administrador com a unidade em branco deixa a da outra na fila e o resultado do NPS dela
intacto, e consolida a da sessão; o que escolhe a outra unidade consolida só a escolhida, como antes (o
caso fixa o que vale até a resposta do dono); o gerente consolida a dele na hora (o controle do
"manter"); sem unidade na sessão nem no filtro -- ausente ou em branco -- nada; e a rotina, sem unidade,
consolida as duas. Mutantes, um de cada vez, restaurados e conferidos por sha1: a chamada de antes, com
a unidade do filtro tal como veio e sem guarda (2 vermelhos: a unidade em branco e a sessão sem
unidade); sempre a unidade da sessão, a leitura da primeira versão (1, o da unidade escolhida, também no
PostgreSQL); o filtro sem cair na sessão quando em branco (1); sem a guarda de unidade nenhuma (1); e
sem a chamada (3, o do gerente inclusive). O rollback desfaz o que a rotina grava no banco; fora dele,
cada consolidação que conta alguma pesquisa acrescenta uma linha a `data/logs/log_scripts.txt`
(`IdealizeUtil::logScripts`) -- arquivo no .gitignore, só com a contagem, sem dado pessoal.

**O que falta, e espera o dono:** "a unidade de quem abre", na resposta 17 (b), foi aplicada ao caso que
a pergunta descrevia -- "o administrador sem unidade não dispara a rede" --: com o filtro em branco, a da
sessão. O administrador que ESCOLHE outra unidade no filtro não estava na pergunta, e a resposta pode ser
lida dos dois jeitos (a escolhida, que é a que a tela mostra, ou sempre a da sessão). Ficou como era -- a
escolhida -- e a pergunta foi ao lote. Trocar é uma linha (`$unidadeDoNps`), e o caso
`testOAdministradorQueEscolheOutraUnidadeConsolidaAEscolhidaComoAntes` muda junto.

**Decidido pelo dono em 07/10/2026** (pergunta 44 da página do lote): a opção (a), "A loja escolhida,
como está" -- "Ele vê o NPS dela na hora, e a lista nunca segura a trava da rotina por mais tempo do que
já segurava." O caminho que esperava o dono fica como o código já faz: o administrador que escolhe outra
unidade consolida a escolhida. Nada a mudar no código, e
`testOAdministradorQueEscolheOutraUnidadeConsolidaAEscolhidaComoAntes` passa a fixar a regra, e não mais
o que vale até a resposta (verde nos dois bancos em 08/10/2026, com os outros quatro do arquivo). Com isso
o comportamento está todo decidido -- a rede nunca, a unidade da sessão com o filtro em branco, a escolhida
quando há escolha.

**O que resta, e é o PARCIAL do título (a revisão de 08/10/2026):** o texto de dois comentários, que ainda
dizem que a escolha espera a resposta -- o de `VendasController::indexVendasAction` ("ESSE ULTIMO CAMINHO
ESPERA O DONO ... a pergunta esta no lote") e o docblock de
`testOAdministradorQueEscolheOutraUnidadeConsolidaAEscolhidaComoAntes` no `D428NpsDaListaDeVendasTest`
("Este caminho ESPERA O DONO ... O caso fixa o que vale ate a resposta"). Não é comportamento, mas é o
que o próximo leitor do código lê, e diz o contrário deste registro. O item das decisões de 07/10/2026 não
mudava código, e os dois ficaram; com eles reescritos ("a escolhida, pela resposta 44 do dono"), o título
fecha. A primeira versão deste parágrafo dava o débito como FECHADO e a sobra como "de texto": a revisão
a devolveu, porque o que fica de pé no corpo pede PARCIAL. **E o tempo do pedido não foi cronometrado**
(acima): com zero pendentes na cópia, não havia o que medir, e a resposta 44 não depende dele -- a lista
não segura a trava por mais tempo do que já segurava, em caminho nenhum.

#### CORRIGIDO em 11/10/2026 -- o item RESPOSTAS-SEM-RODADA

**O conserto,** só texto, como a revisão de 08/10/2026 pediu: os comentários que diziam que a unidade
escolhida esperava o dono dizem "a escolhida, pela resposta 44 do dono" -- o de
`VendasController::indexVendasAction` e o docblock de
`testOAdministradorQueEscolheOutraUnidadeConsolidaAEscolhidaComoAntes`; e um terceiro que a revisão não
listou, o docblock da classe `D428NpsDaListaDeVendasTest` ("esse caminho espera a confirmação do dono, e
o caso dele fixa o que vale até lá"). Nenhum comportamento muda, e nenhum teste: a regra é a que o código
já fazia. Conferido por `grep`: nenhum "espera o dono" sobre o D428 ficou no código. O tempo do pedido
continua não cronometrado, pelo motivo acima.

### D429 — Duas abas no PDV, e a segunda apaga o rascunho da primeira — REGISTRADO em 06/10/2026 — CORRIGIDO em 07/11/2026 (a fatia 111: a aba antiga que lança no id apagado é recusada com frase, sem criar venda) — PARCIAL em 07/11/2026 (a verificação da 111): a aba antiga é recusada com frase em todas as portas, mas abrir o PDV ainda apaga a venda da outra; a escolha foi ao lote — CORRIGIDO em 07/11/2026 (a fatia 131, a resposta 359 (b)): abrir o PDV pergunta antes de descartar a venda com item, e o descarte é por POST

**Tirado do "O que ficou" do D316**, onde estava desde 01/10 sem número: a revisão do item
PORTOES-E-INSTRUMENTOS apontou que nenhum débito o tinha assumido, e que o "CORRIGIDO" do título do D316
ficava desmentido pelo corpo. Lido no código, não observado.

- **O que acontece:** `VendasController::novoAction` começa apagando as vendas temporárias de quem abre
  (`VendaRepository::apagarVendaTemporaria`: `DELETE ... WHERE ven.usuario = :usuario AND ven.status IS
  NULL`). Com duas abas do mesmo usuário, a segunda abre e apaga a temporária da primeira; a primeira
  segue mandando o id apagado, e o lançamento de item com um id que não existe cria outra venda.
- **Não observado:** na cópia de produção, as 16 vendas temporárias são de 16 usuários diferentes --
  nenhum usuário com duas.
- **É a corrida do D321** (o finalizar repetido e a aba antiga), na outra ponta da venda: o começo.

**O que faltava (até 07/11/2026):** reproduzir com duas abas e ver o que fica no banco antes de decidir se a
primeira aba devia ser recusada ou recuperar a venda.

**Recusado em 07/11/2026 (a fatia 111, PDV-ANTIGO-ABAS-E-CONVENIO); PARCIAL pela verificação da fatia.** Reproduzido EM
PROCESSO, e não no navegador (o coordenador não o abre nesta fila): as duas abas são os dois despachos do mesmo usuário, na
ordem do navegador -- o `GET /vendas/novo` da aba nova e o `POST /mod-vendas/vendasProdutos/adicionar` com os campos do
`pdvCarrinhoAdicionarProduto`.

**O que ficava no banco.** A aba A lança dois cortes (a venda V1, 179,80); a aba B abre e apaga V1 com os dois itens (os
itens e os pagamentos vão junto, pelo `ON DELETE CASCADE`); a aba A lança o terceiro com o `venda_id` de V1, o `find` dá
nulo, e o `insert` de baixo cria **outra venda em andamento, só com este item** -- a resposta diz `sucesso` com o id novo, e
a tela troca o `#venda_id` calada. Com a venda da aba B já aberta, o mesmo usuário ficava com **duas** vendas em andamento,
e as portas que procuram "a venda em andamento de quem pede" (`selecionarVendaTemporariaPorUsuario`) só veem a mais antiga.
**Visto no banco** com o mutante M1b (a recusa arrancada), nos dois bancos: o caso 1 cai mostrando a venda nova com 1 item
em andamento, e o caso 2 as duas vendas em andamento, cada uma com 1 item. (Na primeira rodada da fatia o M1 caía antes,
na resposta, e o banco não era contado; a verificação apontou, e os dois casos passaram a conferir o banco ANTES da
resposta.) **Lido no código, e não visto:** a tabela da tela sairia vazia, porque é montada pelo id apagado
(`tabelaProdutos` com o `venda_id` do pedido). Não há outro jeito de o id sumir: o `apagarVendaTemporaria` é a única
escrita do `module/` que apaga venda -- e ele apaga por USUÁRIO, não por sessão: o mesmo login aberto em dois balcões faz o
mesmo.

**O que foi feito: a aba antiga é recusada, como a do D321.** Recuperar a venda não existe depois do `DELETE`. O
`venda_id` que veio no pedido e não existe é recusado ANTES de qualquer escrita, com a frase
`RegraDaVendaNoPdv::FRASE_DA_VENDA_DESCARTADA`: *"Esta venda não existe mais: o PDV foi aberto de novo com o seu usuário
(em outra aba, janela ou computador), e cada abertura descarta a venda em andamento, com os itens desta tela. Continue
onde o PDV foi aberto por último. Atualizar esta página descarta também a venda em andamento de lá."* A tela já mostra a
`mensagem` da recusa no aviso (`notificacao('warning', ...)`): o JavaScript não mudou. As portas:

- o lançamento de item (`VendasProdutosController::adicionarAction`), na primeira rodada;
- desde a verificação, as outras portas da aba antiga, que recusavam sem dizer por quê: tirar o item (`removerAction`;
  dizia "Erro ao remover item."), lançar pagamento e finalizar (`VendasPagamentosController`; diziam "Não foi possível
  localizar a venda.") e o "Quitar" (`pagarDebitosAction`; dizia "Erro ao localizar venda.");
- a venda de OUTRA unidade continua com a frase de cada porta, que não conta nada dela (o D237), e o item que não existe
  numa venda que existe continua com o "Erro ao remover item.". O caso do D320 (`D317A320RestosDoD316Test`, o finalizar
  de um id que não existe) passou a esperar a frase nova.

A frase da primeira rodada dizia "outra aba ou janela" (o `DELETE` é por usuário: o outro balcão não abriu aba nenhuma) e
oferecia "atualize esta página para começar de novo" -- e atualizar é outro `GET /vendas/novo`, que apagava, calado, a venda
da aba mais nova. A verificação apontou as duas coisas; a frase de agora não manda atualizar, e diz o custo.

**Provado** em `tests/Caracterizacao/D429DuasAbasNoPdvAntigoTest.php` (4 casos, verdes nos dois bancos): a aba antiga é
recusada com a frase e nenhuma venda nasce (o controle antes: na mesma aba, o segundo item vai para a venda do primeiro);
a venda da aba nova segue sozinha e inteira, e continua recebendo item; tirar o item, pagamento, "Quitar" e finalizar da
aba antiga dizem a frase e não gravam nada; a venda de outra unidade segue com a frase de antes. Mutantes, um por vez,
nos dois bancos, devolvidos pelo sha1: M1b (a recusa do lançamento arrancada) derruba os casos 1 e 2 no banco; M2 (a
abertura sem o `apagarVendaTemporaria`, da primeira rodada) os três de então; M3 (a do tirar o item), M4a (a do
pagamento), M4b (a do "Quitar") e M4c (a do finalizar) derrubam o caso 3 na porta dela (o M4c também o do D320); M5 (o
`descartadaPelaOutraAba` que nunca acha) o caso 3 e o do D320; M6 (a frase nova também para a venda de outra unidade) o
caso 4.

**O que fica (e por isso PARCIAL).** O que o título diz continua: abrir o `/vendas/novo` apaga a venda em andamento do
mesmo usuário, também a da outra aba ou do outro balcão -- os itens e os pagamentos lançados nela se perdem (o dinheiro
não: o pagamento lançado só é gravado na venda, e o caixa só o soma no finalizar). E atualizar a aba antiga apaga a da nova. A recusa só tirou a
venda calada que nascia depois. Tirar o PAGAMENTO da venda apagada segue com "Erro ao remover pagamento.": a tela manda só
o id do pagamento, e sem ele e sem a venda não há de onde saber (caracterizado no caso 3). Parar de apagar na abertura (ou
perguntar antes) muda a saída que o D380 e o D433 escrevem nas frases deles ("recarregar o PDV descarta a venda em
andamento") e é escolha do dono: foi ao lote, "TELAS-RESTANTES [vendas] (07/11/2026) -- Abrir o PDV continua descartando a
venda em andamento de outra aba? (D429)". Na cópia de produção, as 16 vendas em andamento são de 16 usuários diferentes.
Não visto num navegador: a frase no aviso do PDV, com as duas abas abertas.

**Perguntado antes de descartar em 07/11/2026 (a fatia 131, PDV-PERGUNTA-ANTES-DE-DESCARTAR; a resposta 359 (b) do dono, o
que o título dizia).** A opção (b), à letra: *"ao abrir o PDV com uma venda em andamento que tem item, a tela pergunta
'Você tem uma venda em andamento com N itens, aberta em outra aba ou computador. Retomar ou descartar?'. Retomar abre a
mesma venda (as duas abas passam a mexer nela; a antiga continua sendo recusada se a venda for descartada depois);
descartar é o de hoje. A venda vazia é descartada sem perguntar."* O que mudou:

- **A abertura** (`VendasController::novoAction`) não chama mais o `apagarVendaTemporaria`. Ela apaga só a venda em
  andamento SEM item (`VendaRepository::apagarVendasTemporariasVazias`, com o `NOT EXISTS` dos itens no próprio `DELETE`,
  para que o item lançado por outra aba no meio não saia junto) e, se sobrar uma com item, desenha a pergunta
  (`venda-em-andamento.phtml`) no lugar do PDV, sem gravar nada. A frase é a da opção, pela regra pura
  (`RegraDaVendaNoPdv::perguntaDaVendaEmAndamento`); o N são as linhas do carrinho (uma por lançamento, com a quantidade
  dela), e com uma só a frase diz "1 item" -- o único desvio da letra, de gramática.
- **Retomar** é um link de leitura para o mesmo `/vendas/novo` com `?retomar=<id>`: abre a MESMA venda, com o cliente, os
  itens, os pagamentos já lançados (no modal do finalizar, com o "Falta" e o botão que a resposta do pagamento escolheria)
  e o estado do "Quitar" (o D380), montados pelo servidor como na comanda reaberta -- o corpo da `comandaAction` virou o
  ajudante `telaDaVendaAberta`, e os dois efeitos dela (apagar os pagamentos, refazer a pontuação) ficaram só nela: a
  retomada não grava nada. O `retomar` só vale para a venda que a pergunta mostrou (a em andamento de quem abre, a que
  `selecionarVendaTemporariaPorUsuario` acha); com outro id, a abertura pergunta de novo.
- **Descartar** é o `descartarAction`, só por POST (o GET é 404, a guarda que o `conferir-remocao-por-post.php` lê), que
  chama o `apagarVendaTemporaria` de antes e volta ao PDV vazio. O "Sim" do "Limpar" do PDV também passou a ser um
  formulário POST para ele (era um link para o `/vendas/novo`, que agora só abriria a pergunta). A barreira de origem da
  entrada do ERP recusa o POST de outro site.
- **O caixa de ontem** continua mandando ao caixa, e a venda com item fica para a pergunta da próxima abertura (até esta data
  ela era apagada antes do redirecionamento).
- **As frases.** A da aba antiga (`FRASE_DA_VENDA_DESCARTADA`) deixou de dizer "cada abertura descarta" e de avisar que
  atualizar descarta: *"Esta venda não existe mais: ela foi descartada no PDV aberto com o seu usuário (em outra aba, janela
  ou computador), com os itens desta tela. Continue onde o PDV foi aberto por último. Se atualizar esta página, o PDV
  pergunta antes de descartar a venda em andamento de lá."* As do D433 (as duas portas da comanda, `VendasController::
  comandaAbrirAction` e `Adm\ComandasController::abrirAction`) e a da resposta 79 (`RegraDosDebitosNaVenda::
  fraseDaOutraVenda`) trocaram "(recarregar descarta a venda em andamento)" pelo caminho de agora: "recarregue o PDV e
  escolha descartar a venda em andamento". O que elas protegem não mudou.
- **A tela nova** (`/novo`) não tem esse fluxo: o item "Nova" do menu abre a comanda nova (`CAMINHOS_DE_COMANDA` do
  `Console.tsx`), e nenhum `fetch` chama o `/vendas/novo`; só o clique do meio no menu leva ao PDV antigo, que agora
  pergunta.

**Provado** em `tests/Caracterizacao/D429DuasAbasNoPdvAntigoTest.php` (12 casos, verdes nos dois bancos), em processo, na
ordem do navegador: abrir com venda com item desenha a pergunta com o N e não apaga a venda, os itens nem o pagamento (o
banco conferido antes da página; a página desenhada pelo `ViewRenderer`, com o "Retomar" como link e o "Descartar" como
POST); retomar abre a mesma venda, sem apagar o pagamento, e as duas abas lançam nela, até uma descartar -- e a outra ser
recusada com a frase da 111; o retomar com outro id pergunta de novo; descartar por GET é 404 e não apaga; a venda vazia
é descartada sem perguntar; as duas consultas novas pelo repositório (a conta das linhas, e a das vazias apagadas, com a
com item poupada); o N na regra pura ("1 item", "3 itens"); a frase da aba antiga pelo texto. Os quatro casos da
111 passaram a abrir, ver a pergunta e descartar pelo POST, e continuam provando a recusa; o caso do D380 que recarregava
o PDV também (`D380QuitarComMarcaTest`), e os do D384 com a frase nova. Mutantes, um por vez, devolvidos pelo sha1: M1 (o
descarte de volta na abertura) derruba 7 casos nos dois bancos, e M4c (o `NOT EXISTS` arrancado) 8 (os 7 e o caso direto do
repositório, rodado de novo depois que ele entrou); M2 (o retomar abrindo o
PDV vazio) 2; M3 (o descartar aceitando GET) 1; M4a (a vazia que fica) 1; M5 (o N fixo) 1; M6 (a retomada apagando os
pagamentos, como a comanda) 1; M7 (o retomar sem conferir o id) 1 -- todos nos dois bancos; M4b (a regra perguntando na
vazia) e M11 (a frase velha) 1 cada, no MySQL; e, nos portões, M8 (o "Sim" do "Limpar" de volta a link) e M10 (o
"Descartar" como link) deixam o `conferir-remocao-por-post.php` em 1, e M9 (o nome do cliente sem escape) o
`conferir-escape-nas-views.php`, que passou a vigiar a pergunta. Os tocados e os vizinhos achados por `grep`, 1.363 em
cada banco; os portões 75 ok, com o `conferir-cobertura-por-par.php` de 456 para 458 pares (os dois do repositório, citados
no caso direto; tirando as duas chamadas dele, o "sem menção" sobe para 112 e o portão fica em 1), o
`conferir-remocao-por-post.php` com o `descartar` nas duas telas, e o inventário de ações regerado (330).

**O que fica.** Não visto num navegador: a pergunta, o "Retomar" com a venda montada pelo servidor (o painel do cliente, os
pacotes, a primeira visita, o modal com os pagamentos) e o "Limpar" por POST. Com duas vendas em andamento do mesmo usuário
(as de antes da 111, ou a corrida do primeiro item em duas abas), a pergunta é sobre a mais antiga, e o "Descartar" leva as
duas, como o descarte de sempre ("descartar é o de hoje") -- a outra some sem ter aparecido numa pergunta; se ela deve ficar
para a próxima foi ao lote, "TELAS-RESTANTES [vendas] (07/11/2026) -- Com duas vendas em andamento do mesmo usuário, o
"Descartar" da pergunta leva as duas? (D429)" (recomendada a (a), só a mostrada). O
administrador que troca de unidade na sessão vê a pergunta da venda dele, que é da unidade do usuário (`Venda::getUnidade`);
o que as portas fazem com ela retomada noutra unidade não foi medido (antes, a abertura a apagava).

**A verificação da fatia 131 (07/11/2026)** confirmou 8 achados (dois médios), consertados com caso ou registrados:

- **O "Descartar" de uma pergunta velha apagava a venda que ninguém viu** (médio). O mesmo login em dois balcões: o B vê a
  pergunta da venda 10; o A finaliza a 10 e lança a 11; o B clica "Descartar" e o POST apagava a 11, com item e pagamento.
  Agora o formulário leva o `venda_id` da venda mostrada (na pergunta, o id dela; no "Limpar", o `#venda_id` da aba, copiado
  no envio), e o `descartarAction` só apaga se ela ainda é a em andamento de quem pede (a que `selecionarVendaTemporariaPorUsuario`
  acha, a mesma da pergunta); se é outra, ou se a aba do "Limpar" não tinha venda, nada é apagado, e a abertura pergunta de
  novo com o aviso `RegraDaVendaNoPdv::FRASE_DA_PERGUNTA_VELHA` (`?mudou=1`; a frase vai direto nas mensagens da página, e
  não pelo `flashMessenger`, que a guardaria para a página seguinte). A leitura e o `DELETE` são dois passos: a venda que
  nascer entre eles, em milissegundos, ainda vai junto. O descarte com a venda certa continua "o de hoje".
- **A página do PDV vazio, a de todo dia, não era desenhada por caso nenhum** (médio), e a fatia pôs nela uns 15 trechos
  atrás do `$retomada`: o caso da venda vazia agora a desenha, e confere o título, a tabela vazia, os blocos escondidos e o
  "Limpar" como POST que copia o `#venda_id`.
- **A venda retomada na tela**: o par "Pagar o restante outra hora"/"Finalizar" pelo `fiado`, nos dois sentidos (com falta e
  paga por inteiro, com o valor sugerido vazio), o bloco dos débitos antes e depois do "Quitar" (o D380 na página da
  retomada: escondido, a bandeira ligada e o débito do painel vazio) e o aviso de comanda aberta do cliente no lugar do
  "Abrir comanda" -- desenhados e conferidos.
- **O caixa de ontem** manda ao caixa e não apaga a venda com item nem o pagamento: o caso entrou.
- **A venda em andamento sem cliente** pergunta só com o "Descartar", e o `?retomar` com o id dela pergunta de novo, em vez
  de cair no `telaDaVendaRetomada`: o caso entrou (o estado é montado à mão; a tela não o deixa nascer).
- **Duas vendas em andamento**: o caso fixa o de agora (a pergunta da mais antiga, o "Limpar" da aba da mais nova voltando à
  pergunta, o "Descartar" levando as duas), e a escolha foi ao lote (acima).
- **Os comentários que ainda diziam que recarregar o `/vendas/novo` apaga a venda, e que o `apagarVendaTemporaria` é a única
  escrita que apaga venda**: corrigidos em `VendasPagamentosController` (o "Quitar" de volta e o `descartadaPelaOutraAba`),
  `VendasProdutosController` (o lançamento e a remoção na venda apagada), `RegraDaVendaNoPdv` (a frase da aba antiga),
  `tests/Caracterizacao/ApagarVendaTemporariaTest.php`, `D380QuitarComMarcaTest` e, no front, o comentário do
  `frontend/src/telas/quitarDaTelaAntiga.teste.ts` (só o texto; o arquivo segue com 14 casos verdes, e o lint saída 0). O
  `comandas.ts` ("recarregar o PDV") segue verdadeiro. O "Limpar" da `comanda.phtml` continua um link para o `/vendas/novo`,
  mas nenhum botão da página abre aquele modal, e o link agora só abriria a pergunta.

O `D429DuasAbasNoPdvAntigoTest` foi de 12 para 20 casos, verdes nos dois bancos. Mutantes, um por vez, devolvidos pelo sha1,
vermelhos nos dois bancos: o descarte sem conferir a venda mostrada (3 casos), o "Limpar" sem copiar o `#venda_id`, a pergunta
sem o id escondido, o aviso que não sai, o PDV vazio sem a guarda no "Resgatar Pontuação" e no `$semCadastro` (a página cai), o
bloco dos débitos sem o "Quitar" de antes, os botões do modal invertidos (2 casos), o valor sugerido sempre a diferença, a
comanda do cliente que não chega à página, o caixa de ontem apagando a venda, a retomada sem conferir o cliente (cai no
`getConvenio`) e o "Retomar" sem conferir o cliente na página, e o "Limpar" da retomada sem o id da venda. O
`conferir-cobertura-por-par.php` passou as chamadas do ISVenda de 157 para 158 (o `descartarAction` lê a venda em andamento;
os pares não mudam).

### D430 — O passo 3 do corte apagava `estados`, e o ERP antigo monta dela o campo obrigatório de UF — PREPARADO em 06/10/2026

**Achado pela verificação do item DECISOES-NO-REGISTRO-E-NO-CORTE, e conferido no código e no
`franquiabv_dev`.** O dono decidiu (lote de perguntas, pergunta 10) rodar
`scripts/limpar-dicionario-cep.sql` no passo 3 do corte, e o roteiro apagava as quatro tabelas do
dicionário de CEP, `estados` junto. A justificativa escrita -- "nenhuma tabela fora das quatro aponta
para elas" -- vale para chave estrangeira, e só para ela.

- **Quem lê `estados`:** `EstadoRepository::popularCombobox`, e só dela. Ela monta o select "*Estado" de
  `ISCadastro\Form\Cliente` -- o cadastro e a edição (`ClientesController:96` e `:143`), o modal do PDV
  (`VendasController:147` e `:405`) e o da agenda (`AgendasController:218`) --, de `Fidelidade`, de
  `UnidadesController` (`:53`, `:88`, `:117`), de `UsuarioDados` e de `ISRelatorio\Form\Cliente`, que
  serve também o filtro das campanhas (`NotificacoesController:25`). O registro já tinha medido 20 telas
  que dependem dela (o aviso do `$dados = []`, no D100).
- **O que acontecia no primeiro dia:** com o país Brasil, o `ClienteFilter` exige o estado (`required` e
  `NotEmpty`); a busca de CEP (`cepBuscarEndereco`, `public/layout/scripts/geral.js`) faz
  `$("#estado").val(uf)` num select sem a opção, e o `EnderecoService::guardar` grava a UF no banco, mas
  a página aberta não a ganha. O primeiro cliente brasileiro de cada UF não se cadastrava pelo ERP antigo
  até alguém recarregar a página, e a edição de um cliente de UF ainda não consultada ia com o estado
  vazio e era recusada. A tela nova não sofria: ela usa `RegraDoCadastroDeCliente::UFS`.
- **A prova:** no `franquiabv_dev`, onde o esvaziamento rodou em 31/08, `SELECT uf FROM estados` dá 4
  linhas: RJ, SC, SP e ZY.
- **O conserto, no roteiro:** depois do `DELETE FROM estados`, o `INSERT` das 27 UFs de
  `RegraDoCadastroDeCliente::UFS`, com `SET NAMES utf8mb4` no começo (os nomes têm acento, e o padrão do
  cliente `mysql` de quem roda não é sabido). O `EnderecoService` acha a UF pelo `uf` e não cria outra.
  `php scripts/ensaiar-sql-do-passo-3.php` exige, depois do roteiro, 0 logradouros, bairros e cidades e
  as 27 UFs iguais às da constante, duas vezes seguidas, no 8.4.11 e no 8.0.46; com o roteiro de antes,
  sai com 1 ("estados ficaram com 0"). O passo 3 do procedimento e o D74 dizem isso.
- **Espera a confirmação do dono** (lote de perguntas, item DECISOES-NO-REGISTRO-E-NO-CORTE): repor as
  27, como está, ou não apagar `estados` -- as duas deixam o campo funcionando. Até a resposta, o roteiro
  repõe.

**O que falta:** o passo na janela; e o `franquiabv_dev`, que segue com 4 UFs no formulário antigo --
rodar o roteiro ali é escrita no banco de desenvolvimento, e não foi feita.

**Decidido pelo dono em 07/10/2026** (pergunta 34 da página do lote): a opção (a), "Apagar e repor as 27
UFs no mesmo roteiro" -- "A lista da tela nova; o ensaio do passo 3 confere as 27." Confirma o que já
estava feito: o roteiro repõe as 27 de `RegraDoCadastroDeCliente::UFS`, e o ensaio do passo 3 as confere
duas vezes seguidas (verde em 08/10/2026, no 8.4.11 e no 8.0.46). Nada mudou no roteiro; o passo 3 do
procedimento diz "confirmado" no lugar de "espera a sua confirmação". O que resta: o passo na janela, e o
`franquiabv_dev`, que segue com as 4 UFs (escrita no banco de desenvolvimento, não feita).

### D431 — O laboratório de migração, como a documentação o ensina, divide portas e `local.php` com a homologação do BVIP — REGISTRADO em 06/10/2026 — PARCIAL em 08/10/2026: a documentação e o modelo, num clone próprio; falta saber se ele já rodou do diretório da homologação

**Achado ao pôr o corte no comando do laboratório** (D360, 06/10/2026). Medido com `docker compose config`
(só leitura) e o `.env.homologacao.example`, que é o `.env` do BVIP; nada rodou no BVIP.

- **As portas.** O laboratório (`docker compose -p franquiabv-lab -f docker-compose.yml -f
  docker-compose.desenvolvimento.yml --profile fase1 up -d`, na `docs/ambiente-homologacao.md`) publica as
  MESMAS cinco portas da homologação com o perfil `fase1`: `127.0.0.1:8080`, `3308`, `8025`, `8081` e
  `5433`. O `.env` é um só, e o `-p` não muda porta. Com a homologação de pé, o `up` do laboratório deve
  morrer com "port is already allocated" (deduzido, não rodado).
- **O `local.php`.** Os dois apps montam o MESMO diretório (`.:/var/www/html`, medido no `config` dos
  dois). O passo "para a aplicação falar com o PostgreSQL" da seção copia o `local.postgres.php.dist` por
  cima do `config/autoload/local.php` -- e com isso a homologação também passa a apontar para o
  PostgreSQL.
- **Os comandos seguintes vão à homologação.** Os `docker compose exec` da seção (o
  `pre-migracao-limpeza.sql`, o `psql` do schema, o `phpunit`, a conferência do RLS) não têm `-p`, e o
  projeto sai do `name: franquiabv` do `docker-compose.yml` -- o da homologação. O `migrar-dados.sh`, o
  `criar-papeis.sh` e o `conferir-papeis.sh` também: o `PROJETO` deles vale `franquiabv` quando não é
  dado, e a seção não o dá. Como os bancos que eles pedem (`franquiabv_dev`) não são os da homologação
  (`franquiabv_homolog`), o mais provável é o erro, e não a escrita -- deduzido, não medido.

**Não se sabe** se o laboratório já rodou assim no BVIP. Se rodou, o `local.php` da homologação pode estar
apontando para o PostgreSQL.

**O conserto** (documentação e um modelo de `.env`; quem roda é o dono): o laboratório num clone próprio,
como o teste -- outro diretório, um `.env` com `COMPOSE_PROJECT_NAME=franquiabv-lab`, portas próprias no
loopback e `COMPOSE_FILE=docker-compose.yml:docker-compose.desenvolvimento.yml` (que traz o corte sem `-f`)
--, e a seção reescrita para rodar dele. O portão `conferir-provedores-no-loopback.php` passaria a ler o
modelo novo em vez da linha da documentação.

**Decidido pelo dono em 07/10/2026** (pergunta 37 da página do lote): a opção (a), "Reescrever para um
clone próprio" -- "Outro diretório, um .env modelo novo, portas próprias e o corte dos provedores; só
documentação e um modelo, e quem roda é você." A pergunta de fato -- se o laboratório já rodou assim no
BVIP -- ficou sem resposta: o campo veio vazio.

#### PARCIAL em 08/10/2026: a documentação e o modelo, num clone próprio

**O conserto**, só documentação e um modelo:

- **O modelo**, `.env.laboratorio.example`, no molde do `.env.example`: `COMPOSE_PROJECT_NAME=franquiabv-lab`
  (vence o `name: franquiabv` do `docker-compose.yml`), `COMPOSE_FILE=docker-compose.yml:docker-compose.desenvolvimento.yml`
  (o corte dos provedores, sem `-f`), e as sete portas no loopback e só dele: 8094, 8095, 8027, 3310,
  5435, 9004 e 9005. Os bancos são `franquiabv_dev`, o nome que os roteiros de carga e o
  `local.postgres.php.dist` esperam sem nada a mais; moram no MySQL e no PostgreSQL deste projeto.
- **A seção** "Laboratório de migração (Fase 1)" da `docs/ambiente-homologacao.md`, reescrita: o clone em
  `~/franquiabv-lab`, com o `.env` e o `local.php` dele; todo comando de dentro dele, sem `-f` nem `-p`;
  o `export PROJETO=franquiabv-lab` antes dos roteiros de carga (sem ele, `migrar-dados.sh`,
  `criar-papeis.sh`, `conferir-papeis.sh`, `conferir-migracao.php` e `ensaiar-corte.sh` falam com
  `franquiabv`, que no BVIP é a homologação); a tabela das portas das três pilhas; e como conferir se o
  `local.php` da homologação aponta para o PostgreSQL (`grep -c 'PDOPgSql'`, só a contagem). O
  `ensaio-geral-do-corte.md` (o `export` antes das três linhas), o `ambiente-desenvolvimento.md`, o
  comentário do `docker-compose.desenvolvimento.yml` e o do `conferir-tudo.sh` dizem o mesmo.
- **O portão** `conferir-provedores-no-loopback.php` lê o modelo -- o projeto, o `COMPOSE_FILE` exato, e
  cada porta que o `docker-compose.yml` publica com a sua linha, no loopback, sem repetir e sem ser da
  homologação, do teste, da 8090/8091 nem da 3306 -- e, na seção, só os blocos de código: nenhum `docker
  compose` com `-f` ou `-p` (as opções do compose, e não o `-f` do `psql`), um `up`, o `cd
  ~/franquiabv-lab`, o `cp` do modelo e o `export PROJETO` antes do primeiro roteiro de carga. Até aqui
  ele exigia o contrário: o `-f` em cada linha com `-p franquiabv-lab`.

**Medido** com `docker compose --env-file <modelo> config` (só leitura, nesta máquina), os três modelos
lado a lado, com os perfis `fase1` e `f15`: a homologação, projeto `franquiabv`, publica 3308, 5433,
8025, 8080, 8081, 9000 e 9001; o teste, `franquiabv-teste`, 3309, 5434, 8026, 8092, 8093, 9002 e 9003; o
laboratório, `franquiabv-lab`, 3310, 5435, 8027, 8094, 8095, 9004 e 9005 -- todas em `127.0.0.1`, e o
app das três com os 18 `extra_hosts` (nove nomes, dois endereços). Os volumes do laboratório saem
`franquiabv-lab_*`. **Falsificado**, um mutante por vez, devolvido e conferido pelo sha256: o modelo sem
o arquivo do desenvolvimento, sem o projeto, com a porta do teste (8092), com a da homologação (3308), sem
a linha do `MINIO_PORT`, com uma porta fora do loopback e com duas portas iguais; a seção com `-f` no `up`,
com `-p franquiabv` num `exec`, sem o `export PROJETO` e com o `cp` do modelo da homologação -- 1 vermelho
cada; e o controle, um `-f` num comentário do shell, verde.

**A revisão de 08/10/2026: a conferência de cada bloco só avisava.** Os blocos começavam por `cd
~/franquiabv-lab && grep -qx 'COMPOSE_PROJECT_NAME=franquiabv-lab' .env || echo "PARE ..."`, e o `echo`
sai com 0: as linhas de baixo rodavam assim mesmo, no diretório em que se estava. Do clone da homologação,
com o `cd` falhando (o clone em outro `~`, ou ainda não feito), o bloco de "apontar a aplicação para o
PostgreSQL" copiava o `local.postgres.php.dist` sobre o `local.php` da HOMOLOGAÇÃO -- o defeito deste
débito de volta -- e rodava a suíte no projeto dela; com o `.env` errado no clone, o `docker compose` ia ao
projeto que aquele `.env` nomeia; e o bloco do clone, com o `git clone` falhando, copiava o modelo sobre o
`.env` da homologação. O portão lia que o `cd` e o `cp` estavam no texto, e não que eles seguravam.
**Agora cada bloco é um subshell, `(` e `)`, com `set -e`:** o `cd` que falha e o `grep` do `.env` que não
bate (`|| { echo "PARE ..." >&2; exit 1; }`) param o bloco, e o `exit` sai só do subshell -- o terminal de
quem cola fica, e o `export PROJETO` não sobra nele. O bloco do clone ficou em dois: o clone, o `cp` do
modelo e a conferência no subshell, e o `nano` fora. E o irmão, no `docs/ensaio-geral-do-corte.md`:
"`cd ~/franquiabv-lab && export PROJETO=franquiabv-lab` antes das três linhas" -- com o `cd` falhando, o
`export` não acontecia e o `ensaiar-corte.sh` rodava no projeto `franquiabv`, a homologação. As três
linhas viraram um bloco da seção ("O ensaio geral do corte, no laboratório"), na mesma forma, e o
documento do ensaio aponta para ele. **O caso que o pega:**
`sh scripts/ensaiar-blocos-do-laboratorio.sh` tira da seção cada bloco `bash` que age (sete) e roda o MESMO
texto pelo `.`, no mesmo shell, como quem cola -- com dubles de `docker`, `php`, `git`, `gunzip` e `nano`,
um `$HOME` próprio e um clone de mentira da homologação como diretório de partida --, em três casos: sem o
clone (o `git clone` falha), com o `.env` errado, e o certo. Nos dois primeiros nada pode rodar e nenhum dos
dois clones pode mudar; no certo, tudo o que roda roda dentro do laboratório; e em todos o shell tem de
seguir depois do bloco. Roda no `conferir-tudo.sh` e no CI, e está declarado no `conferir-lacuna-do-ci.sh`.
**Falsificado** numa cópia do documento, um mutante por vez: a forma de antes (sem subshell, `|| echo`: 17
vermelhos), sem o `set -e` (2: o bloco do clone segue depois do `git clone` falho e copia o modelo sobre o
`.env` da homologação), a guarda que só avisa dentro do subshell (7), sem o `cd` no bloco do
`local.postgres` (2: ele para também no clone certo), sem o subshell, com o `exit` e o `set -e` soltos no
terminal (14: fecharia o terminal de quem cola) e a guarda só num comentário do shell (12) -- esses seis
com os seis blocos de antes do do ensaio geral --; e o bloco do ensaio geral na forma solta, o `cd ... &&
export` (3). Verde no Git Bash e no contêiner (dash).

**Não provado:** nada disto rodou no BVIP -- quem roda é o dono --, e o `local.php` da homologação de hoje
não foi lido. **O que resta, e é o PARCIAL:** saber se o laboratório já rodou do diretório da homologação
(a pergunta de fato, sem resposta); se rodou, a conferência do `local.php` dela, que a seção ensina.

### D432 — O `dataValida` confere o tamanho e não a forma: `1/02/02026` passa, e o cadastro de cliente, o fechamento da agenda e os relatórios dão 500 — REGISTRADO em 06/10/2026; a pergunta foi ao lote — CORRIGIDO em 08/10/2026

**Achado no conserto do D420** (o item ERP-BOTOES-E-DATAS, 06/10/2026). O ajudante confere que a data tem
dez caracteres, três partes e, desde o D420, que as três são dígitos -- e não onde estão as barras. Com
dez caracteres há outras formas além de `dd/mm/aaaa`: `1/02/02026`, `01/2/02026`, `001/2/2026`. O
`checkdate` as aceita (dia 1, mês 2, ano 2026), e quem confia no ajudante as lê pela posição ou pelo
formato estrito: o `createFromFormat('d/m/Y')` de quem grava devolve `false`, e o `stringDate` e o
`dataHoraBr_to_dateTime`, que leem os caracteres 0-1, 3-4 e 6-9, montam `2026-2/-1/`, `2026-2/-01` e
`2026-/2-00`.

**Medido em processo, cada forma num processo próprio** (a revisão do item; a primeira redação dava as
três como medidas no cadastro, e só a primeira tinha sido -- as outras duas, na mesma rodada, eram a
cascata do `EntityManager is closed`), num rascunho de teste apagado depois:

| entrada | `dataValida` | cadastro de cliente, ERP e aplicativo | fechamento da agenda antiga | relatório de caixas, MySQL | relatório de caixas, PostgreSQL |
|---|---|---|---|---|---|
| `26/12/2026` (controle) | `true` | -- | grava | responde | responde |
| `1/02/02026` | `true` | 500 | 500 | responde, lido como 01/02/2026 | 500 |
| `01/2/02026` | `true` | 500 | 500 | responde, lido como 01/02/2026 | 500 |
| `001/2/2026` | `true` | 500 | 500 | 500 | 500 |

- **O cadastro de cliente** -- o modal do ERP (`clientes/salvar`) e o do aplicativo (`/api/clientes/novo`),
  as três formas nas duas portas, no MySQL: passam pela validação e morrem no `flush`,
  `ConversionException: Could not convert PHP value '' of type 'boolean' to type 'datetime'`; a conversão
  falha ao montar o INSERT, e nada é gravado. No PostgreSQL, a primeira forma no aplicativo: a mesma
  exceção (ela é do PHP, antes do banco).
- **O fechamento da agenda antiga** -- `fecharValidar` e `fechar`, as três formas, nos dois bancos: a
  `RegraDeFechamento::conferir` passa pelo ajudante (as linhas 67 e 85) e o `dataHoraBr_to_dateTime` lança
  `Exception: Failed to parse time string (2026-2/-1/ 09:00)`, sem `try`; nenhum fechamento gravado (a
  exceção vem antes do serviço). O controle, `26/12/2026`, valida e grava um.
- **O relatório de caixas** (`relatorios/caixas`, GET): o `stringDate` vai como parâmetro de
  `DATE(...) BETWEEN`. No PostgreSQL as três são `SQLSTATE[22007] invalid input syntax for type date`; no
  MySQL, `001/2/2026` é `1525 Incorrect DATE value: '2026-/2-00'`, e as outras duas respondem -- o MySQL lê
  `2026-2/-1/` e `2026-2/-01` como 2026-02-01, com o aviso de delimitador obsoleto (medido num `SELECT` sem
  tabela) --, e a tela mostra no filtro a forma torta e os caixas de outro dia. Os relatórios de produtos e
  de vendas, a lista de vendas e as outras consultas que passam a data pelo `stringDate` seguem o mesmo
  caminho: lido no código, não despachado (a lista de vendas roda o `npsContabilizar`, que escreve).

É a mesma classe do D420 (o 500 no lugar da frase, ou a resposta errada), por outro caminho, e só no
pedido montado à mão: o seletor manda `dd/mm/aaaa`.

**Não foi consertado:** o dono decidiu, na pergunta 29, `ctype_digit` nas três partes, e conferir a forma
muda o que o aplicativo -- contrato congelado (o D5) -- aceita: estas formas passam a "data nascimento
inválida". A direção é a do dia do remarcar (D216), que já confere `^\d{2}/\d{2}/\d{4}$` antes de chamar o
ajudante: no próprio ajudante, as quinze de uma vez. Foi ao lote como pergunta, com esta tabela.

**Decidido pelo dono em 07/10/2026** (pergunta 39 da página do lote): a opção (a), "Conferir a forma no
ajudante" -- "As quinze chamadas de uma vez; o aplicativo responde “data nascimento inválida” também a
essas formas." O `dataValida` passa a conferir `dd/mm/aaaa`. ~~A implementação está na fila (outro item);
nada mudou no código neste.~~ (feita em 08/10/2026, logo abaixo)

#### O conserto — 08/10/2026

No item DATA-NA-FORMA, pela resposta 39 do dono. No próprio ajudante, as quinze de uma vez: `dataValida`
confere `is_string` e a forma `~^([0-9]{2})/([0-9]{2})/([0-9]{4})\z~` -- dois dígitos, barra, dois dígitos,
barra, quatro dígitos, nada antes nem depois -- e passa as três partes capturadas ao `checkdate`. A
expressão toma o lugar do `strlen == 10`, do `explode` e do `ctype_digit` do D420: o `[0-9]` recusa o que o
`ctype_digit` recusava (letra, espaço, sinal), e a posição é a novidade. Duas escolhas, ditas no docblock:

- **o `\z`, e não o `$`**: o `$` do PCRE aceita um `\n` no fim, e `01/02/2026\n` -- onze caracteres, que o
  `strlen` de antes recusava -- voltaria a passar; o `createFromFormat('d/m/Y')` de quem grava devolve `false`
  para ela (medido no contêiner), o mesmo 500;
- **o `is_string` antes da expressão**: o `preg_match` com um arranjo é o `TypeError` que o D420 tirou.

**As chamadas, derivadas do código e não da memória:** o grep de `dataValida` em `module/`, `public/` e
`scripts/` deu as mesmas quinze em onze linhas do D420 -- o cadastro de cliente do ERP, a adesão da
fidelidade e o cadastro do aplicativo (`data_nascimento`, uma cada), o fechamento da agenda antiga
(`RegraDeFechamento`, duas), os relatórios de caixas (duas), de produtos (quatro) e de vendas (duas) e a lista
de vendas (duas) -- mais o dia do remarcar (`AgendasController::diaQueSeVe`), que já conferia a forma
antes de chamar e agora confere duas vezes. Nenhuma cópia do ajudante em outra classe, e nenhum equivalente
em JavaScript nas telas antigas (o único acerto em `public/` é o `DataValidation` da biblioteca de planilha
do amCharts). **Nenhum valor legítimo é recusado:** os seletores de data das telas antigas
(`geral.js` e `fidelidade_cadastro.js`) têm `format: "dd/mm/yyyy"`, a máscara `.data` é `00/00/0000`, os
padrões dos relatórios e da lista de vendas são `date('d/m/Y')`, o contrato do aplicativo é `d/m/Y` com dez
caracteres (`ensaiar-escritas-do-aplicativo.php`), e os testes que mandam data válida às quinze, todos em
`dd/mm/aaaa`, seguem verdes. As frases de recusa são as que cada caminho já dava ao 31 de fevereiro -- o
código das chamadas não mudou.

**A documentação embutida da API diz outra coisa** (a revisão do item, 08/10/2026): `ApiDocsController:526`
descreve o `data_nascimento` do `/api/clientes/novo` como "Formato esperado pelo backend (dd-mm-aaaa)", e o
exemplo da linha 534 manda `10-01-1990`. Quem a seguir recebe "data nascimento inválida" -- antes deste
conserto (o `explode` por `/` não achava três partes) e depois (a expressão exige a barra; o caso do traço
está no `D432DataNaFormaTest`). Não é regressão daqui, e o aplicativo publicado não segue a página: na cópia
de desenvolvimento (só contagem, 08/10/2026), 1.438 clientes têm a marca que só o cadastro do aplicativo põe
(`cadastro_restricao_agendamento` ligado, com a origem `Aplicativo`) e o nascimento gravado -- e, desde a
importação (15/08/2026), só a data com barra passa o `dataValida` e o `createFromFormat('d/m/Y')` do `flush`.
É dedução pela contagem: o código do aplicativo não está no repositório. O texto da página é o D449,
registrado neste item; corrigi-lo não é deste.

**O que muda para quem usa:** nada na tela. O pedido montado à mão com a data fora da forma recebe a frase
de cada caminho ("- Data inválida" no cadastro de cliente do ERP, "data nascimento inválida" no aplicativo,
"- Data início inválida" e "- Data fim inválida" no fechamento da agenda, a data de hoje nos relatórios e na
lista de vendas) no lugar do 500 ou dos caixas de outro dia. No aplicativo (contrato congelado, o D5), a
mudança é a que o dono aceitou na resposta 39, e só no pedido malformado.

**Provado:**
- `D432DataNaFormaTest` (32 execuções, verdes no MySQL e no PostgreSQL): as quatro formas que passavam
  (`1/02/02026` e `001/2/2026`, as da pergunta, e `01/2/02026` e `0001/02/26`, as vizinhas -- a última
  também passava, medida no contêiner antes do conserto) são `false` (4); as vizinhas que já eram falsas
  continuam -- a quebra de linha no fim, o espaço antes e depois, o traço, o ano na frente com barra e em
  ISO, e, desde a revisão do item, as de outro tamanho: a data sem o zero à esquerda (`1/02/2026`,
  `01/2/2026`, `1/2/2026`) e o ano de cinco dígitos (`01/02/20260`) (10); na forma, o calendário decide (o
  01/02 e o 29/02/2024 valem, o 31/02 e o 29/02/2026 não) (1); e quatro caminhos despachados em processo --
  seis das quinze chamadas, cada uma recebendo as quatro formas: o modal de cliente do ERP responde só
  "- Data inválida" e não grava (4, uma chamada), o cadastro do aplicativo responde `{sucesso: false,
  mensagem: "data nascimento inválida"}` e não grava (4, uma), o `fecharValidar` e o `fechar` (com a
  confirmação) respondem as duas frases de data com `horarios` zero e nenhum fechamento gravado (4, as duas
  da `RegraDeFechamento`), e o relatório de caixas mostra hoje no filtro e roda a consulta (4, as duas: a
  torta só no início, e a torta só no fim com o início na forma -- o `||` para no primeiro `!dataValida`, e
  com a torta nas duas pontas, como a primeira redação do caso mandava, a do fim nunca a recebia), com o
  controle de uma data na forma, que fica no filtro (1). (A primeira redação daqui dizia "quatro das quinze
  despachadas"; eram quatro caminhos, e cinco chamadas recebiam a forma torta.)
- Os vizinhos, verdes nos dois bancos, 502 execuções em 40 arquivos (3 pulados em cada banco): os
  que despacham ou leem as quinze chamadas e os que mandam data a elas -- o `D420DataValidaTest`, o
  fechamento da agenda (`FechamentoDaTelaAntigaTest`, `FecharAgendaTest`), o cadastro de cliente
  (`CadastroDeClienteTest`, `NascimentoSemHoraTest`, `ResponsavelNoCadastroDoErpTest`, o D417, o D438,
  `ClienteApiTest`), a adesão da fidelidade (o D184, `FidelidadeTudoOuNadaTest`, `PesquisaFidelidadeTest`),
  a remarcação da tela antiga, os relatórios e a lista de vendas (`ListagemDeVendasTest`, o D14, o D428).
- Mutantes, um de cada vez, restaurados pelo sha256:

  | mudança | vermelhos |
  |---|---|
  | a forma arrancada -- o corpo do D420 de volta (`strlen == 10`, `explode`, `ctype_digit`) | 20 de 55 nos dois bancos (com o `D420DataValidaTest`): as 4 formas no ajudante; o cadastro do ERP e o do aplicativo, `ConversionException` no `flush` (8; na rodada inteira o primeiro é a exceção e os outros sete a cascata do `EntityManager is closed` -- o do aplicativo, rodado sozinho, abre com a mesma `ConversionException`); o fechamento, `Failed to parse time string (2026-2/-1/ 09:00)` e as outras três (4); o relatório de caixas, no PostgreSQL `invalid input syntax for type date` (4), no MySQL `1525 Incorrect DATE value` para `001/2/2026` e `0001/02/26` e o filtro com a forma torta para as outras duas (4). Rodado de novo na revisão, com os casos novos: 20 de 59 nos dois bancos (MySQL 12 erros e 8 falhas, PostgreSQL 16 e 4), os mesmos 20 -- no MySQL o relatório de caixas agora cai nas quatro pela forma torta no filtro, porque o primeiro pedido leva hoje no fim |
  | o `checkdate` arrancado (`return true`) | 9 no MySQL: o calendário daqui (1) e, no D420, as seis datas de verdade falsas e o 31/02 nas duas portas (8) |
  | o `$` no lugar do `\z` | 1: a quebra de linha no fim |
  | sem o `^` | 1: o espaço antes |
  | `.` no lugar de `[0-9]` nas três partes | 9 no MySQL, todos do D420: as sete entradas com letra no dia, no mês ou no ano, espaço ou sinal, e o `1a/02/2026` nas duas portas -- o `(int)` engole a letra e o `checkdate` aceita |
  | `[0-9]{1,2}` no dia e no mês (a revisão do item: a data sem o zero, que o `createFromFormat('d/m/Y')` aceita) | 3 de 59 no MySQL: `1/02/2026`, `01/2/2026` e `1/2/2026`, as vizinhas novas. Antes delas o mutante passava verde em todos os casos, e com ele `1/02/2026` chegava ao `stringDate` e ao `dataHoraBr_to_dateTime`, que leem a posição 9 de um texto de nove caracteres |
  | `[0-9]{4,}` no ano | 1 de 59 no MySQL: `01/02/20260`, a vizinha nova; antes, verde em todos |
  | no `CaixasController`, sem a conferência do fim (o `\|\| !DataHora::dataValida($request['data_fim'])` arrancado) | 4 de 59 no PostgreSQL, `invalid input syntax for type date`: o pedido com a torta só no fim. Com o pedido da primeira redação (a torta nas duas pontas), o `\|\|` parava no início e este mutante passaria -- lido, não rodado |

  Os quatro mutantes do meio não foram rodados de novo: sob cada um deles as entradas novas continuam
  `false` -- nenhuma casa a expressão, mexida ou não --, e a contagem é a mesma (conferido pela leitura das
  entradas).

**O que fica fora daqui:** os caminhos que leem a data SEM o ajudante, e por isso não ganham nada com este
conserto, registrados neste item:
- **D447, a leitura** -- a data da URL crua na consulta: os relatórios de descontos, de vendas canceladas e
  do consultor visagista, medidos (500 no PostgreSQL com as quatro formas), e os de clientes, indicações e
  franquias, lidos; e, desde a revisão do item, a lista de bloqueios da agenda e a de notificações, medidas
  (500 no PostgreSQL com as quatro, 1525 no MySQL com duas). A comanda aberta pela agenda e as outras
  leituras do dia da agenda já estão no D425: a data torta vira `InvalidArgumentException` na `diaInteiro`,
  um 500 de propósito, no lugar de outro dia.
- **D448, a escrita** -- o nascimento do sócio da unidade e o do usuário (e a contratação), gravados sem
  conferência nenhuma além do vazio: a mesma `ConversionException` do cadastro de cliente, medida nos dois
  bancos.
- **D449** -- a documentação embutida da API, que manda o nascimento do cadastro do aplicativo com traço.

E o `preg_match` do `diaQueSeVe` (o D216) ficou como estava: agora repete o que o ajudante confere, e o
comentário dele ("não é enfeite") já dizia o motivo de antes do D420 -- tirar não é deste item.

**Não provado:** nenhum pedido por HTTP nem clique no navegador neste item -- o ciclo, que roda depois,
tem o contrato e os ensaios do aplicativo.

### D433 — A venda em andamento com os débitos de um cliente somados, sem pagamento, vira a comanda de outro com a dívida dentro — REGISTRADO em 06/10/2026; a pergunta foi ao lote — CORRIGIDO em 08/10/2026

**Achado no conserto do D380 e do D384, lido no código, não medido.** A recusa do D384 olha os pagamentos
da venda em andamento. O "Quitar" não é pagamento: ele só soma ao total o que o cliente deve. Se o operador
clicou "Quitar" na venda em andamento do cliente A, ainda sem pagar, e abre a comanda do cliente B -- por
outra aba do PDV, ou pela tela nova --, `VendaService::comandaAbrir` vira a venda na comanda de B com a
dívida de A no total: B paga a dívida de A, e a de A continua aberta. Era assim antes. Com a marca do D380
há uma diferença: se B também deve, até o valor de A, a comanda reaberta de B esconde o bloco dos débitos e
liga a bandeira, e o finalizar baixa os débitos de B com o dinheiro que era a dívida de A (antes, o bloco
aparecia e o "Quitar" somava a dívida de B por cima).

**É pergunta ao dono** (no lote de 06/10/2026): recusar também, com a frase dizendo para finalizar a venda
ou recarregar o PDV (não há botão que tire os débitos somados, e recarregar o `/vendas/novo` apaga a venda
em andamento); tirar do total o que ele tem acima dos itens ao trocar o cliente; ou deixar, porque o
caminho pede duas telas abertas ao mesmo tempo. A recomendação é a recusa, pelo critério da pergunta 15.

**Decidido pelo dono em 07/10/2026** (pergunta 40 da página do lote): a opção (a), "Recusar também" --
"Enquanto a venda tiver débitos somados de outro cliente, com a frase dizendo para finalizar a venda ou
recarregar o PDV." A implementação está na fila (outro item); nada mudou no código neste.

#### A recusa pelos débitos — CORRIGIDO em 08/10/2026, o item PDV-QUITAR-SEGUNDA-RODADA

**O conserto**, pela decisão de 07/10/2026 (pergunta 40), no serviço que as duas portas chamam:
`VendaService::comandaAbrir` não troca o cliente da venda em andamento que tem débitos somados ao total --
a marca do D380, o que o total tem acima da soma dos itens, agora lida por
`RegraDosDebitosNaVenda::temDebitosSomados` (acima do centavo de folga) -- quando a comanda pedida é de
outro cliente: devolve `venda` nula e a venda que impede em `vendaComDebitos`. O ERP (`comandaAbrirAction`)
responde `sucesso: false` e *"Há uma venda em andamento no PDV com os débitos de outro cliente somados ao
total, código: N.<br/>Finalize a venda ou recarregue o PDV (recarregar descarta a venda em andamento) antes
de abrir esta comanda."*, que o `pdvComandaAbrir` já mostra desde o D384 -- o JavaScript não mudou, nem o
`?cache=`; a API, 409 com a mesma frase e `detalhes = {motivo: "venda_em_andamento_com_debitos", venda:
N}`, que a tela nova mostra no aviso de erro de sempre. O parêntese da frase está lá porque recarregar o
`/vendas/novo` apaga a venda em andamento (`novoAction`), e não há botão que tire os débitos somados.

- **Antes da recusa dos pagamentos, de propósito:** com pagamento e débitos somados, a frase é a dos
  débitos -- finalizar ou recarregar resolve os dois, e a dos pagamentos mandaria removê-los só para recusar
  de novo logo depois.
- **Segue como antes:** o mesmo cliente (o "Abrir comanda" do `/vendas/novo` depois do "Quitar", e a
  comanda reaberta lê a marca); a venda sem débitos somados; o cliente que já tem comanda aberta, que a
  recebe de volta sem tocar na venda em andamento (o D52). A venda sem cliente conta como de outro.
- **O "Sem Cadastro" com débitos somados também é recusado** -- uma leitura, e não uma pergunta: a exceção
  da resposta 42 (o D434) é a dos pagamentos, e a 40 não abre exceção; os débitos somados seriam os do
  cliente 2, que na cópia de desenvolvimento tem 26 débitos abertos (de 44), contados em 08/10/2026.

**Provado** em `D384ComandaDeOutroClienteTest` (18 casos; eram 9), verde nos dois bancos, com o "Quitar" de
verdade (`pagarDebitosAction` soma os 30,00 do cliente da venda): a recusa pelo ERP, pelo serviço e pela
API (com o `motivo`); a frase dos débitos quando há também pagamento; o "Sem Cadastro" com débitos somados;
e o controle do mesmo cliente, que abre com os 80,00 do total. Os 26 de `D380QuitarComMarcaTest` também.
Mais 27 arquivos vizinhos (460 casos, os dois deste item incluídos), verdes nos dois bancos, 2 pulados em
cada.

**Os mutantes do item** (o D433, o D434 e a tela nova do D380), um de cada vez, cada um contra os 44 casos
dos dois arquivos, nos dois bancos -- o mesmo vermelho no MySQL e no PostgreSQL, e o arquivo devolvido e
conferido pelo sha1 depois de cada um:

| mudança | vermelho |
|---|---|
| a recusa dos débitos arrancada do `comandaAbrir` | 5: o ERP, o serviço, a API, a frase com pagamento e o "Sem Cadastro" com débitos |
| a recusa dos débitos também para o mesmo cliente | 1: o mesmo cliente com os débitos dele |
| a recusa dos débitos só sem pagamento (a dos pagamentos primeiro) | 1: a frase com pagamento |
| a exceção do "Sem Cadastro" arrancada | 3: o ERP abre, a página apaga o pagamento, a API abre com ele dentro |
| a exceção também para o avulso | 1: o avulso ainda recusado |
| a comanda da API sem o devido (`completo` sem ele) | 3: o `GET` depois do "Quitar", a resposta do pagamento e a do `POST .../debitos` |
| o devido da comanda da API sem a unidade | os mesmos 3 -- e o portão do recorte vermelho (saída 1, a entrada `recurso`) |
| o campo também na comanda concluída | 1: a finalização sem a bandeira |
| `jaSomados` sem o devido positivo | 1: o cliente sem débito |
| `jaSomados` sempre falso | 5: a comanda reaberta do ERP e quem a reabre e finaliza, e os 3 da API -- uma regra para as duas telas |
| a resposta do pagamento sem o `recurso` | 1: o pagamento recebido depois do "Quitar" |
| `VendaJson` com a chave | 1: a venda sem o campo |
| a frase do ERP arrancada (cai na dos pagamentos, sem a venda dela) | 3 erros: o ERP, a frase com pagamento e o "Sem Cadastro" com débitos |
| a frase da API arrancada | 1 erro: a recusa da API |

No front (`ComandasLigada.teste.tsx`), três mutantes em `Comandas.tsx`, um de cada vez: a finalização sem
a bandeira, e o aviso arrancado -- 1 vermelho cada, o caso com os débitos somados --; e a bandeira sempre
-- 1, o caso sem eles.

**O que fica:** nada do D433. Não visto no navegador -- nem a frase no PDV antigo, nem o 409 na tela nova.

#### A verificação — 08/10/2026, o mesmo item

**O meio centavo do desconto era recusado como débito somado** (lido no código, conferido com a conta do PHP
8.1 do contêiner). `temDebitosSomados` pergunta se o excedente passa da folga de meio centavo, e o excedente
comparava o total -- que `Venda::getValorTotal()` arredonda -- com os itens crus. O corte de 45,90 com 5% de
desconto do PDV grava o item como 43,605 (`calcularLinha`), e o total como 43,605: 43,61 − 43,605 =
0,005000000000002558, acima da folga. A venda sem "Quitar" nenhum era recusada ao abrir a comanda de outro
cliente, com a frase errada; e o caminho da resposta 42 ("Sem Cadastro", o item com o desconto, o pagamento,
cadastrar a pessoa) caía nesta recusa, contra a opção do dono. Com dois itens assim o excedente chegava a
0,01.

**O conserto é na marca**, e vale para todos os que a leem: `RegraDosDebitosNaVenda::excedente` soma os itens
arredondados ao centavo e devolve o excedente em centavos. O total incremental de quem só lança itens é a
soma dos itens arredondados (cada item entra sobre o total anterior já arredondado), e não a dos crus. O
irmão, do lado do `cobre`: dois itens que arredondam para baixo tiravam meio centavo cada, e depois do
"Quitar" o finalizar recusava a bandeira (49,994 contra 50,00) -- ver o D380, "A verificação". Medido na
cópia de desenvolvimento, só leitura: de 459 vendas com item fora do centavo, 9 tinham o total acima dos
itens crus por mais que a folga, e 3 acima dos arredondados.

**Provado** em `D384ComandaDeOutroClienteTest` (20 casos; eram 18): o item de meio centavo sem pagamento vira
a comanda do outro cliente pelo ERP, e o "Sem Cadastro" com ele e com o pagamento vira a comanda do cliente
cadastrado no meio pela API -- os dois vermelhos com os itens crus, nos dois bancos.

**O que fica:** nada do D433, que segue CORRIGIDO. As 3 vendas medidas são outro defeito, de antes dele: o
centavo que tirar (ou ajustar) um item de meio centavo deixa no total, cobrado do cliente. A marca o lê como
débito somado, e por isso esta recusa ainda pode dizer "débitos somados" sem "Quitar" nesse caso; a causa e
o conserto são da remoção, no serviço do balcão -- o D450, ao lote. Não visto no navegador.

### D434 — O "Sem Cadastro" que ganha cadastro no meio da venda, com pagamento lançado, não abre comanda — REGISTRADO em 06/10/2026; a pergunta foi ao lote — CORRIGIDO em 08/10/2026

**Achado na verificação do item PDV-QUITAR-E-COMANDA, lido no código (JS e PHP), não visto no
navegador.** É um caminho da tela que a recusa do D384 passou a alcançar. No `/vendas/novo`:

1. "Cliente sem cadastro" (`pdvClienteSemCadastro`) põe o cliente 2 no `#pdvcliente_id`, e o primeiro item
   cria a venda em andamento com ele (`VendaService::insert`);
2. o operador lança um pagamento no modal de fechar;
3. o botão do cliente (`pdvClienteModal('', '')`) pesquisa o 2, que `ClientesController::pesquisarAction`
   não devolve; o JavaScript limpa o `#pdvcliente_id` e abre o cadastro vazio. Salvar cria o cliente e põe
   o id dele no campo -- a venda continua do 2 (`atualizarCliente` só roda na venda sem cliente);
4. "Abrir comanda" manda o cliente novo, e `VendaService::comandaAbrir` recusa: a venda em andamento tem
   pagamento e é de outro cliente -- sendo a mesma pessoa.

**O que custa:** pouco. A frase diz para remover os pagamentos, e antes do D384 a venda virava a comanda e
a página da comanda apagava o pagamento de qualquer jeito (`VendaPagamentoService::apagarPagamentos`, a
cada abertura). Mas o item prometia que quem usa a tela direito não seria recusado, e este é um caminho da
tela. `D384ComandaDeOutroClienteTest::testOSemCadastroComPagamentoERecusadoParaOClienteCadastradoNoMeio`
retrata o que está de pé.

**É pergunta ao dono** (no lote de 06/10/2026): manter a recusa; tratar o cliente 2 como venda sem dono,
que pode virar a comanda de qualquer um (e a venda "Sem Cadastro" de outra aba vira a comanda de outra
pessoa com o dinheiro dentro, que é o que o D384 fecha); ou a tela gravar na venda o cliente cadastrado no
meio dela -- o que muda também a quem a venda finalizada é atribuída. A recomendação é manter a recusa.

**Decidido pelo dono em 07/10/2026** (pergunta 42 da página do lote): a opção (b), "Tratar o “Sem
Cadastro” como venda sem dono" -- "Reabre o que a sua 15 fechou." --, e não a (a), "Manter a recusa",
que era a recomendação. A venda em andamento do cliente 2 ("Sem Cadastro") passa a poder virar a comanda
de qualquer cliente, como a venda sem cliente; o custo, escrito na opção, é que a venda "Sem Cadastro" de
outra aba vira a comanda de outra pessoa com o pagamento dentro -- o que a resposta 15 (o D384) fecha
para os outros clientes. A implementação está na fila (outro item); nada mudou no código neste, e
`D384ComandaDeOutroClienteTest::testOSemCadastroComPagamentoERecusadoParaOClienteCadastradoNoMeio` vira
com ela.

#### O "Sem Cadastro" como venda sem dono — CORRIGIDO em 08/10/2026, o item PDV-QUITAR-SEGUNDA-RODADA

**O conserto**, pela decisão de 07/10/2026 (pergunta 42, a opção (b), e não a recomendada), no serviço que as
duas portas chamam: `VendaService::trocariaOClienteDeUmaVendaComPagamento` não conta a venda do cliente 2
como de outro cliente. O 2 é reconhecido pelo id fixo, como no resto do sistema
(`VendaService::SEM_CADASTRO = 2`, o mesmo de `Adm\ClientesController`). Abrir a comanda do cliente
cadastrado no meio da venda -- ou de qualquer outro -- não é mais recusado, pelo ERP
(`comandaAbrirAction`) e pela API (`POST /adm/api/comandas`): a venda vira a comanda do cliente pedido,
com os itens e o pagamento.

**Uma divergência com a instrução do item, escrita para não sumir.** A instrução dizia para o "Sem Cadastro"
seguir o caminho da venda SEM cliente. Esse caminho, hoje, é a recusa: o D384 conta a venda sem cliente
como de outro (`testAVendaEmAndamentoSemClienteComPagamentoTambemRecusa`), e segui-lo manteria o "Sem
Cadastro" recusado -- o contrário da opção do dono, cujo texto no lote era "tratar o cliente 2 como venda
sem dono, que pode virar a comanda de qualquer um". Segui a opção. A venda sem cliente continua recusada (a
pergunta foi só do 2; na cópia de desenvolvimento não há venda sem cliente nenhuma, contado em 08/10/2026),
e o avulso (o cliente 1), o outro marcador de balcão, também.

**O que acontece com o pagamento já lançado, com todas as letras:**

- **pelo ERP** -- o caminho da pergunta, o "Abrir comanda" do `/vendas/novo`: o `comandaAbrir` não toca no
  pagamento, e o `pdvComandaAbrir` leva à página da comanda, que **APAGA os pagamentos** a cada abertura
  (`VendasController::comandaAction` chama `VendaPagamentoService::apagarPagamentos`: as linhas de
  `vendas_pagamentos` saem, e o recebido e o troco voltam a zero), como antes do D384. Não é escolha deste
  item: a página já apagava, e continua. Nada sai do caixa nem do crédito -- o pagamento lançado só vira
  dinheiro no finalizar --, e o operador recebe de novo ao fechar a comanda;
- **pela API** -- a tela nova, ou outra aba: o pagamento **fica** dentro da comanda do cliente pedido (o `GET`
  da comanda não apaga nada). É o custo escrito na opção ("Reabre o que a sua 15 fechou"): a venda "Sem
  Cadastro" de outra aba vira a comanda de outra pessoa com o pagamento dentro. Um pagamento em crédito do
  cliente 2 seria conferido, no finalizar, contra a linha do cliente novo (o D166 recusa sem linha) -- lido
  no código; na cópia, nenhuma venda do 2 tem pagamento em crédito (as formas 5 e 6), contado em 08/10/2026.

**Os débitos somados não entram na exceção.** A venda do "Sem Cadastro" com débitos somados pelo "Quitar"
(os do cliente 2, que na cópia tem 26 débitos abertos de 44) é recusada pela regra do D433: a resposta 42
tirou a recusa dos pagamentos, e a resposta 40 não abre exceção.

**Provado** em `D384ComandaDeOutroClienteTest` (18 casos; eram 9), verde nos dois bancos: o caso que
retratava a recusa virou `testOSemCadastroComPagamentoViraAComandaDoClienteCadastradoNoMeio` (o ERP abre, e
o pagamento continua na venda), e entraram a página da comanda que apaga o pagamento
(`testPeloErpAPaginaDaComandaApagaOPagamentoQueOSemCadastroTinha`, despachada em processo), a API com o
pagamento dentro (`testPelaApiOSemCadastroComPagamentoViraAComandaComOPagamentoDentro`) e o avulso ainda
recusado (`testOAvulsoComPagamentoContinuaRecusado`). Os mutantes estão na tabela do item, no D433.

**Na verificação do item (08/10/2026):** o caminho da pergunta com um item de desconto em porcentagem (5% de
45,90, o item de 43,605) caía na recusa dos débitos somados do D433 -- a marca lia o meio centavo como
"Quitar". Corrigido na marca (ver o D433, "A verificação"), e provado com
`testOSemCadastroComItemDeMeioCentavoViraAComanda`, pela API.

**O que fica:** nada do D434. O custo da opção -- a venda "Sem Cadastro" de outra aba virando a comanda de
outra pessoa com o pagamento dentro, pela API -- é o que o dono aceitou ao escolhê-la. Não visto no
navegador.

### D435 — Com uma sessão no pacote, a comanda aberta pela agenda dá a sessão a cada item do mesmo serviço, e o finalizar deixa o saldo negativo — REGISTRADO em 07/10/2026; a pergunta foi ao lote — CORRIGIDO em 08/10/2026

Achado em 07/10/2026, ao escrever o caso que o D426 pedia ("prova qual item sai como sessão
-- o primeiro marcado"), e não procurando por ele: o caso não tinha como passar.

**O defeito.** `VendaService::comandaAbrirAgenda` percorre os serviços do dia e, para cada
um, pergunta `ClientePacoteRepository::selecionarQuantidadeServicoDisponivel` -- a soma do
`quantidade_restante` dos pacotes ativos do cliente para aquele serviço. A soma é a gravada, e
ela só cai no finalizar (`VendaPagamentoService::salvarPacotes`, uma sessão por item). Nada
desconta o item anterior da mesma comanda. Então, com **uma** sessão restante e dois horários
do mesmo serviço no dia, em barbeiros diferentes, os **dois** itens saem como sessão do pacote
(valor zero, o mesmo pacote nos dois), e -- pela leitura do finalizar -- o pacote fica com -1.
O lançamento de item no PDV (`VendaProdutoService::calcularLinha`) tem a mesma forma: cada
item pergunta sozinho.

**Provado:** `D426OrdemDosServicosDaComandaTest::testComUmaSessaoDoPacoteOsDoisItensSaemComoSessao`,
nos dois bancos -- o pacote da fixture com o restante posto em 1, dentro da transação, e a
comanda aberta pelo serviço de verdade: os dois itens apontam para o pacote e valem zero. O caso
diz **o fato de hoje, em pergunta ao dono**; quando ele decidir, o caso muda junto.
**Não medidos:** o finalizar levando o pacote a -1, e o caminho do PDV.

**Na cópia** (`franquiabv_dev`, MySQL, só contagens, em 07/10/2026): 17 dos 3.475 pacotes de
cliente estão com `quantidade_restante` negativo (16 ativos; o menor, -25), e 107 vendas têm dois
itens ou mais apontando para o mesmo pacote (115 pares venda e pacote, 239 itens). Por qual porta cada um chegou lá, **não
foi medido**.

**Por que não foi consertado aqui:** descontar as sessões já lançadas na mesma venda muda o valor
da comanda -- o segundo item passa a sair pago --, nas duas portas (a comanda pela agenda e o
PDV). É regra de dinheiro, e foi ao lote de 07/10/2026 com as saídas: (a) descontar, e só o
primeiro marcado (a resposta 23) gasta a sessão; (b) deixar entrar e o finalizar recusar o
pacote negativo; (c) deixar como está. A recomendação é (a). Nenhuma delas mexe nos 17 pacotes
já negativos.

**E corrige a premissa do D426**, que dizia que a ordem dos serviços decidia qual item saía como
sessão e qual saía pago. Hoje não decide: os dois saem como sessão. A ordem que o dono escolheu
(o id) passa a decidir isso se a saída (a) for a escolhida.

**Decidido pelo dono em 07/10/2026** (pergunta 45 da página do lote): a opção (a), "A comanda e o PDV
descontam as sessões já lançadas na mesma venda" -- "Só o primeiro serviço (o marcado primeiro) sai como
sessão; o segundo sai pelo preço." O campo em que o dono pediria que os 17 pacotes já negativos fossem
olhados veio vazio: eles ficam como estão. A implementação está na fila (outro item); nada mudou no
código neste.

**O conserto, em 08/10/2026** (o item AGENDA-SEGUNDA-RODADA). As duas portas que lançam serviço com sessão
de pacote passam a venda a `ClientePacoteRepository::selecionarQuantidadeServicoDisponivel` e
`::selecionarPacote`: a comanda aberta pela agenda (`VendaService::comandaAbrirAgenda`) e o lançamento de
item (`VendaProdutoService::calcularLinha`), por onde passam o PDV antigo e a API da tela nova
(`Adm\ComandasController`, com `pacoteAtivo`; a tela nova não o manda hoje, e nenhum `.tsx` muda). Com a
venda, o saldo de cada pacote é o restante gravado menos as sessões que os itens DESTA venda já apontam para
ele, nunca abaixo de zero por pacote; e o pacote escolhido é o primeiro da ordem de sempre (vence antes,
depois o id, o D72) que ainda tem saldo nela. O primeiro item lançado -- na comanda pela agenda, o horário
marcado primeiro, pela ordem do id (o D426) -- gasta a sessão; o seguinte sai pelo preço, com o convênio se
houver, como qualquer item sem sessão. O finalizar continua sendo quem grava a baixa, e não mudou.

**Os cuidados, um a um.** Tirar da venda o item da sessão: a sessão volta para o PRÓXIMO lançamento (o
`remover` apaga a linha, e ela sai da conta). O item que já tinha saído pelo preço continua pelo preço --
nenhum item é recalculado quando outro sai --, e isso a resposta 45 não decide: na comanda pela agenda, tirar
o da sessão deixa o outro a 89,90 com a sessão livre no pacote, o que antes do conserto não acontecia. A
primeira versão deste registro o decidia sozinha; a verificação do item o mandou ao dono, como o D453. Dois pacotes do mesmo serviço: cada item vai para um
(só a conta do total não bastava -- o segundo iria para o mesmo, o que vence antes, e o finalizar o levaria a
-1 com o outro intacto). Pacote de outra unidade: não entra, como antes (a conta é da unidade do serviço;
lido no código, sem caso novo). Venda cancelada: não entra, porque só a própria venda conta (o caso da
"outra venda" prova o recorte com uma venda aberta). A comanda reaproveitada (o D52): as sessões que ela já
tinha contam. Os 17 pacotes já negativos da cópia ficam como estão.

**Provado** em `D435SessaoDoPacoteNaMesmaVendaTest` (8 casos, um deles o fato do D453) e no caso mudado do
`D426OrdemDosServicosDaComandaTest` (`testComUmaSessaoDoPacoteSoOMarcadoPrimeiroSaiComoSessao`, que até aqui
dizia o fato em pergunta), verdes no MySQL e no PostgreSQL: o cenário da pergunta -- uma sessão, dois
horários do mesmo serviço -- dá o marcado primeiro como sessão e o outro a 89,90, e o pacote termina em 0
depois do finalizar (era -1); com duas sessões, as duas, e 0; com dois pacotes de uma sessão, um item em
cada, os dois em 0; a comanda reaproveitada com a sessão lançada pelo PDV põe os dois horários pelo preço;
no PDV, o segundo sai pelo preço, e a sessão volta quando o item dela sai; o pacote estourado por uma comanda
de antes do conserto não tira a sessão do outro pacote; e a sessão lançada em OUTRA venda aberta não conta
(o limite da resposta: é o D452). Como fato, em pergunta (o D453): tirar da comanda o item da sessão deixa o
outro pelo preço, e o pacote termina o finalizar com 1. A validade do pacote da fixture (2030-12-31) é posta
no caso, um ano depois do dia do cenário: com a data fixa, o "pacote que vence antes" dos casos de dois
pacotes deixaria de vencer antes em novembro de 2030 (o padrão do D437; a verificação do item).

**Falsificado**, um mutante por vez, restaurado e conferido pelo sha256, com o mesmo vermelho nos dois
bancos:

| mutante | caso vermelho |
|---|---|
| a comanda pela agenda sem a venda nas duas consultas | o da pergunta, dois pacotes, a comanda reaproveitada, a outra venda, e o do `D426Ordem` |
| o PDV sem a venda nas duas consultas | o do PDV e o do pacote estourado |
| `selecionarPacote` sem descontar a venda | dois pacotes e o do pacote estourado |
| as sessões de QUALQUER venda contam | a outra venda |
| sem o piso de zero por pacote | o do pacote estourado |

**O que fica fora, e vai ao D452 e ao D453:** a resposta é "na mesma venda". Duas vendas abertas do mesmo
cliente ainda dão a mesma última sessão, e o item com quantidade maior que o saldo do pacote que vence antes
-- também o saldo que a própria venda já gastou -- ainda o leva abaixo de zero (o D452); e o item tirado não
devolve a sessão ao que ficou pelo preço (o D453). Os três provados como fato, nos dois bancos.

### D436 — Com "Agendas unidades", na grade de outra unidade, o modal dos horários marcados lista os da unidade da sessão e abre a comanda nela, e o check-in mexe na do horário clicado — REGISTRADO em 07/10/2026; a pergunta foi ao lote — CORRIGIDO em 08/10/2026 pela escolha (b) do dono: a lista é a do horário clicado, e o “Abrir comanda” segue na unidade da sessão

Achado em 07/10/2026, na verificação do AGENDA-DECISOES (o D422). **Lido no código, não visto no
navegador.**

O clique num horário marcado da grade abre o modal "HORÁRIOS MARCADOS" (`agendaModalHorariosMarcados` em
`mod-cadastros.js`, que chama `AgendasController::horariosMarcadosAction`). O conteúdo sai de
`horariosMarcadosPorClienteHtml`, que pede `AgendaRepository::horariosMarcadosPorClienteData($cliente,
$this->unidadeId)`: os horários do cliente na unidade da **sessão** (pela unidade do serviço,
`pro.unidade`), de hoje em diante -- o `agendamento_id` clicado nem vai no pedido. O "Abrir comanda" do
mesmo modal procura e abre a comanda na unidade da sessão (`selecionarComandaCliente(...,
$this->unidadeId)` e `comandaAbrirAgenda`), com os serviços dela. O modal de confirmação do check-in só
pergunta "Deseja fazer o check-in do cliente?".

Para quem não enxerga a rede, a grade aberta é sempre a da sessão, e as três coisas falam da mesma
unidade. Para quem tem "Agendas unidades" (e o administrador), logado na A e com a grade da B aberta: o
modal lista os horários do cliente na A; "Fazer check-in" e "Sim" viram, desde o D422, só os horários do
cliente naquele dia na B, a unidade do horário clicado; e "Abrir comanda" abre a comanda na A com os
serviços da A. Antes do D422 o check-in virava os da A e os da B, e a lista era ao menos parte do que o
clique mexia; agora a lista que o operador confirma não tem nada em comum com o que o clique muda. O
modal e a comanda são os de antes; foi a regra do dono ("para marcar na outra, abre a grade dela") que
pôs esse fluxo no caminho de quem enxerga a rede.

**Não foi consertado:** o que o modal mostra e onde a comanda abre, para quem enxerga a rede na grade de
outra unidade, é regra de negócio -- a comanda é venda, e venda é da unidade. Foi ao lote de 07/10/2026
com as saídas: (a) o modal lista os horários do cliente na unidade do horário clicado (os que o check-in
alcança), e o "Abrir comanda" só aparece quando essa unidade é a da sessão; (b) o modal lista os da
unidade do horário clicado, e o "Abrir comanda" continua abrindo na unidade da sessão, com os serviços
dela; (c) deixar como está. A recomendação é (a). Qualquer uma pede que a tela mande o horário clicado ao
`horariosMarcadosAction`, que hoje recebe só o cliente e o dia.

**Decidido pelo dono em 07/10/2026** (pergunta 47 da página do lote): a opção (b), "A janela mostra os da
unidade do clicado, e o “Abrir comanda” fica como hoje" --, e não a (a), "A janela mostra os horários da
unidade do clicado, e o “Abrir comanda” só aparece se essa unidade for a sua", que era a recomendação. O
modal "HORÁRIOS MARCADOS" passa a listar os horários do cliente na unidade do horário clicado (os que o
check-in alcança), e o "Abrir comanda" continua como hoje: abre na unidade da sessão, com os serviços
dela. A implementação está na fila (outro item) -- e pede que a tela mande o horário clicado ao
`horariosMarcadosAction`, que hoje recebe só o cliente e o dia --; nada mudou no código neste.

**O conserto, em 08/10/2026** (o item AGENDA-SEGUNDA-RODADA), a opção (b), e NÃO a recomendada.
`agendaModalHorariosMarcados` (`mod-cadastros.js`, `?cache=166`) passou a mandar o horário clicado
(`agendamento_id`, que ele já recebia da grade e só usava no botão do check-in). Em
`AgendasController::horariosMarcadosAction`, para quem enxerga a rede, a lista é a dos horários do cliente
na unidade desse horário -- a do colaborador, a mesma que o `checkinAction` alcança desde o D422
(`unidadeDoModal`). O "Abrir comanda" não mudou: a comanda continua procurada (`selecionarComandaCliente`) e
aberta (`comandaAbrirAgenda`) na unidade da sessão, com os serviços dela. Quem não enxerga a rede segue como
antes, e o pedido sem `agendamento_id` também. Na tela, esse pedido é o do botão `#btn_agendamentos` do modal
de marcar -- que, desde a importação, manda o cliente 7136 escrito no JavaScript, e não o escolhido: o D454,
achado na verificação e não consertado aqui. A primeira versão deste registro o citava como caminho legítimo.

**Duas coisas que a escolha pedia, e que o item não dizia:**

- **O desmarcar de dentro do modal** redesenha a lista (`desmarcarAction`, `horarios_cliente`), e a redesenhava
  na unidade da sessão: com a lista da B aberta, desmarcar um horário da B trocava a lista pela da A. Agora,
  para quem enxerga a rede, ela volta na unidade do horário desmarcado.
- **O horário clicado que não é mais do cliente** -- apagado (remarcado ou desmarcado com a grade aberta),
  inativo ou de outro cliente -- abre o modal como antes de 08/10: a lista da unidade da sessão e o "Abrir
  comanda" de sempre, com um aviso que a tela mostra ("Não foi possível localizar o horário clicado: ...
  A lista é a dos horários na sua unidade; atualize a agenda para ver os da unidade do horário."). O
  check-in do mesmo modal continua recusando esse clique (o D422). A primeira versão recusava o modal
  inteiro com a frase do check-in, e o "Abrir comanda" só voltava depois de atualizar a grade -- o que a
  escolha (b), "o Abrir comanda fica como hoje", não pedia; a verificação do item o achou. Na recusa que
  sobrou (o cliente que não existe), o servidor não manda frase e a tela mostra a de sempre; a linha que
  mostra a do servidor, quando houver, ficou.

**O que a escolha (b) deixa, de propósito:** quem enxerga a rede, logado na A com a grade da B aberta, vê a
lista da B e faz o check-in na B, mas o "Abrir comanda" abre (ou mostra a já aberta) na A, com os serviços da
A -- a unidade de quem está logado. A recomendada (a) escondia o botão fora da própria unidade; o dono
preferiu deixá-lo como está.

**Provado** em `D436ModalDosHorariosMarcadosTest` (10 casos, o controller despachado em processo), verdes no
MySQL e no PostgreSQL: com "Agendas unidades", o clique num horário da outra lista os da outra, e o "Abrir
comanda" é a comanda da minha; o clique na minha lista os da minha; sem o privilégio, o horário da outra
escrito à mão não muda nada; sem o horário clicado, a sessão; o horário apagado, o inativo e o de outro
cliente abrem na sessão, com o "Abrir comanda" da sessão e o aviso; o desmarcar redesenha na unidade do
horário (com o privilégio) e na sessão (sem ele). O JavaScript, em `remarcarDaTelaAntiga.teste.ts` (no front:
as funções do arquivo servido com um jQuery de mentira, 5 casos novos): o POST leva `agendamento_id`; a
resposta abre o modal; o aviso aparece com o modal aberto; a recusa mostra a frase do servidor, ou a de
sempre. E o piso do `?cache=` no mesmo arquivo subiu para 166. Até a verificação do item, a prova do
JavaScript estava só num roteiro de rascunho, fora do repositório: apagar o `agendamento_id` do POST desfazia
a escolha do dono com a suíte verde.

**Falsificado**, um mutante por vez, restaurado e conferido pelo sha256, com o mesmo vermelho nos dois
bancos. Rodados de novo na verificação do item, contra o código final (os da primeira versão tinham rodado
antes da última mudança do controller):

| mutante | caso vermelho |
|---|---|
| a lista sempre na sessão | o da outra, e os três do horário que não serve (sem o aviso) |
| o horário clicado decide também para quem não enxerga a rede | o "sem o privilégio" |
| o horário que sumiu cai na sessão sem o aviso | os três do horário que não serve |
| o horário que sumiu recusa o modal, como na primeira versão | os três do horário que não serve |
| sem o `aviso` na resposta | os três do horário que não serve |
| sem conferir o cliente do horário clicado | o de outro cliente |
| sem conferir o `status` | o inativo |
| o desmarcar redesenha na sessão | o desmarcar com o privilégio |
| o "Abrir comanda" na unidade do horário clicado (perto da opção "a") | o do "Abrir comanda" |

E no front (`remarcarDaTelaAntiga.teste.ts`), um por vez, cada um com um caso vermelho só: o POST sem o
`agendamento_id` (o "manda o cliente, o dia da grade e o horário clicado"); sem mostrar o `aviso` (o "aviso do
servidor aparece"); a recusa sem a frase do servidor (o "a recusa mostra a frase do servidor"); e o layout com
`?cache=165` (o piso).

**Não provado:** nada aberto no navegador -- o modal de verdade, com a grade da outra unidade, não foi visto.

### D437 — Cinco arquivos de teste da agenda montam o cenário "daqui a 45 dias", e em 09/11/2026 o dia cai no horário fixo da fixture — REGISTRADO em 07/10/2026 — PARCIAL em 07/10/2026: o do D190 escolhe o dia, e os outros quatro esperam — CORRIGIDO em 07/10/2026

Achado em 07/10/2026, na verificação do AGENDA-DECISOES: a revisão apontou o caso da consulta no
`D190CheckinEmDuasFranquiasTest`, e disse que o `D237AgendaRecortePorUnidadeTest` "pode ter o mesmo
choque". Medido, é maior.

**O defeito.** A fixture (`tests/Caracterizacao/fixtures.sql`, o bloco das agendas) grava dois horários
fixos do cliente 90001000 com o colaborador 90001000: 24/12/2026 às 09h e 10/01/2030 às 10h. Vários casos
da agenda montam o cenário em `agora('Y-m-d', '+45 days')`, com o mesmo cliente ou o mesmo colaborador,
e contam ou listam o que há no dia. Em 09/11/2026 (24/12/2026 menos 45 dias) e em 26/11/2029, o dia do
cenário é o dia da fixture, e a conta sai com um a mais.

**Medido** em 07/10/2026, no MySQL como o CI, com o relógio do PHP dos testes adiantado -- o instante de
`CaracterizacaoTestCase::agora`, trocado só durante a rodada e restaurado pelo sha256: com 08/11/2026,
5 falhas; com 09/11/2026, 28. As 5 do controle aparecem nos dois dias (as 3 de
`PontuacaoValidadePorUnidadeTest` e 2 de `RemarcacaoDaTelaAntigaTest` que desenham "hoje"): são do
relógio do PHP adiantado sem o do banco, e não da data. As 23 a mais:

| arquivo | casos vermelhos só em 09/11/2026 |
|---|---|
| `D190CheckinEmDuasFranquiasTest` | 1 (a consulta do check-in devolve três horários, o da fixture primeiro) |
| `D237AgendaRecortePorUnidadeTest` | 4 |
| `D237RestosDoPdvEDaAgendaTest` | 7 |
| `FechamentoDaTelaAntigaTest` | 4 |
| `RemarcacaoDaTelaAntigaTest` | 7 |

`PontuacaoValidadePorUnidadeTest` e `D426OrdemDosServicosDaComandaTest` também usam "+45 days" e não
mudaram entre os dois dias.

**O que entrou:** o `D190CheckinEmDuasFranquiasTest`, o arquivo que o AGENDA-DECISOES tocava, escolhe o
dia -- o primeiro, a partir do 45º, sem horário nenhum do cliente e dos dois colaboradores --, e um caso
prova que a escolha pula o dia ocupado (vermelho, nos dois bancos, se ela devolver o dia pedido sem
olhar). Com o relógio em 08/11/2026, em 09/11/2026 e em 26/11/2029: 15 de 15.

**O que falta:** os outros quatro arquivos. A direção é a mesma -- o dia escolhido, e não um "+46 days"
que cai noutro dia fixo; mexer na fixture não resolve, porque toda data fixa é alcançada por "+45 days"
em algum dia, e o horário de 24/12/2026 tem propósito (o comentário dela: a disponibilidade do aplicativo
precisa de um horário ocupado para descontar). **Prazo: até 08/11/2026.** Em 09/11/2026 a suíte sai
vermelha sem que o código mude -- a família do "mês escrito em teste".

**O conserto (07/10/2026, os outros quatro).** A escolha saiu do `D190CheckinEmDuasFranquiasTest` para um
ajudante comum, `tests/Caracterizacao/DiaLivreNaAgenda.php` -- um trait, no molde do `ControllerEmProcesso`:
`primeiroDiaSemHorario(45, clientes, colaboradores)` devolve o primeiro dia, a partir do 45º, em que
nenhum dos clientes e nenhum dos colaboradores tem horário em `agendas`, de qualquer status, e `daquiA()`
escreve as datas pelo `agora()` (o portão do relógio continua com saída 0). Os quatro arquivos escolhem o
dia no `setUp` e escrevem todas as datas do cenário a partir dele -- o dia e o seguinte, o fim da faixa
das contagens --, cada um com quem o seu cenário usa:

| arquivo | clientes | colaboradores |
|---|---|---|
| `D190CheckinEmDuasFranquiasTest` | 90001000 | 90001000, 90001150 |
| `D237AgendaRecortePorUnidadeTest` | 90001000 | 90001000, 90001150 |
| `D237RestosDoPdvEDaAgendaTest` | 90001004 | 90001000, 90001150, 90001001 |
| `FechamentoDaTelaAntigaTest` | 90001000 | 90001000 |
| `RemarcacaoDaTelaAntigaTest` | 90001000 | 90001000 |

O D190 passou a usar o mesmo ajudante e ganhou dois casos, um para cada metade da escolha: o dia em que
só o colaborador tem horário, com outro cliente, e o dia em que só o cliente tem, com outro colaborador
(17 casos, eram 15). A fixture não mudou.

**Um choque que a medida de origem não viu:** a fixture tem um terceiro horário fixo, o do colaborador
90001150 em 15/06/2030 às 14h (o bloco da sessão do aplicativo, com o cliente 90001150), e "+45 days" o
alcança em 01/05/2030. Os dois arquivos do D237 contam a agenda desse colaborador -- por isso ele está na
lista dos dois, e o mutante sem ele fica vermelho naquele dia.

**Medido** com o relógio de `agora()` adiantado, agora sem trocar linha de arquivo: um `--bootstrap` no
`/tmp` do contêiner define `Testes\Caracterizacao\time()`, que o `agora()` chama sem barra, e devolve o
meio-dia do dia pedido -- o mesmo instante da troca de linha da verificação, e nada a restaurar no
repositório. Calibrado contra ela: em 09/11/2026, sobre o código de antes, os mesmos 27 vermelhos daquela
rodada, menos o do D190, já consertado. Os cinco arquivos, iguais no MySQL e no PostgreSQL:

| relógio de `agora()` | antes do conserto | depois |
|---|---|---|
| hoje | -- | 77 de 77 |
| 08/11/2026 | 2 vermelhos | 2 |
| 09/11/2026 | 24 | 2 |
| 26/11/2029 | 25 | 2 |
| 01/05/2030 | (os mutantes abaixo) | 2 |

Os 2 de todo dia adiantado são o controle, e não a data: `RemarcacaoDaTelaAntigaTest::testSemODiaDesenhaHojeComoAntes`
e `testODiaQueNaoEDiaDesenhaHoje` comparam o "hoje" do teste com o da aplicação, que não se adianta. Sem o
relógio adiantado, os vizinhos da agenda (agenda, check-in, comanda, fechamento, remarcação, o D190, os do
D237 e o D426), 430 verdes nos dois bancos (2 pulados).

**Mutantes**, um de cada vez, restaurados e conferidos pelo sha256, iguais nos dois bancos:

| mutante | relógio | vermelho |
|---|---|---|
| a escolha devolve o dia pedido sem olhar (`DiaLivreNaAgenda`) | hoje | os 3 casos da escolha, no D190 |
| o mesmo | 09/11/2026 | 26: os 3 da escolha, a consulta do check-in e os 22 dos quatro arquivos (mais os 2 do controle) |
| a escolha sem os colaboradores | hoje; 01/05/2030 | o caso do colaborador sozinho; e mais 3 em cada arquivo do D237 |
| a escolha sem os clientes | hoje | o caso do cliente sozinho |
| o D190 sem a escolha | hoje; 09/11/2026 | os 3 da escolha; e mais a consulta do check-in |
| `D237AgendaRecortePorUnidadeTest` sem a escolha | 09/11/2026 | 4 |
| o mesmo, sem o colaborador 90001150 na lista | 01/05/2030 | 3 |
| `D237RestosDoPdvEDaAgendaTest` sem a escolha | 09/11/2026 | 7 |
| o mesmo, sem o colaborador 90001150 na lista | 01/05/2030 | 3 |
| `FechamentoDaTelaAntigaTest` sem a escolha | 09/11/2026 | 4 |
| `RemarcacaoDaTelaAntigaTest` sem a escolha | 09/11/2026 | 7 (mais os 2 do controle) |

**O que os mutantes de cada arquivo dizem, e é um limite:** tirar a escolha de um dos quatro fica verde
com o relógio de hoje -- só fica vermelho no dia em que a data alcança a fixture. A guarda de todo dia é a
do ajudante (os três casos do D190); a de cada arquivo é a medida com o relógio adiantado. (A verificação,
abaixo, achou as duas frases fracas: os três casos não pegavam um "+1" cego, e a guarda de cada arquivo
não rodava em lugar nenhum. Hoje são cinco casos e um portão.)

**A varredura, de 07/10/2026 a 31/12/2027, dia a dia -- 451 dias --, no MySQL como o CI.** Os arquivos
saíram do grep, e não da memória: os 38 de `tests/Caracterizacao` com data relativa (um deslocamento
literal como `'+45 days'`, ou passado a um ajudante, ou `modify()`), e destes os 31 que falam de agenda,
venda ou comanda -- 476 testes por dia, todos rodados nos 451. O controle é o conjunto que o relógio
adiantado dá sozinho: 20 casos vermelhos, os mesmos em 08/11/2026, 09/11/2026, 26/11/2029 e 01/05/2030 e
nos dois bancos -- casos que comparam o "hoje" do teste com o da aplicação ou do banco, que não se
adiantam (a campanha agendada, o caixa de hoje, o cupom vencido, a pontuação que vence, a fidelização, o
mural, os horários "de hoje em diante" e os dois da remarcação). O resultado:

- 410 dos 450 dias adiantados dão exatamente o controle -- 09/11/2026 e 24/12/2026 entre eles;
- 29 dias, de 08/10 a 05/11/2026, dão menos que o controle: os casos da pontuação e da fidelização só
  ficam vermelhos depois de um adiantamento maior. É do relógio;
- 11 dias, de 25/01 a 04/02/2027, dão um caso a mais, `FidelizacaoTest::testVisitasForaDaJanelaNaoContam`.
  Também é do relógio, e não da data: `VendaPagamentoService` conta as visitas de `date()` menos 90 dias
  até `date()` -- o relógio da aplicação --, e as visitas do caso, a 200, 150 e 120 dias do `agora()`,
  caem todas nessa janela exatamente quando o adiantamento está entre 110 e 120 dias. Numa rodada de
  verdade os dois relógios andam juntos e o caso não muda;
- nenhum dia com um vermelho da causa deste débito -- uma data relativa que cai numa data fixa da fixture.

Nenhum débito novo: as duas famílias fora do controle são do instrumento, não do código nem dos testes.
O primeiro dia, o de hoje com o instante ao meio-dia, dá 7 vermelhos, os da campanha agendada -- a rodada
começou às 08h30, e o agendamento "para agora" ficou três horas e meia à frente do banco. Também é do
instrumento.

**Não provado:** os 20 casos do controle ficam vermelhos em todo dia adiantado, então uma colisão de data
DENTRO deles não aparece nesta medida -- entre eles um da agenda,
`DiaDoClienteNaAgendaTest::testOsHorariosMarcadosDeHojeEmDiante`. A varredura rodou só no MySQL; no
PostgreSQL, os mesmos 31 arquivos nas quatro datas acima, iguais ao MySQL. E os 7 arquivos com data
relativa que não falam de agenda nem de venda ficaram fora dela.

**A verificação (07/10/2026).** Quatro achados, todos conferidos no código:

- **A contagem do D190 ficou velha** -- o registro dele dizia quinze casos, e o docblock da classe dizia
  que "os dois casos" da escolha "continuam" ali, quando eram três e dois tinham acabado de nascer.
  Corrigidos os dois textos; a escolha tem hoje cinco casos, e o arquivo, dezenove.
- **Os três casos da escolha não pegavam um deslocamento fixo cego.** Rodados sobre o conserto, 17 de 17
  verdes com cada um de três mutantes do `DiaLivreNaAgenda`: P1, `return $aPartirDe + 1` sem consulta --
  a família do "+46 days" que o docblock do trait diz não resolver --; P2, a escolha que pula no máximo um
  dia; P3, a que acha o dia livre e devolve o seguinte. Os três caem num dia livre por acaso. Com P1 e o
  relógio em 08/11/2026, os cinco arquivos dão 30 vermelhos, nos dois bancos: o cenário em 46 dias cai em
  24/12/2026, um dia antes do D437 original. **O conserto:** `assertPrimeiroDiaLivre` -- todo dia do
  pedido até a véspera do escolhido tem horário do cenário, e o escolhido não tem nenhum -- nos três
  casos, e dois casos novos: `testODiaDoCenarioEOProprioDiaPedidoQuandoEleEstaLivre` e
  `testODiaDoCenarioPulaDoisDiasSeguidosOcupados`.
- **Desfazer a escolha de um arquivo não tinha guarda de todo dia.** `$this->deslocamento = 45;` no
  `setUp`, ou um caso novo com `'+45 days'`, fica verde com o relógio de hoje, e o relógio adiantado só
  roda no rascunho. **O conserto:** o portão `scripts/conferir-dia-do-cenario-da-agenda.php`, no
  `conferir-tudo.sh` ao lado do portão do relógio. Lendo os tokens (comentário não conta), ele exige que
  os cinco arquivos usem o trait; que, em quem o usa, toda atribuição a `$this->deslocamento` venha de
  `$this->primeiroDiaSemHorario(`; que nenhuma chamada a `agora()` leve dia, semana, mês ou ano num texto
  (a exceção é o "+60 days" dos fechamentos do D190, procurados pelo id; a lista conta nos dois sentidos);
  e que o primeiro argumento de `daquiA()` comece por variável. O `setUp` do D190 passou a chamar o trait
  como os outros quatro.
- **O docblock do trait dizia que o portão do relógio acusa quem o ler por fora**, e ele não vê
  `new \DateTimeImmutable('+1 day')`, que o `CampanhaAudienciaTest` usa. Corrigido o texto; o ponto cego
  é anterior ao D437 e ficou registrado como D440.

**Medido depois da verificação,** com o relógio de `agora()` pelo mesmo `--bootstrap`:

| relógio de `agora()` | os cinco arquivos, MySQL e PostgreSQL |
|---|---|
| hoje | 79 de 79 |
| 08/11/2026, 09/11/2026, 26/11/2029, 01/05/2030 | 79, só os 2 do controle vermelhos |

E os vizinhos da agenda (`--filter 'Agenda|Checkin|Comanda|Fechamento|Remarcacao|D190|D237|D426'`), com o
relógio de hoje: 432 verdes nos dois bancos, 2 pulados -- os 430 de antes e os dois casos novos.

**Mutantes da verificação,** restaurados pelo sha256, iguais nos dois bancos:

| mutante | antes da verificação (MySQL) | depois |
|---|---|---|
| P1: `return $aPartirDe + 1` | 17 de 17 | 4 vermelhos: o do colaborador sozinho, o do cliente sozinho e os dois novos |
| P1, com o relógio em 08/11/2026, nos cinco | -- | 30: os 4 acima, a consulta do check-in, o dia ocupado pelo cenário, os 22 dos quatro arquivos e os 2 do controle |
| P2: pula no máximo um dia | 17 de 17 | 1: os dois dias seguidos |
| P3: devolve o dia seguinte ao livre | 17 de 17 | os 5 casos da escolha |
| D1: o `setUp` do D190 com `= 45` | -- | verde hoje; com 09/11/2026, a consulta do check-in e o dia ocupado. O portão o acusa |

**O portão, falsificado** (`medir-d437v-portao.py` no rascunho; cada mutante restaurado pelo sha256):
saída 1 com `= 45` no `setUp` de cada um dos cinco (A1, R1, F1, M1 e D1), com `agora('d/m/Y', '+45 days')`
no `dia()` do D237, com `daquiA(45, ...)` no do fechamento, com `'+' . 45 . ' days'` concatenado no
`marcar()` dos restos, sem o `use DiaLivreNaAgenda` na remarcação, e com a exceção do D190 sem uso; saída 0
com tudo isso só num comentário; saída 2 com o trait sem o `daquiA`.

**O que o portão não vê,** e fica dito no docblock dele: QUEM entra na lista da escolha -- tirar o
colaborador 90001150 de um arquivo do D237 (A2, R2) continua verde hoje e só fica vermelho em 01/05/2030 --,
nem um arquivo novo que monte cenário de agenda com data relativa sem usar o trait. Esses, só a medida com
o relógio adiantado. E a varredura não moveu o `amanha()` do `CampanhaAudienciaTest` nem o `-1 minute` do
`CupomTest`, que leem o relógio por `new \DateTime...` (o D440): para eles ela mediu menos do que diz.

### D438 — A edição de cliente do ERP gravava toda chave do POST que tivesse setter: o estado por fora da regra da unidade, a blacklist do aplicativo e a fidelidade — CORRIGIDO em 07/10/2026

**Achado pela verificação do CADASTRO-DECISOES** (a resposta 24 do dono; o D199 e o D394). A página
`editar` e o modal de cliente (`clientes/salvar`, o das telas de agenda, venda e comanda) entregavam o
POST cru a `ClienteService::update()`, e o `ClassMethodsHydrator` chama o setter de toda chave que tenha
um -- a família do D202, que ficou de fora dele porque o cliente é da rede (o D145) e não havia o que
recortar. Pela edição, que só pede `clientes/cadastro/escrever`, gravava-se num cliente de qualquer
franquia:

| chave do POST | o que mudava |
|---|---|
| `status` | o estado: o gerente de outra franquia desativava com `status=0` -- a porta que a regra da resposta 24 não fechava --, e o select escondido da página `editar`, com o estado lido ao abrir, desfazia a troca feita pela lista nesse meio-tempo (o D259 dos convênios). E os dois cadastros do sistema: `editar/1` e o modal com o id 1 ou 2 (o D394) |
| `bloqueio_agendamento_app` | a blacklist do aplicativo, que tem privilégio próprio (`clientes/blacklistApp`, em `BlacklistAgendaController`) |
| `programa_fidelidade` | a adesão ao programa de fidelidade |
| `cadastro_restricao_agendamento` | a restrição do cadastro feito pelo aplicativo |

Lido no código e reproduzido em processo (os casos abaixo, vermelhos antes); não medido quantos usuários
têm o cadastro sem a blacklist. O `PUT` da API não tinha o defeito: `paraOServico` monta as chaves. O
cadastro novo também hidrata o POST (pelo construtor de `Cliente`), mas o construtor devolve as marcas ao
padrão depois de hidratar, `insert()` grava o estado ativo, e o resto só toca a linha que está nascendo.

**O conserto:** `ClienteService::CAMPOS_DA_EDICAO` -- o hidratador da edição vê só os campos que as telas
mostram (o `Form\Cliente` da página e do modal) e o RG e a profissão, que o `PUT` manda. O estado muda só
pelos botões (`ativarRemover` e a API), e as marcas, pelas telas delas. O select do estado continua na
página `editar`, escondido, e é ignorado.

**Provado** nos dois bancos (`D438EdicaoDoClienteSoGravaOsCamposDaTelaTest`, 4 casos): o gerente da
unidade 90001000, sem ser administrador, salva pela página o cliente da 90001150 com o select em
"Inativo", e ele continua ativo, e, com o select em "Ativo", o que estava inativo continua inativo; o modal
com `status=0`, o mesmo; a página `editar/1` e o modal com os ids 1 e 2, nos dois sentidos, e o estado
fica; e as três marcas no POST da página e no do modal não gravam nada. Em todos, a edição grava o nome --
a prova de que o pedido passou. Mutantes, cada um sozinho e restaurado por sha1, vermelhos: o serviço
hidratando o POST cru (4 casos), a lista com o `status` (3) e o conserto mínimo -- só o `status` fora --
(1, o das marcas). Os vizinhos -- o cadastro, a API e a lista de clientes, a fidelidade, o crédito, o
D199, o D203 e o D420 --, 478 verdes nos dois bancos (3 pulados). O `PUT` da API passa pela mesma lista: os
casos 35 do ensaio de recusas, o 35n inclusive (o cadastro inteiro, conferido no banco), 33 de 33 no MySQL e
no PostgreSQL.

**Não visto no navegador:** a página e o modal foram despachados em processo.

### D439 — A edição do ERP reescreve o nome e o telefone do avulso e do "sem cadastro", que a API não deixa alterar — REGISTRADO em 07/10/2026 — CORRIGIDO em 07/11/2026

**Achado pela verificação do CADASTRO-DECISOES**, ao lado do D438. A API nova recusa alterar os dois
cadastros do sistema (`PUT /adm/api/clientes/1` ou `/2`: 409 `sentinela`, "Este cadastro é um marcador do
sistema e não pode ser alterado."), porque não são pessoas (o D18). O ERP antigo não recusa:
`editarAction` só barra o id 2 (`$cliente->getId() != 2`), e a página `editar/1` grava o nome, o telefone
e o resto no avulso; `salvarAction` não barra nenhum dos dois antes de `update()` -- o `getId() != 2` vem
depois da gravação e só decide o `sucesso` --, e o pedido do modal com o id 1 ou 2 grava nos dois. Desde o
D438 a edição não muda mais o ESTADO deles; os dados, sim.

**Quanto se alcança pela tela, lido no código:** pouco. Os dois estão inativos na cópia (o D394), e a busca
do PDV só traz cliente ativo (`ClienteRepository::autoComplite`); o "Sem Cadastro" do PDV pede
`pesquisar` com o id 2, que o recusa, e o modal abre vazio, como cadastro novo. O caminho que sobra é a
lista de clientes filtrada pelos inativos, com o "Editar" do avulso. Pelo pedido montado à mão, os dois.
Não medido: a cópia tem os nomes trocados pelo anonimizador, e não diz se o avulso já foi renomeado.

**A direção** (técnica, a regra da API, como o D394 fez com o estado): a página `editar` recusar os dois
ids com o 404 que já dá ao 2, e o `salvar` recusá-los antes de gravar, com a frase da API; um caso em
processo, vermelho antes. Ficou fora do item porque já existia antes dele e não é o estado.

**Corrigido em 07/11/2026 (a fatia 104), pela direção acima.** Conferido antes: `editarAction` ainda barrava só o 2, e
`salvarAction` gravava antes do `getId() != 2`. Agora a página `editar` recusa os dois (`self::SENTINELAS`) com o 404 que
dava ao 2, no GET e no POST; o `salvar` os recusa antes de validar e de gravar, com a frase da API ("Este cadastro é um
marcador do sistema e não pode ser alterado."), que o modal mostra pelo `data.mensagem` de sempre; e a lista não desenha
o "Editar" nem o nome como link no avulso (a página o recusaria). Provado nos dois bancos por
`D439SentinelasNaoSeEditamNoErpTest` (5 casos, os dois cadastros e o controle); os mutantes (a condição antiga `!= 2` no
`editar`, a recusa arrancada do `salvar`, a condição da lista) ficaram vermelhos e voltaram pelo sha1.

**A verificação da fatia (07/11/2026) achou o que a primeira rodada não via, e foi consertado no mesmo dia:**

- **O POST da página `editar` gravava no `id` do corpo.** A guarda conferia o cliente da ROTA, e o
  `ClienteService::update()` grava no `$data['id']` — o campo escondido do formulário, que nem o `Form\Cliente` nem o
  `ClienteFilter` validam. O POST para `editar/<um cliente comum>` com `id=1` no corpo passava pela guarda e
  reescrevia o nome e o telefone do avulso. Agora o `editarAction` grava no id da rota (`$dados['id']` trocado antes
  do `setData`): o corpo não escolhe mais quem. Caso: `testOPostDaPaginaGravaNoClienteDaRotaENaoNoDoCorpo` (a rota do
  comum com o 1 e com o 2 no corpo: o comum muda, os dois ficam como estavam).
- **Os outros links para a página `editar`** levavam o avulso ao 404 novo: a aba "Dados" do histórico de consumo, do
  histórico de pontuação e dos pacotes (a lista mostra os dois históricos do avulso), o relatório de descontos, as
  estatísticas do cupom e a lista de conveniados. Agora mostram o nome sem o link nos dois marcadores, pela regra
  `RegraDoCadastroDeCliente::eMarcadorDoSistema()`; a lista de vendas e os detalhes da venda, que já tiravam o link do
  avulso (`!= 1`), passaram à mesma regra e o tiram também do "sem cadastro". Casos:
  `testAsFichasDosMarcadoresNaoLinkamAPaginaEditar`, `testOsRelatoriosNaoLinkamOsMarcadoresParaAPaginaEditar` e
  `testOsDetalhesDaVendaNaoLinkamOsMarcadores`. Ficaram de fora, porque as consultas já tiram os dois: a tabela de
  clientes do NPS (`ContasDoNps::sentinela`) e o relatório de indicações (`ContasDoProgramaDeIndicacao::temSentinela`).
- **O PDV mostrava a recusa como "Campos Obrigatórios: Este cadastro é um marcador…".** O `salvar` manda agora
  `sentinela: true`, e o `pdvClienteSalvar` do `mod-vendas.js` (`?cache=168`) mostra a frase sozinha; as recusas da
  validação seguem com o prefixo. Caso: `frontend/src/telas/sentinelaNoPdvDaTelaAntiga.teste.ts` (a função do arquivo
  servido, sobre um jQuery de mentira); o modal da agenda (`mod-cadastros.js`) já mostrava a frase sozinha.

Os mutantes da verificação (o id do corpo de volta no `editar`, a aba "Dados" sem a condição, o link do relatório de
descontos sem ela, o dos detalhes da venda de volta ao `!= 1`, o ramo do `sentinela` arrancado do PDV) ficaram
vermelhos e voltaram pelo sha1. **Não visto no navegador.**

### D440 — O portão do relógio nos testes não vê `new DateTimeImmutable('+1 day')`, e dois testes leem o relógio assim — REGISTRADO em 07/10/2026 — PARCIAL em 07/11/2026: o portão vê o `new DateTime`/`DateTimeImmutable` e o `date_create` sem data absoluta, `gmdate`, `idate`, `microtime`, `hrtime`, `gmmktime`, `gettimeofday` e o nome com a barra, e tem ensaio próprio; o `amanha()` e o `RegrasDoMarketingTest` leem uma vez; falta trocar por `agora()`, caso a caso, o que der das leituras dos 12 arquivos do grupo "relógio da aplicação"

**Achado pela verificação do D437.** `scripts/conferir-relogio-nos-testes.php` (o D87) procura o relógio
do PHP pelo NOME da função -- `date`, `time`, `mktime`, `strtotime`, `getdate`, `localtime` -- e o do banco
nos textos do SQL. Não tem regra para o `new`: `new \DateTimeImmutable('+1 day')` lê o relógio tanto quanto
`strtotime('+1 day')`, e o portão sai com 0. Lido em `tests/Caracterizacao` em 07/10/2026, dois testes fazem
isso (os `new \DateTime($this->agora())` e os de data absoluta não leem relógio nenhum):

| arquivo | trecho | o que lê |
|---|---|---|
| `CampanhaAudienciaTest.php`, `amanha()` | `new \DateTimeImmutable('+1 day')` | o amanhã da campanha agendada, lido de novo a cada chamada, fora do instante único do `agora()` |
| `CupomTest.php` | `new \DateTime('-1 minute')` | um minuto atrás, comparado com a data de criação que a ENTIDADE carimba pelo relógio da aplicação -- ali o relógio de verdade é o certo, como nos casos que comparam com o "hoje" da aplicação |

**Por que importa.** É o D87 de volta pela porta que o portão não vigia: o `amanha()` relê o relógio, e um
caso que atravesse a meia-noite entre duas leituras vê dois amanhãs. E a medida mente: a varredura do D437
adiantou o relógio redefinindo `Testes\Caracterizacao\time()`, que o `agora()` chama, e esse `amanha()` não
se moveu nos 451 dias -- para o `CampanhaAudienciaTest`, ela mediu menos do que diz. O mesmo ponto cego
cobre `gmdate`, `idate`, `microtime`, `hrtime` e `date_create`, que nenhum teste usa hoje.

**A direção** (técnica, sem mudar código de produção): o portão ganha a regra do `new` -- `DateTime` ou
`DateTimeImmutable` sem argumento, ou com um texto que não seja data absoluta, é leitura de relógio --, o
`CupomTest` entra nas exceções com o motivo, e o `amanha()` passa a `$this->agora('d/m/Y', '+1 day')`;
falsificar com um `new \DateTime('now')` novo. Ficou fora do D437 porque é anterior a ele (é do D87) e não é
da agenda; o docblock de `DiaLivreNaAgenda`, que dizia que o portão "acusa quem ler o relógio por fora",
passou a dizer o que ele vê.


#### Em 07/11/2026, a fatia 102: o portão vê o objeto

**A regra do `new`** entrou em `scripts/conferir-relogio-nos-testes.php`: `new \DateTime`,
`new \DateTimeImmutable` (com ou sem a barra), `date_create` e `date_create_immutable` contam como leitura de
relógio quando vêm sem argumento, com `null`, ou com um texto que não diz o ano, o mês e o dia (`'now'`,
`'today'`, `'+1 day'`, `'-1 minute'`, `'15:00'`); com data absoluta (`'2026-03-15 00:00:01'`, `'@1700000000'`)
não contam. Argumento que não é um texto só (`$this->agora()`, uma variável, uma conta) o portão não julga.
E pelo nome, como o `date`: `gmdate`, `idate`, `microtime`, `hrtime`, `gmmktime` e `gettimeofday`.

**Eram dois testes em 07/10; em 07/11 eram 23 arquivos.** A suíte cresceu um mês sem o portão ver o objeto.
O `amanha()` do `CampanhaAudienciaTest` passou a `$this->agora('d/m/Y', '+1 day')`, como a direção dizia. As
outras foram lidas uma a uma e ficaram na `PERMITIDAS` com o motivo, em três grupos: **comparam com o relógio
da aplicação** (o `CupomTest`, que a direção já nomeava, e mais 12 arquivos em que a entidade, a API, a sessão
ou a rotina carimba o próprio "agora" e o caso precisa da mesma fonte); **regra pura num `TestCase` sem o
`agora()`** (`RegrasDoMarketingTest`, `RegrasDoRelatorioDeCaixaTest`: o instante é argumento da regra); e
**cronômetro** (`microtime`/`hrtime` em 9 arquivos medindo duração -- a espera da trava, o tempo-limite do
cliente, o começo do segundo seguinte --, que a virada da meia-noite não muda). Trocar as do primeiro grupo
por `agora()` é possível em parte, e não foi feito aqui: mudaria o que cada caso compara, um a um.

**Falsificado numa cópia do portão e da suíte** (o repositório não foi tocado): um `new \DateTime('now')`
novo sai 1 nomeando o arquivo; o mesmo para `new \DateTimeImmutable` sem parêntese, `new \DateTime()`,
`new \DateTime(null)`, `date_create('+1 day')`, `gmdate`, `idate` e `'today'` com fuso; o `amanha()` de volta
ao `new` sai 1; o `CupomTest` sem a leitura sai 1 (exceção sem uso); a data absoluta, o `@` com segundos, o
argumento `$this->agora()` e o `new` só num comentário saem 0. **E os mutantes do código**, cada um deixando
de acusar a leitura plantada: sem o bloco do `new`; o texto nunca julgado; o `new` sem parêntese não julgado;
só o nome sem a barra (`T_STRING` sem `T_NAME_FULLY_QUALIFIED`); todo texto válido tomado por absoluto.

**O que ele ainda não vê**, escrito no cabeçalho dele: o argumento que não é um texto só, e o
`createFromFormat` sem `!` nem `|`, que completa com o relógio o que o formato não diz.

#### Em 07/11/2026, a verificação da fatia 102: PARCIAL, e não CORRIGIDO

A fatia marcou CORRIGIDO com o corpo dizendo que uma parte não foi feita -- a troca por `agora()` das leituras
do grupo "relógio da aplicação". A regra da casa é PARCIAL quando resta parte. E a direção pedia **uma** exceção
(o `CupomTest`); entraram 22 leituras em 13 arquivos, por classificação da própria fatia. A classificação não
foi refeita aqui -- cada caso é uma troca do que ele compara --, mas uma delas estava errada:

- **o `RegrasDoMarketingTest` não era "regra pura sem relógio".** `agora()` (`new DateTimeImmutable('now')`) e
  `as()` (`new DateTimeImmutable('today')`) reliam o relógio A CADA CHAMADA, e o caso compara instantes montados
  em chamadas diferentes: `assertEquals(self::as('09:00', 1), RegraDaCampanha::comecoDoPrazo(self::as('20:30')))`
  lê 'today' duas vezes, e com a meia-noite entre as duas o esperado e o calculado saem de dias diferentes -- o
  próprio D87. Agora o instante é lido UMA vez, no `setUp`, e `agora()` e `as()` partem dele; a exceção desceu de
  2 para 1. O caso novo `testOInstanteDoCasoEUmSo` (o mesmo objeto nas duas leituras) cai com o `agora()` relido,
  e o portão sai 1 com o `as()` de volta ao 'today'.

**O nome com a barra.** `\microtime(`, `\date_create(` e `\date(` o PHP 8 entrega como um token só
(`T_NAME_FULLY_QUALIFIED`), e o portão só via o nome solto: era um ponto cego que o cabeçalho não dizia. Agora vê
(a barra com namespace no meio, `\Outro\microtime(`, não é a do PHP e continua de fora). Hoje nenhum teste usa a
forma.

**O ensaio do portão**, `scripts/ensaiar-portao-do-relogio.sh`, no `conferir-tudo.sh` e no job `verificacao`:
21 arquivos plantados num diretório temporário (o portão lê o diretório do primeiro argumento) -- 15 com uma
leitura, com a chave e a conta que ele tem de dar, e 6 sem leitura, que ele não pode acusar (a data absoluta, o
`@`, o `$this->agora()`, o comentário, o namespace alheio, outra classe). O `'2026'` sozinho é um dos que leem: o
PHP o toma por 20:26 de hoje. Os mutantes do código do portão, numa cópia apontada por `PORTAO_A_ENSAIAR`, cada
um com caso fora da saída esperada: sem o bloco do `new` (8), o `new` sem parêntese não julgado (1), o `null` não
julgado (1), todo texto tomado por absoluto (6), só `T_STRING` no `new` (7), o nome com a barra não visto (3),
sem o `gmdate` (1). Dois mutantes sobrevivem por serem equivalentes: o namespace no meio aceito (o nome com
namespace não casa com a lista) e a data sem o dia tomada por absoluta (`date_parse` sempre dá o dia quando dá o
mês).

**O que resta:** as leituras dos 12 arquivos do grupo "relógio da aplicação" (`CampanhaAudienciaTest`,
`CupomTest`, `D368...`, `D76...`, `IndicacaoDoFormularioTest`, `NotificacaoApiTest`, `RegistroDeAcessosTest`,
`RotasAntigasDoAplicativoTest`, `SaidaApiLeiturasTest`, `SenhaTrocadaTest`, `SessaoRetomadaPeloCookieTest`,
`UnidadeEscritaApiTest`): trocar por `agora()` o que der, um a um, sabendo que cada troca muda o que o caso
compara -- a entidade carimba o próprio relógio. As do cronômetro (9 arquivos) e a do `RegrasDoRelatorioDeCaixaTest`
(uma leitura só) ficam.

### D441 — O corredor local rodava 36 roteiros que o `ci.yml` não roda, e nenhuma conferência olhava essa direção — PARCIAL em 07/10/2026: 28 roteiros e os testes do front entraram no `ci.yml` e 8 ficam fora com o motivo; a prova no runner espera o CI voltar — PARCIAL em 07/11/2026: no runner (execução 42347466612) o job `verificacao` inteiro, os 2 do PostgreSQL e o `front` (4.663 casos) saíram verdes; faltam os 3 do `contrato`, que vêm depois da caracterização do MySQL — CORRIGIDO em 07/11/2026: os 3 do `contrato` rodaram verdes no runner (execução 42360871047)

**07/11/2026, a prova no runner.** Na execução 42360871047 (a8324f15), os dois jobs do MySQL rodaram "Toda unicidade do mapeamento tem indice que a sustente (D17)", "Ordenacao por coluna anulavel sem correcao (D46)" e "Os roteiros SQL do passo 3 do corte fazem o que o procedimento diz", os três `success`; o job do PostgreSQL rodou os 45 passos que não são condicionais, entre eles as telas, as rotinas de cron e o ensaio do D111; o `verificacao` rodou 65 de 65. Os 8 roteiros que ficam fora do `ci.yml` continuam fora, com o motivo de 07/10.

**Achado pelo levantamento de 03/10/2026, recontado em 07/10/2026.** O `conferir-lacuna-do-ci.sh` do
D187 pergunta o que o CI roda e o corredor não. A pergunta contrária ninguém fazia: portão novo nascia
no `conferir-tudo.sh` e ficava lá, e um PR que o quebrasse passaria verde no CI. Contado nos dois
arquivos, sem comentário: o corredor rodava 55 roteiros, o CI 39, e **36 só o corredor rodava** (o
levantamento dizia 31 de 50; contando a citação, eram 37 -- o 37º é o próprio `conferir-tudo.sh`, que
se cita num comentário). Entre eles, os dois ensaios do corte (`ensaiar-sql-do-passo-3.php` e
`ensaiar-crontab-do-passo-9.sh`) e o portão do D437.

**Por que importa.** O CI é a rodada desenhada para acontecer em máquina limpa a cada push -- quando a
conta deixa: desde 13/09/2026 nenhum job inicia (D187). O corredor depende de alguém rodá-lo. Portão
que só vive no corredor protege a máquina de quem o roda, e não o repositório -- a mesma doença do
D187, na outra direção.

#### O conserto

**Cada um foi classificado lendo o roteiro, e não pelo nome**, e medido num checkout limpo: só o que
o git versiona (sem `vendor/`, `local.php` nem `.env`), copiado para o `/tmp` do contêiner da
aplicação -- PHP 8.1, `dash` e `mawk`, como o Ubuntu do runner.

| onde | quantos | quais | como foi medido |
|---|---:|---|---|
| job `verificacao` | 23 | os textuais: `conferir-debitos-repetidos`, `-rotas-orfas`, `-resposta-cron`, `-entidade-duplicada`, `-ordenacao-empates`, `-tempo-limite-rede`, `-rastro-das-rotinas`, `-registro-de-acesso`, `-autorizacao-privilegiada`, `-relogio-nos-testes`, `-dia-do-cenario-da-agenda`, `-provedores-no-loopback` (o modo dos arquivos), `-relogio-do-banco`, `-injecao-dql`, `-cobertura-por-par`, `-fosseis`, `-unicidade-do-schema`, `-grupos-por-id`, `-escritores-do-horario`, `-inventario-atual.sh`, `-scripts-servidos`, `-acl-como-booleano` e `ensaiar-crontab-do-passo-9.sh` | os 23 com saída 0 no checkout limpo |
| job `contrato` (MySQL) | 3 | `conferir-relacoes-doctrine`, `conferir-ordenacao-nulos` e `ensaiar-sql-do-passo-3` (com `CONTEINER_MYSQL` = o contêiner do serviço do job) | os dois primeiros com 0 contra `descartavel_ci_espelho`, montado como o job monta o dele (`mysql.sql` mais as fixtures); o ensaio, com 0 no MySQL 8.4 daqui |
| job `caracterizacao-postgres` | 2 | `conferir-booleanos-em-consulta` e `conferir-booleano-em-coluna-inteira`, ao lado do irmão `conferir-tipos-do-mapeamento` | com 0 contra o PostgreSQL descartável montado como o do job; sem conexão nenhuma, os dois morrem com 255 (o Doctrine pergunta a plataforma ao banco), por isso não foram para o job sem banco |
| fora, na tabela `FORA_DO_CI` | 8 | ver abaixo | |

O comentário do `ci.yml` que dizia que o `conferir-booleano-em-coluna-inteira` e o
`conferir-relacoes-doctrine` precisavam da cópia de produção estava errado: medidos, não precisam.
Foi corrigido no mesmo lugar.

**Os oito que ficam fora, e por quê** (a tabela mora no roteiro; o motivo é o produto dela):

| roteiro | o que o runner não tem |
|---|---|
| `conferir-completude-da-copia` | a CÓPIA: nas fixtures ele se declara sem o que conferir e sai 0 |
| `conferir-unicidade` | a CÓPIA: sem ela sai 0 sem medir |
| `conferir-limpeza-da-unicidade` | a CÓPIA: nas fixtures sai 2 |
| `conferir-caminhos-postgres` | o docker do OPERADOR: lê o `local.postgres.php.dist` direto, com o host `postgres` do compose |
| `conferir-schema-tool.sh` | o docker do OPERADOR: `docker compose exec app`, e mede o esquema do banco de desenvolvimento |
| `conferir-passo-zero-do-corte` | o histórico do GIT: o checkout do CI é raso e ele sai 2 (decisão já escrita no cabeçalho dele) |
| `conferir-referencias-docs` | o DISCO de quem programa: os documentos citam o `local.php` e os diretórios de upload, que não são versionados -- 32 ausências no checkout limpo, saída 1 |
| `conferir-agendamento` | é o vermelho CONHECIDO do D50: no CI o job ficaria sempre vermelho |

**O `conferir-lacuna-do-ci.sh` ganhou a direção nova**, derivada dos dois arquivos a cada execução:
roteiro que o corredor roda, que o CI não roda e que a `FORA_DO_CI` não declara faz o roteiro sair com
1. E a declaração que envelheceu também -- linha da `DECLARADO` cujo passo já não é só do CI, ou da
`FORA_DO_CI` cujo roteiro passou a rodar no CI. Com `--so-declaracao`, ele confere só as duas
declarações e sai, sem rodar ensaio nenhum. É assim que o `conferir-tudo.sh` o chama, e é assim que o
job `verificacao` está escrito para chamá-lo -- escrito, e nunca executado no runner. **A primeira
versão do leitor lia texto, e não shell; a revisão do mesmo dia a refez** (abaixo), e o parágrafo
seguinte registra o que se provou dela.

**Provado, nos arquivos de verdade, um mutante por vez, devolvidos pelo sha256:** tirar do `ci.yml` o
passo do `conferir-dia-do-cenario-da-agenda` sai 1 nomeando-o; deixar o mesmo passo só num comentário
do YAML sai 1; uma linha `rodar ... scripts/conferir-xyz.php` no corredor sai 1; a mesma citação só
num comentário do corredor sai 0 (não roda); pôr o `conferir-unicidade` num `run:` sai 1 (a declaração
dele envelheceu); um passo novo só no CI sai 1, como antes. **E os mutantes do código, numa cópia do
roteiro:** sem o laço da `FORA_DO_CI`, a linha nova do corredor passa (0); lendo o CI inteiro, o passo
só comentado passa (0); sem o corte de ` #` no bloco, o passo comentado dentro do `run: |` passa (0);
sem a conferência da declaração velha, o `conferir-unicidade` no CI passa (0); lendo o corredor
inteiro, a citação em comentário vira chamada (1); juntando o comentário terminado em contrabarra à
linha de baixo, o `rodar` do passo 0 some e a declaração dele envelhece (1); sem o bloco do `run: |`,
o roteiro rodado dentro de um bloco com comentário e linha em branco no meio deixa de contar (1); e o
leitor do `run:` quebrado sai 2 ("o leitor quebrou"), e não 0. O leitor dos `run:` deu o mesmo no
gawk do Git Bash e no mawk do contêiner.

**Antes do conserto, o mesmo roteiro com a tabela nova e o `ci.yml` de antes saía 1 nomeando 28.**

#### A revisão do mesmo dia: o leitor lia texto, e não shell -- e não via o front

A revisão adversarial confirmou cinco defeitos no leitor dessa primeira versão, todos lidos no código
e reproduzidos em cópias:

1. **Os testes do front não existiam para a lacuna.** O `rodar 'testes do front' npm test --prefix
   frontend -- --maxWorkers=4` não cita roteiro de `scripts/`, e o leitor só extraía nome de roteiro: o
   maior portão do corredor (~1.600 casos) ficava fora do CI sem declaração nenhuma, e a lacuna saía 0.
   O mesmo valeria para qualquer `rodar` futuro fora do molde.
2. **O corredor só contava a linha que começa com `rodar`.** `[ -d frontend ] && rodar ...`,
   `if ...; then rodar ...; fi` e `php scripts/x.php > ... || falhou=...` rodavam no corredor e
   passavam calados -- e para a pergunta nova, contar a menos é o lado errado.
3. **E contava o que não roda.** O comentário no fim de uma linha `rodar` e o corpo de um `: <<'FIM'`
   viravam chamada: isentavam de declaração o passo novo do CI, ou mantinham viva a linha velha da
   `FORA_DO_CI`.
4. **No CI, `true;#php ...`, `echo 'php ...'`, `echo php ...` e `php ... || true` contavam como "o CI
   roda".** O corte de comentário só via o `#` depois de branco, e nada separava comando de texto.
5. **A conferência da declaração velha da `DECLARADO` lia comentário do `ci.yml`.** O passo tirado do CI
   e deixado num comentário mantinha a linha viva. O caso existia: o `rodar-contrato.sh`, que o `ci.yml`
   só cita num comentário, tinha a linha `SOZINHO` e entrava na conta "o CI roda".

E a saída de `--so-declaracao` afirmava "desde 13/09/2026 ele não inicia" -- o próprio CI imprimiria
isso no dia em que voltasse.

**O conserto: o leitor passou a ler shell como shell**, nos dois arquivos -- aspas, comentário no começo
de palavra (depois de branco ou de operador, `;#` inclusive), linha continuada, heredoc (o `: <<FIM` é
comentário em bloco) e os operadores que separam um comando do outro. **Cada pergunta lê do jeito que
erra para o lado seguro, e são quatro leituras:** "o CI roda isto como portão?" conta a menos -- não
conta o que está entre aspas, o `echo`, o comando antes de `||`, `&&`, `|` ou `&`, a condição de `if`,
o negado com `!`, o que vem depois de `set +e`, o corpo de heredoc, nem passo ou job com
`continue-on-error` ligado ou com `if:` que diga `false`, `failure()` ou `cancelled()`; "o corredor roda
isto?" conta a mais -- todo código fora de comentário; "o CI cita isto?" conta todo texto fora de
comentário, do YAML e do shell; "o corredor chama isto?" conta só o comando das chamadas de `rodar` e
`rodar_conhecido`, sem os rótulos. **O `rodar` sem roteiro de `scripts/` vale pelo comando**: o mesmo
texto tem de estar num `run:`, ou o comando entra na `FORA_DO_CI`. **E a declaração velha se mede com a
mesma leitura que a exige** -- senão uma linha seria exigida e velha ao mesmo tempo. A linha do
`rodar-contrato.sh` saiu da `DECLARADO`, e o relatório completo passou a apontar as suítes do PHPUnit,
que esta conta não vê. A saída de `--so-declaracao` diz que leu dois arquivos, e nada sobre o CI ter
rodado. As diferenças das quatro leituras saem numa passada só de `awk`: a primeira escrita do leitor
novo levava 3,4 s no Git Bash, quase tudo em criar processo, e o ensaio abaixo a roda 43 vezes; agora,
0,5 s.

**Os testes do front ganharam o job `front`** no `ci.yml`: Node 24 (o maior da máquina onde os casos são
medidos; o vitest 5 pede 22.12, 24 ou 26 em diante), `npm ci` e o MESMO comando do corredor, com
`TZ=America/Sao_Paulo` (só a agenda fixa o fuso; os outros casos com data foram medidos nele). **Nunca
rodaram no Linux**: o `node_modules` daqui é o do Windows, com o binário nativo do Vite 8 de lá, e baixar
outro não era deste item. O que foi conferido: os 484 imports relativos do front batem com a grafia
exata dos arquivos versionados -- no Windows `./Agenda` acha `agenda.tsx`, no runner não.

**Os casos saíram do rascunho: `scripts/ensaiar-lacuna-do-ci.sh`**, no corredor e no `verificacao`. São
43, cada um uma cópia dos três arquivos num diretório temporário com UMA mudança -- quase sempre
acrescentada (um job no fim do `ci.yml`, uma linha no fim do corredor, uma linha numa tabela) e com
nomes que não existem, para não envelhecer quando um passo de verdade muda de lugar; a única âncora é o
passo do front. Cada caso confere a saída E o motivo impresso: na primeira rodada dos mutantes, arrancar
o laço que exige a `FORA_DO_CI` ainda dava 1 -- a lacuna morria no `set -u` ao listar uma chave que a
tabela não tem --, e o ensaio contava isso como acerto. **Contra a lacuna anterior, 29 dos 43 saem fora
do esperado** (dois só pelo formato da linha velha); contra a nova, 0, no gawk do Git Bash e no mawk do
contêiner. **Os 28 mutantes do código** -- um por peça do leitor: o `#` que não abre comentário, o `;#`,
o nome entre aspas, o `||`, o `&&`, o pipe, o `echo`, o `continue-on-error`, o `if:`, a condição, o
`!`, o `then`, o `set +e`, o heredoc nos dois arquivos, o corredor lido só nas linhas de `rodar`, o
comando do `rodar` sem roteiro, os dois rótulos do `rodar_conhecido`, o comentário do YAML no texto
citado, o heredoc sem fim nos dois arquivos, o bloco `run: |`, a guarda contra verde sobre nada, as duas
declarações velhas e as duas exigências -- caem todos, cada um em pelo menos um caso.

#### O que resta, e por que é PARCIAL

**Nenhum passo novo rodou no runner** -- nem os 28 roteiros, nem o job `front`, nem a declaração da
lacuna e os dois ensaios do `verificacao`. O CI não inicia desde 13/09/2026 (D187), e já houve dois
ensaios escritos no `ci.yml` que nunca executaram. O checkout limpo no contêiner não é o runner: o PHP
do `setup-php` traz outras extensões, o MySQL 8.0 da matriz não foi medido (o ensaio do passo 3 só
rodou no 8.4 daqui), e o `docker exec` no contêiner do serviço, com `job.services.mysql.id`, só se
prova lá. **Os testes do front nunca rodaram no Linux**: o `node_modules` daqui é o do Windows, e o
job novo também custa minutos de runner na conta que está bloqueada. A primeira execução verde fecha
este débito -- e vale conferir que os 26 do `verificacao` (os 23, a lacuna e os dois ensaios), os 3 do
`contrato`, os 2 do PostgreSQL e o `front` aparecem nela.

**E três dos oito de fora entrariam com uma mudança pequena**, que não é deste item: o
`conferir-passo-zero-do-corte` com `fetch-depth: 0` no checkout (um clone de ~44 MB por execução); o
`conferir-caminhos-postgres` com o host e o banco por variável de ambiente; e o
`conferir-referencias-docs` aceitando a ausência do caminho que o `.gitignore` exclui (os 32 são todos
ignorados pelo git). O que a lacuna não vê está no cabeçalho dela: o nome do roteiro é a unidade, e não
a chamada (o `--conteiner` do portão dos provedores continua só no corredor); a primeira direção só vê
roteiro de `scripts/` (o `composer`, as suítes do PHPUnit e o `psql` do RLS ficam fora da conta); e o
shell é lido por aproximação -- subshell em segundo plano, `if:` com outra expressão, `shell:` trocado e
`run:` entre aspas do YAML não são modelados, e o bloco `>` é lido linha a linha.

**Decidido pelo dono em 08/10/2026 (pergunta 48 da página do lote):** a opção (a), *"Manter o
trabalho no CI"* -- "Os testes do front passam a barrar o push que os quebra, também para quem não
roda o conjunto local." O job `front` fica no `ci.yml` como está, com os minutos de runner que custa
quando a conta voltar; nada mudou no código. O débito continua PARCIAL pelo que o título diz: a
decisão era só sobre manter o job, e nenhum passo novo rodou no runner -- a primeira execução verde é
que o fecha.


#### Em 07/11/2026, a fatia 102: o runner rodou -- e quase tudo deste item já está provado lá

Com a cobrança de volta (D187), a execução **42347466612** (07/11, o commit dd0b4ae3) mostra:

- **o job `verificacao`** ("Sintaxe, composer e segredos") **verde, 64 de 64 passos** -- entre eles os 23
  roteiros textuais deste item, a declaração da lacuna ("O que só um dos lados roda está declarado, nas
  duas direções") e o ensaio dela ("O leitor da lacuna vê o que diz ver");
- **o job `front` verde: 4.663 casos em 199 arquivos**, no Linux, com Node 24 e `npm ci` -- a primeira vez fora
  do Windows e do Mac;
- **os 2 do PostgreSQL** (`conferir-booleanos-em-consulta` e `conferir-booleano-em-coluna-inteira`) verdes, no
  job da caracterização do PostgreSQL, antes da suíte.

**O que falta são os 3 do `contrato`** (`conferir-relacoes-doctrine`, `conferir-ordenacao-nulos` e
`ensaiar-sql-do-passo-3`): no job do MySQL eles vêm depois da caracterização, e ela caía nos dois casos de
fuso do D187 -- os seis passos seguintes ficaram pulados nas duas versões. Com o conserto do D187, a próxima
execução deve mostrá-los: **o que fecha este item é ver, no job do MySQL 8.4 e no do 8.0, "Toda unicidade do
mapeamento", "Ordenacao por coluna anulavel" e "Os roteiros SQL do passo 3 do corte" verdes** (o último com
o `docker exec` no contêiner do serviço, que só lá se prova).

**E o job do PostgreSQL inteiro** (a verificação da fatia 102). Na 42347466612 ele teve 17 passos verdes, 1
vermelho e **28 pulados**: tudo o que vem depois da caracterização -- as telas, o cron, as rotas públicas, o
erro JSON, o fechamento da agenda, os 21 ensaios da `/adm/api` e do aplicativo, o medidor da `/adm/api` e os
relatórios. Eles não rodam inteiros no runner desde 15/10/2026 (o piso das telas), e desde 17/10 nem começam
(a concessão que batia na chave; ver o D187). O passo das telas e a ordem dos ensaios foram consertados e
repetidos aqui na ordem do runner (31 de 31 saída 0); a prova que fecha é a do runner: **o job "Contrato e
caracterização — PostgreSQL" com os 46 passos verdes e nenhum pulado** -- em especial "Rotinas de cron" e "O
fechamento da agenda obedece a validacao (D111)", os dois que a repetição local não roda.

### D442 — O `pre-push` decidia ler o stdin pelo terminal, e fora dele o `read` esperava para sempre — CORRIGIDO em 07/10/2026

**Achado pelo uso.** O hook decide se o git o chamou por `[ ! -t 0 ]`: "o stdin não é terminal, então
são as linhas do git". Fora de terminal isso mente -- no Bash de um agente ou de uma IDE, o stdin é
um pipe que não fecha, e o `while read` ficava esperando para sempre. **Mas só na conferência à mão.**
Num `git push` isso nunca travou: o git abre um pipe só para o hook, escreve as linhas e o fecha, e o
stdin de quem chamou o push não chega lá. A revisão mediu, num repositório de rascunho com remoto
`bare` também de rascunho, o hook ANTIGO e um stdin de fora que não fechava: `git push` saiu com 0 e
"conferindo 1 commit(s)"; o mesmo hook chamado à mão, sem argumento, estourou o `timeout` (124). A
regra que a casa adotou -- rodar `sh .githooks/pre-push < /dev/null` antes de todo push -- era para a
conferência à mão; o push em si nunca precisou do redirecionamento.

**O conserto.** O git SEMPRE chama o `pre-push` com dois argumentos -- o nome do remoto e a URL -- e
as linhas no stdin; chamado à mão, não há argumento. A decisão passou a ser `[ "$#" -ge 2 ]`: sem os
dois argumentos nada é lido e ele confere o HEAD; com eles, lê o stdin como antes. O cabeçalho do hook
diz isso, e diz que quem o chama à mão COM os dois argumentos passa as linhas, ou `< /dev/null` para o
HEAD.

**Provado sem `git push` nenhum, com `timeout` e em segundo plano.** O stdin que não fecha foi um
`sleep 40 |` na frente do hook -- no Bash desta sessão o stdin já era `/dev/null`, e a trava não
aparecia a olho:

| caso | saída |
|---|---|
| (a) sem argumento, com o stdin que não fecha | 0, "conferindo 1 commit(s)" (o HEAD) |
| (b) `origin <url>` e uma linha no formato do git com a sha remota = `HEAD~2` | 0, "conferindo 2 commit(s)" |
| (c) ramo apagado (sha local zero) | 0, nada conferido, nada impresso |
| (d) MUTANTE: a decisão de volta a `[ ! -t 0 ]`, numa cópia do hook, no caso (a) | **124** (o `timeout` de 20 s) |
| controle do mutante: o mesmo, com `< /dev/null` | 0 |

#### A revisão do mesmo dia: o verde calado da chamada à mão, e a prova que ficou em rascunho

**Chamado à mão sem os dois argumentos, o hook ignorava o stdin em silêncio.** Quem passasse as linhas
de uma faixa (`sh .githooks/pre-push < linhas.txt`, sem `origin <url>`) recebia "conferindo 1
commit(s)" e saída 0 -- um verde sobre o HEAD, achando que tinha conferido a faixa. O hook antigo lia
essas linhas. Nenhum roteiro versionado chamava o hook assim, então era armadilha, e não regressão.
**Agora ele diz, no stderr:** "pre-push sem os dois argumentos do git: o stdin nao e lido, e confiro so
o HEAD", com a forma certa de conferir uma faixa. O cabeçalho do hook passou a dizer o que o parágrafo
de cima diz: quem travava era a conferência à mão, e não o push.

**A prova saiu do rascunho: `scripts/ensaiar-pre-push.sh`**, no corredor e no job `verificacao`. Ele
monta um repositório de rascunho num diretório temporário, com três commits, o hook copiado e o
`conferir-segredos.sh` trocado por um dublê que anota cada commit e recusa o que estiver em
`REPROVAR`; nenhum `git push`. Os sete casos: à mão, sem argumento, com um stdin que não fecha (um
`sleep` por substituição de processo, morto no fim) -- termina, confere o HEAD e avisa; à mão, com a
faixa num arquivo -- confere só o HEAD e avisa; como o git, a faixa c1..c3 -- c2 e c3; ramo novo -- os
três; ramo apagado -- nada; os dois argumentos e o stdin vazio -- o HEAD; e o portão recusando o commit
do MEIO da faixa -- saída 1. **O ensaio pega:** o hook do HEAD (`[ ! -t 0 ]`) sai 124 no primeiro caso
e confere c2 e c3 calado no segundo; o hook da primeira versão deste débito, sem o aviso, cai nos dois
primeiros; e três mutantes do hook novo -- o ramo apagado não pulado, sempre o HEAD, o portão que
recusa e o push que passa -- caem em 1, 4 e 6 casos. Verde no Git Bash e no `dash` do contêiner.

### D443 — Os guias da homologação e do teste davam portas que colidem, um `~` que muda com o usuário e uma contagem de tabelas velha — CORRIGIDO em 07/10/2026

Achado pelo levantamento de 03/10/2026 (a parte "documentos que mentem" do item
CODIGO-MORTO-E-DOCS) e conferido em 07/10/2026, antes de mexer. Só documentação e o
modelo de `.env` do teste: nada rodou no BVIP, que está recebendo a cópia do teste agora.

- **As portas.** O `.env.teste.example` publicava o app em `127.0.0.1:8090` e o Adminer
  em `8091` -- exatamente o par que a `docs/ambiente-homologacao.md` ("Conflito de porta
  com o nginx do host") manda a homologação usar quando a 8080 está ocupada pelo nginx
  do BVIP. As duas pilhas moram no mesmo servidor. **Conferido que as portas são só do
  exemplo e dos guias:** o `scripts/deploy-teste.sh` lê a porta do `.env` (`conferir_env`),
  copia o exemplo só quando não há `.env` (`preparar`), e o exemplo não está nos cinco
  arquivos do selo (`ARQUIVOS_DA_COPIA`), nas `CAMADAS` nem na carga (`VERSAO_DA_CARGA`).
  **Trocadas por 8092 e 8093**, depois de procurar as duas em todo compose, `.env` de
  exemplo, guia e roteiro: nenhuma ocorrência. O laboratório de migração usa hoje as da
  homologação (D431); a pergunta do clone próprio dele (a 37 do lote) foi respondida em
  07/10/2026, e nenhuma porta dele está escrita ainda -- quem escrevê-las precisa desviar
  destas.
- **O `~`.** A `docs/ambiente-teste.md` dava `~/franquiabv-teste`, `~/base-teste.sql.gz`
  e a trava `~/.franquiabv-teste.trava` como lugares fixos, e desde a v9 (02/10/2026) o
  script roda também como root: aí o `~` é `/root`, o `scp ... usuario@bvip:~/` deixa a
  cópia em `/home/usuario`, e a trava (`$HOME/.franquiabv-teste.trava` no script) é uma
  por usuário -- a frase "só um roda por vez no servidor" não valia entre root e outro
  usuário.
- **A contagem de tabelas.** A `docs/ambiente-homologacao.md` dizia 83, e a lista de PII
  "das 83" deixava 74 como "transacionais ou de referência". Medido em 07/10/2026: o
  `mysql.sql` tem **85** `CREATE TABLE` (83 de 16/08 a 02/09; `clientes_sessoes` entrou
  em 03/09 e `usuarios_horarios` em 02/10, pelo `git log`); a cópia `franquiabv_dev` tem
  **87** (`SELECT COUNT(*) FROM information_schema.tables`, só a contagem): as 85, mais
  `sessoes` e `_fixtures_versao`. E a lista de PII não tinha `clientes_sessoes` (o
  telefone do cliente e o hash do token do aplicativo), nem o que o anonimizador e o
  endurecimento do teste tratam a mais (`convenios`, `indicacoes`, `pontuacoes_historicos`,
  `produtos_estoque`, `royalties_faturas`).

#### O conserto

- `.env.teste.example`: `APP_PORT=127.0.0.1:8092`, `ADMINER_PORT=127.0.0.1:8093`, o túnel
  do comentário junto, e o porquê escrito em cima. O `conferir-provedores-no-loopback.php`,
  que lê este arquivo, segue com saída 0.
- `docs/ambiente-teste.md`: as portas novas no túnel e na URL da rotina à mão, e o aviso de
  que **um `.env` gerado antes de 07/10/2026 segue com 8090 e 8091** -- vale o que estiver
  nele, e a página diz como conferir; o `~` dito como o `$HOME` de quem roda, com o caso
  do root; e a trava como uma por usuário.
- `docs/ambiente-homologacao.md`: a contagem medida, com as datas, ao lado do "83" de
  então (que estava certo em 19/08); a lista de PII completada, apontando os dois roteiros
  como a lista que vale; e, na sugestão de 8090 e 8091, o aviso de que o teste as usava
  até esta data.
- `docs/ambiente-desenvolvimento.md` (na verificação do mesmo dia): o guia irmão dizia, no
  presente, que o banco sobe "com as 82 tabelas vazias" -- o número de 15/08 (82 no
  `eaf1fca`; 83 em 16/08, 84 em 03/09, 85 em 02/10, pelo `git log` do `mysql.sql`). Agora
  diz que são as do `mysql.sql`, 85 em 07/10/2026, e como contá-las. A linha datada do
  mesmo guia ("executado de ponta a ponta em 15/08/2026 ... as 82 tabelas são criadas")
  ficou: é o retrato daquele dia.

**O que não se sabe daqui:** se o `.env` do teste no BVIP tem 8090 e 8091, e se a
homologação de lá usa a 8080 ou o par da sugestão. O `preparar` recusa porta ocupada (`ss
-tln`) quando a pilha do teste está parada, e uma colisão com a homologação de pé teria
parado a subida -- mas só quem está no servidor confere.

**A trava do script tem o mesmo erro que o guia tinha**, e ficou no **D444**: a página
agora diz "uma por usuário", e a recusa do `deploy-teste.sh` ainda diz "neste servidor".

### D444 — A trava do `deploy-teste.sh` é uma por usuário, e a recusa diz que é uma por servidor — CORRIGIDO em 08/11/2026: uma trava só, em /var/lock

Achado na verificação do item CODIGO-MORTO-E-DOCS (07/10/2026), lido no código. O
`scripts/deploy-teste.sh` abre a trava em `"$HOME/.franquiabv-teste.trava"` (o `exec 9>`
logo depois do comentário "UM DEPLOY POR VEZ"), e o `$HOME` é o de quem roda: o root trava
`/root/.franquiabv-teste.trava`, e outro usuário trava a do próprio `$HOME`. O comentário
promete um deploy por vez, e a recusa imprime "outro scripts/deploy-teste.sh está rodando
**neste servidor**". Com o root e outro usuário rodando ao mesmo tempo, nenhum dos dois é
barrado -- e a trava existe justamente para isso não acontecer ("um `publicar` rodando
junto com um `base` religava o site no meio da carga"). A `docs/ambiente-teste.md` foi
corrigida no D443 e já diz "por usuário", com o conselho de rodar sempre com o mesmo; o
código não.

**Não foi mexido, de propósito:** a cópia do teste está subindo no BVIP com este script, e
mudar a trava muda como ele se comporta lá. Nada no servidor é afetado enquanto o clone de
lá não for atualizado -- e é aí que a troca precisa ser pensada.

**O conserto, quando a cópia estiver de pé:** o mínimo é a mensagem e o comentário dizerem
"deste usuário". O inteiro é uma trava só por servidor, num caminho fixo fora do `$HOME`,
com a permissão pensada para que o root e o outro usuário abram o MESMO arquivo (um
arquivo criado pelo root sem escrita para os outros faria o `exec 9>` do outro usuário
falhar, e o script aborta nesse caso). Só se prova no BVIP, com dois usuários.

#### CORRIGIDO em 08/11/2026 -- a fatia 129 da fila dos débitos, DEPLOY-TESTE-E-PUBLICAR

Conferido antes: o `exec 9>"$HOME/.franquiabv-teste.trava"` seguia lá. O `scripts/deploy-teste.sh` passa a travar
`/var/lock/franquiabv-teste.trava` (o `/run/lock` do Debian e do Ubuntu, 1777), um caminho fixo fora do `$HOME`, e a recusa
e os comentários dizem o que é ("neste servidor (a trava ..., do grupo docker)"). **A permissão pensada** (refeita na
verificação da fatia, abaixo): o arquivo é **do grupo `docker`, modo 640** -- quem roda o deploy roda o `docker compose`, e
está no grupo, ou é o root -- e é aberto **só para ler** (`exec 9<`); o `flock` trava um descritor de leitura igual. Com o
`exec 9>` (O_CREAT e O_TRUNC), o usuário que não criou o arquivo falhava: pela permissão do dono e, num diretório 1777, pelo
`fs.protected_regular` do kernel (o padrão do systemd), que nega o O_CREAT sobre arquivo alheio. O `tirar` segue tirando sem a
trava quando ela não abre. O que a trava protege não mudou, nem a carga e o selo.

**A verificação da fatia (08/11/2026) achou dois furos na primeira versão, consertados:** (1) criada por `( : >> )`, a trava
nascia com o **umask** de quem rodava primeiro: o root com umask 077 a deixava 600, e o outro usuário saía 1 com "não consegui
abrir" à toa; (2) legível por todos num `/var/lock` 1777, **qualquer usuário do servidor** -- o `www-data` de outro vhost, o de
outro serviço -- podia segurá-la (`flock <a trava> sleep infinity`) e barrar todos os subcomandos, **o `tirar` também**, o freio
do site com dado pessoal (o D326); um arquivo plantado no lugar dela (modo 000) ou um link pendurado (que o `: >>` do root
seguiria, salvo pelo `fs.protected_symlinks`) faziam o mesmo. Agora ela nasce inteira por um temporário do `mktemp`, com
`chmod 640` e `chgrp docker`, ligado ao nome pelo `ln` (que não segue link nem sobrescreve), e só se abre se for um arquivo
comum do grupo `docker` fechado para os outros; a que não é assim é **alheia**: o root a apaga e refaz, quem não é root para
e diz o `sudo rm -f`, e o `tirar` segue sem ela, com o aviso. Segurá-la, agora, só quem está no grupo `docker` -- que já manda
no servidor. Um descuido achado pelo ensaio no caminho: o `exec 9<... 2>/dev/null` mandava o stderr do roteiro inteiro para o
nada (as recusas saíam caladas); o `2>/dev/null` foi para uma chave em volta do `exec`.

**Provado num contêiner Linux descartável com três usuários** (`scripts/ensaiar-trava-do-deploy-teste.sh`, no
`conferir-tudo.sh`; fora do CI, que não tem a imagem do app), 14 casos: o root e o usuário `outro` (do grupo `docker`), cada
um no seu clone; com um segurando a trava, o outro sai 1 com a recusa, sem chegar adiante, nos dois sentidos; com a trava
livre e criada pelo outro dono, cada um passa, e ela é do grupo `docker`, 640; o root com umask 077 a cria igual, e o outro
passa; nenhum arquivo de trava no `$HOME` de ninguém; o `estranho` (fora do grupo) não a abre, e o `tirar` do root passa com
ele tentando; a trava plantada pelo estranho (644 e segura por ele, e depois 000) faz o outro sair 1 com o `sudo rm -f`, o
`tirar` do outro seguir sem ela, com o aviso, e o root apagá-la e refazê-la; e o link pendurado no lugar dela não cria o alvo.
Mutantes da primeira versão: o roteiro do HEAD (a trava no `$HOME`), vermelho em 8 conferências; o `exec 9>` no lugar do
`9<`, vermelho em 3. Os da verificação, um por vez sobre cópias, todos vermelhos: o modo 644, o `chgrp` arrancado, o root sem
refazer a alheia, o `tirar` sem seguir, a conferência da alheia arrancada e o `( : >> )` de volta antes do `mktemp`. O caso do
link pendurado sozinho não fica vermelho com o `: >>` de volta, porque o kernel daqui tem o `fs.protected_symlinks` em 1 (o
mutante cai pelos outros casos).

**Não provado:** o BVIP; o grupo `docker` de lá e quem está nele (quem roda o deploy sem estar no grupo sai 1, dizendo
isso); e o `fs.protected_regular` ligado (o kernel da máquina virtual do Docker daqui o tem em 0; o roteiro não depende dele,
porque não abre com O_CREAT). **Na passagem**, o `atualizar` rodado com o script de antes ainda trava o
`$HOME` e passa o descritor ao `_aplicar` da árvore nova: uma rodada do script novo no meio dele não é barrada (dito na
`docs/ambiente-teste.md`).

### D445 — O PostgreSQL dos contêineres tem 64 MB de memória compartilhada, e o VACUUM paralelo de uma tabela grande falha — REGISTRADO em 08/10/2026

Achado ao medir o D220 (08/10/2026), num banco descartável do PostgreSQL local com uma cópia
de `agendas` (4.000.084 linhas, cinco índices). O `VACUUM ANALYZE` comum -- que limpa os
índices em paralelo quando a tabela tem dois ou mais grandes -- parou com:

```
ERROR:  could not resize shared memory segment "/PostgreSQL...." to 67145344 bytes: No space left on device
```

O `/dev/shm` do contêiner tem **64 MB** (`df -h /dev/shm`), o padrão do Docker, e o
`maintenance_work_mem` de 64 MB pede um segmento desse tamanho para os trabalhadores. Com
`VACUUM (PARALLEL 0, ANALYZE)` passou em 1,2 s. Os quatro `CREATE INDEX` da mesma tabela, com
dois trabalhadores, passaram.

Nenhum compose do repositório define `shm_size`: o serviço `postgres` do
`docker-compose.yml` sobe com o padrão, e o da homologação -- o laboratório da migração --
só lhe põe teto de CPU e memória. O `autovacuum` não usa trabalhadores paralelos e não é
afetado; o que falha é o `VACUUM` dado à mão, que é o passo de costume depois de uma carga.

**Não medido:** se alguma consulta do sistema pede segmento maior que 64 MB (um hash
paralelo grande daria o mesmo erro), e em que máquina o PostgreSQL de produção vai rodar.

**Não mexido, de propósito:** os compose servem a todos os ambientes, e a cópia do teste está
subindo no BVIP. O conserto é uma linha (`shm_size`, por exemplo `1g`, no serviço `postgres`),
ou, sem tocar o compose, `VACUUM (PARALLEL 0)` nos roteiros que vierem a dar `VACUUM`. O
`docker-compose.yml` é uma das camadas que o `deploy-teste.sh` sobe no BVIP, e por isso a escolha
foi ao lote como pergunta (na verificação, 08/10/2026).

**Decidido pelo dono em 08/10/2026 (pergunta 52 da página do lote):** a opção (a), *"shm_size: 1g no
serviço postgres, depois que a cópia do teste estiver de pé no BVIP"* -- "Uma linha, vale para todos
os ambientes, inclusive a homologação." **Não aplicada ainda, de propósito:** a condição é a cópia do
teste de pé no BVIP, e daqui não se sabe se ela já está -- é a pergunta 57 da página do lote. Até lá o
`docker-compose.yml` fica como está, e o `VACUUM` à mão de tabela grande continua pedindo
`(PARALLEL 0)`.

### D446 — A página da adesão promete pontos na primeira compra do amigo, e a etapa 6 paga 10 pontos no envio, com amigo ou sem — REGISTRADO em 08/10/2026 — PARCIAL em 10/10/2026: o texto e os 10 pontos no lote — CORRIGIDO em 11/10/2026

**Achado na verificação do item MEDICOES-LOCAIS-E-D220** (08/10/2026; lido no código, e contado na
cópia, só leitura). A etapa 6 da página pública da adesão (`fidelidade/cadastro.phtml`) diz
"Indique um amigo e ganhe pontos na primeira compra dele". O código faz outra coisa:

- **Os 10 pontos saem no envio.** `FidelidadeController` chama
  `salvarPontuacaoCadastro($cliente, $request['etapa'])` em todo POST válido, e a validação da etapa
  6 aceita os campos do amigo vazios: quem pula a indicação ganha os mesmos 10 pontos.
- **A conversão do amigo não paga nada.** Quando o amigo completa a etapa 3,
  `FidelidadeService.php:216` chama `salvarPontuacaoCadastro($indicador, 6)`, que procura o histórico
  pela descrição "Completou a etapa 6 do cadastro de adesão." -- o mesmo do envio -- e devolve
  `false`. Só pontua o indicador que não tem o histórico (o de um amigo pendente de antes de
  14/04/2026, quando os históricos da etapa 6 começam na cópia).
- **A primeira compra também não.** A comissão da primeira compra
  (`VendaPagamentoService::gerenciarComissaoIndicacaoPrimeiraVenda`) sai da ligação
  `clientes.indicacao`, que só o convite do aplicativo grava (`AplicativoService.php:475`); o amigo
  da etapa 6 nasce sem ela.

| na cópia (até 16/08/2026) | |
|---|---|
| clientes que mandaram a etapa 6 desde 14/04/2026 (10 pontos cada) | 493 |
| ... desses envios, os que criaram um amigo | 110 |
| amigos da etapa 6 ainda pendentes com a ligação `indicacao` | 0 de 183 |
| clientes com a ligação `indicacao` (o convite do aplicativo) | 6; `indicacoes` tem 10 linhas, 2 com a primeira compra |

Qual é a regra -- pontos no envio, pontos quando o amigo completa o cadastro, ou a comissão da
primeira compra, como a página diz -- é decisão do dono: foi ao lote. Nada mudou no código.

**Decidido pelo dono em 08/10/2026 (pergunta 53 da página do lote):** a opção (c), *"A comissão da
primeira compra, como a página diz"* -- "A etapa 6 grava a mesma ligação do convite do aplicativo;
mexe na comissão da unidade." O indicador ganha a comissão da unidade na primeira compra do amigo, pela
ligação `clientes.indicacao` que hoje só o convite do aplicativo grava. A recomendação era a (b); a
escolha foi a que a página já promete. O que acontece com os 10 pontos que o envio dá hoje, com amigo
ou sem, a opção não diz. A implementação é o item R16, junto com o freio da pergunta 51 (o D304), e não
este registro; nada mudou no código aqui.

#### O conserto (10/10/2026, o item ADESAO-ETAPA-6: a resposta 53) -- e o que ficou no lote

O amigo indicado na etapa 6 nasce com a ligação do convite do aplicativo, campo por campo
(`AplicativoService::indicacaoCadastrar`): a linha em `indicacoes` (a unidade e o cliente de quem indica, o convidado,
o link da adesão dele, `data_convite`) e o `clientes.indicacao` do amigo apontando para ela (`FidelidadeService::indicarAmigo`;
o freio e a ordem das regras são o D304). O pagamento não mudou: `VendaPagamentoService::gerenciarComissaoIndicacaoPrimeiraVenda`
já pagava, na primeira venda finalizada do convidado, o percentual `unidades.indicacao_comissao` da unidade da venda
sobre o total, em **crédito em dinheiro** do indicador naquela unidade (`clientes_creditos.valor`), e marcava a ligação
(`data_venda`, `comissao`, `primeira_venda`) -- é a marca que faz a segunda compra não pagar. Na cópia, 32 das 36
unidades ativas têm o percentual (de 10 a 30%); nas 4 sem ele, a primeira compra marca a ligação e não paga nada.
Quando o amigo completa a etapa 3, a ligação ganha `data_adesao` (o `setDataAdesao` que o serviço já chamava) --
e o amigo da etapa 6 quase nunca a completa: nasce sem país, e a página o leva da etapa 2 à 4 (o D460). O
relatório Programa de Indicação e a lista de indicações do aplicativo passam a mostrar os amigos da etapa 6 indicados
daqui em diante; os 183 pendentes de antes seguem sem a ligação (nada foi gravado neles).

**Provado nos dois bancos** (o arquivo do D304, `D304EtapaSeisDaAdesaoTest`): o amigo novo tem a ligação, com o link
que a mensagem leva; com 10% na unidade, a primeira compra de R$ 100,00 dá R$ 10,00 de crédito ao indicador e marca a
ligação, e a segunda, de R$ 50,00, não paga de novo; e os 10 pontos do envio seguem como estavam, com amigo, sem
amigo e com o telefone de cliente.

| mutante (sozinho, restaurado, no PostgreSQL) | caso vermelho |
|---|---|
| o amigo sem o `setIndicacao` | o amigo novo (sem a ligação); na versão final do teste, os casos da comissão caem antes, em "o amigo tem a ligação" (rodado de novo na verificação) |
| a etapa 6 sem os 10 pontos (o `salvarPontuacaoCadastro` trocado por `false` na etapa 6) | os dez casos que conferem os pontos do envio |

**PARCIAL: o que ficou no lote, com a recomendação.** (1) Os 10 pontos do envio da etapa 6 continuam como estavam,
com amigo ou sem: a 53 não falou deles, e a pergunta foi ao lote (continuam, saem, ou só quando o amigo for indicado;
a última, só se "indicado" valer também para o telefone de cliente, ou a resposta contaria quem é cliente). (2) O texto
da etapa 6 ("ganhe pontos na primeira compra dele") não mudou: a comissão é crédito em dinheiro, e não pontos; a
promessa é do dono, e a pergunta foi junto. Lido no código e posto na mesma pergunta: o agradecimento diz "você ganhou
50 pontos", e no Brasil o cadastro inteiro dá 60 (seis etapas; fora do Brasil a etapa 3 é pulada, e são 50).

#### A verificação (10/10/2026): os 10 pontos por compra, as duas guardas da comissão, o indicador inativo

1. **Cada compra do amigo da etapa 6 também dá 10 pontos a quem indicou, junto com a comissão.** Em toda venda
   finalizada, o `VendaPagamentoService::salvarPontuacao` lê a origem do cliente ("Indicação - <id>") e dá 10 pontos ao
   id, com o histórico "Somou mais 10 ponto(s), indicação do ..." preso à venda -- sem guarda de primeira compra. Só a
   etapa 3 troca a origem para "Indicação", e o amigo da etapa 6 quase nunca chega a ela (o D460); o convidado do
   aplicativo nasce com a origem "Indicação" e só gera a comissão. Logo a primeira compra do amigo da etapa 6 paga a
   comissão E 10 pontos, e cada compra seguinte, mais 10. Já era assim antes do conserto: o "a primeira compra também
   não" do achado, acima, deixou isto de fora (o D284 o conhecia), e a pergunta 53 partiu dessa premissa. Na cópia,
   79 desses históricos, de 70 indicadores, o último em 31/07/2026. O caso da comissão confere agora os pontos de quem
   indicou (20, das duas compras). Se os dois prêmios continuam juntos é do dono: foi ao lote, e a pergunta do texto
   ganhou a correção da premissa.
2. **"Uma vez só" tem cada guarda provada sozinha.** A comissão tem duas guardas: a marca na ligação (`data_venda`) e a
   contagem das vendas finalizadas do convidado. Na segunda compra do caso de antes, as duas seguravam, e tirar
   qualquer uma deixava verde. Dois casos novos: a primeira compra cancelada e refeita (só a marca segura -- e a
   comissão fica com quem indicou: o D459, caracterizado sem decidir) e o amigo com uma venda finalizada de antes da
   ligação (só a contagem segura).
3. **O indicador inativo liga o amigo.** O convite do aplicativo recusa o indicador inativo e o cadastro do sistema de
   número 2 ("Cliente indicador inválido ou inativo."); a etapa 6 copiou a ligação, e não essa condição. Não é caso
   raro: o amigo da etapa 6 conclui a adesão inativo (o D460) e, quando indica, indica inativo. Caracterizado num caso;
   se o indicador inativo ganha a comissão é do dono -- foi ao lote, com a recomendação de deixar como está até o
   amigo passar pela etapa 3.

| mutante (sozinho, restaurado, no PostgreSQL, contra a versão final) | caso vermelho |
|---|---|
| a comissão sem a guarda da marca (`data_venda`) | a primeira compra cancelada e refeita: a refeita pagaria de novo |
| a comissão sem a guarda da contagem | a compra que não é a primeira, com a ligação sem a marca |
| a compra do amigo sem os 10 pontos de quem indicou | o da comissão (os 10 pontos de cada compra) |
| a etapa 6 recusando o indicador inativo, como o aplicativo | o do indicador inativo |
| o amigo sem o `setIndicacao` (de novo) | o amigo novo; os três da comissão ("o amigo tem a ligação"); o do indicador inativo |

**Os mesmos PARCIAIS, e mais três perguntas no lote:** (3) os 10 pontos por compra junto com a comissão -- recomendo
só a comissão para o amigo com a ligação, e os 10 pontos para os de antes, que não têm outro prêmio; (4) o indicador
inativo; (5) a comissão da compra cancelada (o D459). E a pergunta do país do amigo (o D460), que se cruza com as três.

#### A quarta rodada (11/10/2026, o item ADESAO-QUARTA-RODADA: as respostas 63 a 68 do dono)

**Decidido pelo dono em 11/10/2026 (a página do lote), os cinco parciais:** (1) a **63**, opção (a): os 10 pontos do
envio da etapa 6 continuam como estão, com amigo ou sem -- nada muda; (2) a **64**, opção (a): o texto da etapa 6 passa
a ser "Indique um amigo e ganhe crédito na primeira compra dele" (o agradecimento dos 50 pontos não foi perguntado, e
não mudou); (3) a **65**, opção (b): só a comissão para o amigo que TEM a ligação -- a venda dele não dá mais os 10
pontos por compra a quem indicou --, e os amigos de antes, sem a ligação, seguem dando os 10 pontos; (4) a **67**,
opção (c), e **não a recomendada**: com quem indica inativo (e o cadastro do sistema, que o aplicativo também recusa),
o amigo nasce e a mensagem sai, mas SEM a ligação -- sem comissão --, e o teto passa a contar também essas indicações
(o D304); (5) a **68**, opção (c): a comissão da compra cancelada fica como está (o D459). O país do amigo, a 66, é o
D460.

**O conserto.**

- **O texto** (`fidelidade/cadastro.phtml`, a etapa 6): "pontos" virou "crédito"; o arquivo segue em CRLF, como no
  repositório.
- **Os 10 pontos por compra** (`VendaPagamentoService::salvarPontuacao`): a origem "Indicação - <id>" só dá os 10 pontos
  quando o comprador não tem a ligação (`clientes.indicacao` nulo). O amigo da etapa 6 que nasce com a ligação (desde a
  resposta 53) dá a quem indicou só a comissão da primeira compra -- e nas 4 unidades sem percentual, nada; o convidado do
  aplicativo já era assim. O amigo de antes (os 183 da cópia, todos sem a ligação) segue dando os 10 pontos. O
  `salvarPontuacao` continua chamado depois da venda fechada, onde o R18 o deixou.
- **Quem indica inativo, ou é o cadastro do sistema** (o cliente 2, o "SEM CADASTRO";
  `FidelidadeService::indicaComALigacao`, a condição do `AplicativoService::indicacaoCadastrar`): o amigo nasce como os
  outros -- inativo, com a origem do número de quem indicou, o país e a mensagem na fila com o link da adesão dele --,
  e sem a linha em `indicacoes` nem o `clientes.indicacao`. A resposta da página é a do aceito. O "ativo?" é lido da
  linha relida sob a trava do indicador. O teto conta esses amigos (o D304).
- **Os 10 pontos do envio** e **a comissão da compra cancelada**: nada mudou, por decisão; os casos que os prendem
  continuam, com a resposta no comentário.

**O que a 65 escrita e a 67 juntas deixam -- foi ao lote, o D466:** o amigo de quem indica inativo nasce sem a ligação,
e a 65, como está escrita, dá os 10 pontos por compra a quem a origem do amigo sem a ligação nomeia: o inativo ganha,
em pontos, o que a 67 lhe tirou em comissão, até o amigo concluir a etapa 3. Não decidi: o caso prende o que o código
faz.

**Provado nos dois bancos, em série** (`D304EtapaSeisDaAdesaoTest`, 33 casos, 207 asserções; os vizinhos, 758 casos,
verdes nos dois -- o D304): a página renderizada promete "crédito" e não "pontos"; as duas compras do amigo com a
ligação dão a comissão uma vez e nenhum ponto a quem indicou (eram 20); a compra do amigo de antes, sem a ligação, dá
10 pontos e nenhuma comissão; quem indica inativo indica sem a ligação, e a mensagem sai com o link; o cadastro do
sistema, ativo, também; e o amigo de quem indica inativo dá a ele os 10 pontos da compra, sem a comissão (o D466).

| mutante (sozinho, restaurado e conferido pelo `sha1`; no PostgreSQL) | caso vermelho |
|---|---|
| o texto de volta a "pontos" | o da página |
| a ligação para todo indicador (sem o `indicaComALigacao`) | os cinco de quem indica inativo e do cadastro do sistema |
| a ligação sem olhar o cadastro do sistema | o do cadastro do sistema |
| a ligação sem olhar se quem indica é ativo | os quatro de quem indica inativo |
| a mensagem só com a ligação | os quatro que contam a mensagem de quem indica sem a ligação |
| os 10 pontos por compra também com a ligação (a guarda arrancada) | a primeira compra do amigo (20 pontos, e não 0) |
| os 10 pontos por compra só com a ligação (a guarda invertida) | a primeira compra do amigo; o amigo de antes; o amigo de quem indica inativo |

**A verificação (11/10/2026):** o caso da compra cancelada conferia os pontos de quem indicou só depois do
cancelamento, com a mensagem "o amigo com a ligação não os dá (a resposta 65)" -- e não distinguia a 65: sem a guarda,
os 10 pontos eram dados e o `VendaService::cancelar` os tirava (o D284), e a conta voltava a 0 do mesmo jeito. Agora o
caso confere os pontos depois da primeira compra, antes de cancelar, e depois da venda refeita. Com a guarda arrancada
de novo (no PostgreSQL, restaurado e conferido pelo `sha1`), ficam vermelhos a primeira compra do amigo e, agora,
também a compra cancelada ("10 é idêntico a 0", antes do cancelamento). O código não mudou. `D304EtapaSeisDaAdesaoTest`:
37 casos e 242 asserções, verde nos dois bancos (o D304).

### D447 — Filtros de tela que leem a data da URL sem conferir: a data torta derruba o PostgreSQL — REGISTRADO em 08/10/2026 — PARCIAL em 08/11/2026: sete telas conferem a data antes da consulta; fica o consultor visagista, fora do escopo

Achado no conserto do D432 (o item DATA-NA-FORMA, 08/10/2026), procurando quem lê a data `dd/mm/aaaa`
SEM o `dataValida` -- e que por isso não ganha nada com ele. O levantamento foi pelo `fromQuery` dos
controllers de `ISRelatorio`: seis telas pegam o período da URL e o passam cru ao `stringDate` (os
caracteres 0-1, 3-4 e 6-9) ou ao `stringToDateTime`, sem conferir nada. As irmãs que conferem -- caixas,
produtos (as duas ações) e a exportação de vendas -- trocam a data ruim pela de hoje; estas não.

**Medido em processo, nos dois bancos** (o arnês `ControllerEmProcesso`, num rascunho de teste apagado
depois; o paginador forçado, como a página o percorre ao desenhar), com as quatro formas do D432 e o traço:

| entrada | descontos (`rel-descontos`) | vendas canceladas (`rel-vendas-canceladas`) | consultor visagista (`rel-consultor-visagista`) |
|---|---|---|---|
| hoje, `dd/mm/aaaa` (controle) | responde, nos dois | responde, nos dois | responde, nos dois |
| `1/02/02026` e `01/2/02026` | PostgreSQL: `invalid input syntax for type date` (`2026-2/-1/`, `2026-2/-01`); MySQL: responde, com a forma torta no filtro | o mesmo | o mesmo |
| `001/2/2026` e `0001/02/26` | PostgreSQL: o mesmo erro; MySQL: `1525 Incorrect DATE value` (`2026-/2-00`, `2/26-1/-00`) | o mesmo | o mesmo |
| `01-02-2026` | responde, nos dois (o `stringDate` lê pela posição, e o traço está onde estaria a barra) | o mesmo | o mesmo |

No relatório de descontos o erro sai já no controller (o total do cabeçalho é consulta direta); nos outros
dois, ao percorrer o paginador -- na página, ao desenhar a lista. Os dois são 500.

**Lidos no código, não despachados:** o relatório de clientes (`rel-clientes`: `ultima_visita_inicio`,
`cadastro_inicio`, `ultimo_acesso_app_inicio` e os fins, pelo `stringDate` do `ClienteRepository`), o de
indicações (`rel-indicacoes`: `convite_inicio`, `adesao_inicio`, `venda_inicio` e os fins, pelo do
`IndicacaoRepository`) e o de franquias (`rel-franquias`: o `potenciais` passa o período ao
`stringToDateTime`, que é `new DateTime` sem `try`; o `royalties`, ao `stringDate` e ao `stringDateYear`).

**Fora de `ISRelatorio`** (a revisão do item, 08/10/2026: a primeira redação parava em `ISRelatorio`, e o
título falava só de seis telas de relatório). O grep do `stringDate` fora do módulo dá mais duas listas da
tela antiga que levam a data da URL crua ao `DATE(...)` da consulta, sem conferir nada; medidas do mesmo
jeito (rascunho apagado, paginador percorrido, cada banco numa rodada):

| entrada | bloqueios da agenda (`agendas/bloqueios`: `data_inicio` e `data_fim` → `AgendaFechamentoRepository::listagemIndex`, linhas 24 a 30) | notificações (`notificacoes`: `data` → `NotificacaoRepository::listagemIndex`, linha 33) |
|---|---|---|
| hoje, `dd/mm/aaaa` (controle) | responde, nos dois | responde, nos dois |
| `1/02/02026` e `01/2/02026` | PostgreSQL: `invalid input syntax for type date`; MySQL: responde, com a forma torta no filtro | PostgreSQL: o mesmo; MySQL: responde |
| `001/2/2026` e `0001/02/26` | PostgreSQL: o mesmo; MySQL: `1525 Incorrect DATE value` (`2026-/2-00`, `2/26-1/-00`) | o mesmo |

O erro sai ao percorrer o paginador, que o embrulha (`Laminas\Paginator\Exception\RuntimeException`): na
página, ao desenhar a lista -- 500.

**Já registrados em outro débito:** a comanda aberta pela agenda (`VendasController::comandaAbrirAgendaAction`,
a `data` da URL) e as outras leituras do dia da agenda que passam pela `AgendaRepository::diaInteiro` -- a
grade da tela antiga, os horários do colaborador, o check-in e o `agendaSelecionar` do aplicativo -- são o
D425: a data ilegível vira `InvalidArgumentException`, um 500 escolhido ali no lugar de responder por outro
dia.

**Os outros chamadores do `stringDate` fora de `ISRelatorio`, do grep, NÃO traçados até a origem da data:**
`CupomRepository:106` e `:110`, `ProdutoEstoqueRepository:30`, `GorjetaRepository:16`, `CaixaRepository:32`,
`VendaProdutoRepository` (285, 340 e 359), `VendaRepository` (94 a 578), `NotificacoesController:404` (a
data da campanha, comparada pelo `strtotime`), `AgendasController:1106` (o filtro de última visita, que
passa a data pela janela de sete dias do `validarDataEntrePeriodo` antes da consulta) e o `CronJobService`
(as rotinas). Alguns têm quem confira antes -- a lista de vendas e os relatórios de caixas, de produtos e
de vendas, pelo `dataValida` --; o resto fica para o levantamento do item que consertar este. As escritas
que gravam a data sem conferir são o D448.

**É a classe do D420 e do D432** -- o 500 no lugar da frase, ou a resposta de outro dia --, num caminho que
o ajudante nunca guardou, e de novo só no pedido montado à mão: os seletores mandam `dd/mm/aaaa`. Aqui
nada é conferido, e não só as formas de dez caracteres chegam à consulta (lido no código; medidas, só as
cinco da primeira tabela e as cinco da segunda).

**A direção** é a das irmãs: o `dataValida` antes da consulta, e a data ruim trocada pelo padrão de cada
tela (hoje nos descontos e nas canceladas, o mês corrente no consultor visagista, o período dos royalties
nas franquias, o vazio nos filtros opcionais de clientes, indicações, bloqueios e notificações). Qual padrão
cada tela mostra é o que ela já mostra sem filtro -- não pede decisão. Fica para outro item: o pedido deste
era só o D432.

#### PARCIAL em 08/11/2026 -- a fatia 121 (DATAS-NOS-FILTROS)

**Conferido antes:** o defeito estava de pé nas seis telas que sobraram (o relatório de clientes saiu do ERP na fatia 76e,
e o potencial de franquia na 76k). O `D447DataNosFiltrosTest`, escrito antes do conserto e rodado contra o código de
então (as formas do D432 e o traço, o paginador percorrido): no MySQL, `1525 Incorrect DATE value` com `001/2/2026` e
`0001/02/26`, e a forma torta no filtro com as outras -- em todas as telas; no PostgreSQL, os mesmos casos vermelhos.

**O conserto**, no molde das irmãs (caixas, produtos, lista de vendas): `DataHora::dataDoFiltro($data, $padrao)`, novo, devolve
a data quando ela passa no `dataValida`, o `$padrao` quando não passa (também o que não é texto, o `data[]=`), e **o vazio como
veio** -- o seletor apagado é "sem filtro", como sempre foi, e não data ruim. As telas, derivadas do grep do `fromQuery` de
data em `module/` (e não da lista do corpo):

| tela | campos | a data ruim vira |
|---|---|---|
| descontos (`rel-descontos`) | `data_inicio`, `data_fim` | hoje |
| vendas canceladas (`rel-vendas-canceladas`) | `data_inicio`, `data_fim` | hoje |
| programa de indicação (`rel-indicacoes`) | os seis: convite, adesão e venda, início e fim | sem filtro |
| royalties (`rel-franquias/royalties`) | `data_inicio`, `data_fim` | a ponta do período da tela (16 do mês passado, 15 deste) |
| bloqueios da agenda (`agendas/bloqueios`) | `data_inicio`, `data_fim` | sem filtro |
| campanhas (`notificacoes`, a lista) | `data` | sem filtro |
| cupons emitidos (`cupons/estatisticas/:id`) | `validade`, `data_criacao` | sem filtro |

O último não estava na lista: era o `CupomRepository:118` e `:122` "não traçado" do corpo, achado agora pelo grep do
`fromQuery` (a `CuponsController::estatisticasAction` leva os dois à `selecionarCuponsGerados`, com `DATE(...) =`).

**A audiência da campanha antiga** (`notificacoes/pesquisarClientes`, POST) lê as datas do formulário do antigo relatório de
clientes (`ultima_visita`, `cadastro` e `ultimo_acesso_app`, início e fim) pelo mesmo `stringDate`, no
`ClienteRepository::filtrosListagemIndexRelatorio` -- o caminho que o relatório de clientes deixou. Ali a data ruim é
**recusada**, com `NotificacoesController::FRASE_DATA_DA_AUDIENCIA` ("Informe as datas dos filtros no formato dd/mm/aaaa."),
e não trocada pelo vazio: o vazio alargaria a lista de quem recebe a campanha. A porta nova (`FiltrosDaAudiencia`) já recusava.

**Os outros do grep, conferidos:** a lista de vendas, os caixas e os produtos já usavam o `dataValida`; a lista de clientes
novos da agenda passa a data pela janela de sete dias (a torta cai fora e vira ontem); as Mensagens conferem pela
`RegraDaMensagem::diaDoErp`; a comanda aberta pela agenda e a agenda do aplicativo são o D425. As consultas do `CronJobService` **não** recebem
só datas montadas pelo código (a primeira redação deste parágrafo dizia que sim; a verificação da fatia 121 achou o
contrário): as três exportações `/api/unidade/vendas` (v1 a v3) leem `inicio` e `fim` da URL -- não são filtro de tela,
e foram ao **D742**.

**O que muda para quem usa:** nada pelo seletor. O endereço montado à mão com a data fora da forma mostra a tela com o padrão
no filtro, no lugar do 500 no PostgreSQL (e do 1525 ou da lista de outro dia no MySQL); o link da paginação sai com o filtro
conferido. Na campanha antiga, a pesquisa com a data torta recebe a frase.

**Provado:** `D447DataNosFiltrosTest`, 42 casos (o ajudante; cada tela com as cinco formas, a torta só no início e só no fim,
e o controle; a audiência com os seis campos, o vazio e uma data na forma), verdes no MySQL e no PostgreSQL. Mutantes, um de
cada vez, devolvidos pelo sha1, vermelhos nos dois bancos: cada tela de volta à leitura crua (M1 a M6, M17 e M18), a recusa
da audiência arrancada (M7), o vazio deixando de passar (M8) e o ajudante devolvendo a data sem conferir (M9).

**Fica de pé:** o consultor visagista (`rel-consultor-visagista`), com o mesmo defeito medido na primeira tabela -- o visagismo
está fora do escopo da migração, e a fatia não o toca. As exportações da unidade pela API, que leem a data da URL sem ser
tela, são o D742.

### D448 — O nascimento do sócio e o do usuário são gravados sem validação de data: o `flush` morre na conversão — REGISTRADO em 08/10/2026 — CORRIGIDO em 07/11/2026

Achado na revisão do conserto do D432 (o item DATA-NA-FORMA, 08/10/2026): o D447 olhou a leitura, e a
escrita tem o mesmo buraco. Três datas digitadas na tela antiga vão ao banco sem o `dataValida` e sem
validador de data no formulário:

- **o nascimento do sócio da unidade** (`unidades/socioSalvar`): o `socioSalvarValidarFormulario`
  (`UnidadesController:426`) só confere o vazio; o `UnidadeService::socioSalvar` hidrata a entidade, e o
  `UnidadeSocio::setDataNascimento` (linha 345) chama o `stringToDia` -- o `createFromFormat('!d/m/Y')`, que
  devolve `false`;
- **o nascimento e a contratação do usuário** (`usuarios/novo` e `usuarios/editar`): o `UsuarioDadosFilter`
  (linhas 135 e 147) só tem o `NotEmpty`; o `UsuarioService::gravarDados` (linhas 141 e 142) chama o
  `setDataNascimento` e o `setDataContratacao` do `Usuario` (linhas 1176 e 1181), que são o `stringToDate`
  -- o mesmo `false`.

**Medido em processo, nos dois bancos** (o arnês `ControllerEmProcesso`, num rascunho de teste apagado
depois; cada escrita num processo próprio, para o `EntityManager is closed` não se passar por medida): com
`1/02/02026` no nascimento, o `socioSalvar` e o `editar` do usuário morrem no `flush` com
`ConversionException: Could not convert PHP value '' of type 'boolean' to type 'datetime'` -- a do cadastro
de cliente no D432 --, e o controle grava (`10/01/1990` no sócio, `sucesso: true`; `22/03/1988` no usuário,
302 e "Editado com sucesso."). No usuário a exceção sai dentro da transação do `atualizarDados`, que a
desfaz e a relança: nada gravado, e 500. O `novo` passa pelo mesmo `gravarDados` (o `inserir`), e a
contratação pelo mesmo `stringToDate` -- lidos, não despachados.

**Mais largo que as formas do D432:** aqui não há ajudante nenhum, e qualquer texto que o `createFromFormat`
não lê -- `aa/bb/cccc`, `2026-02-01` -- tem o mesmo fim (lido). Já a data sem o zero (`1/02/2026`) o
`createFromFormat` aceita, e grava o dia certo: não é defeito.

**Os vizinhos, só o filtro lido:** dos setters que passam o texto ao `stringToDate` ou ao `stringToDia` (o
grep em `Entity/`), o nascimento do cliente (`ClienteFilter`, o das telas de cliente do ERP) e o
vencimento do contrato do convênio (`ConvenioFilter`) têm o validador `Date` no formulário -- com `d/m/yy` e
com `d/m/Y`, e o que cada um deixa passar não foi conferido --; a inauguração da unidade
(`UnidadeFilter`) e o início e o fim do sorteio (`SorteioFilter`) só têm o `NotEmpty`, e a data limite do
cupom (`CupomFilter`), nem isso. O caminho de cada um até o `flush` não foi traçado -- as telas novas da
`/adm/api` têm a sua própria conferência --, nem a origem da data das férias do usuário, da validade do
pacote do cliente, das parcelas e dos pagamentos das saídas, da mensagem, da notificação e da fatura de
royalties, que não têm o campo em filtro nenhum. Fica para o levantamento do item que consertar este.

**A direção** é a do cadastro de cliente: a validação de cada formulário pergunta ao `dataValida` (o sócio
no `socioSalvarValidarFormulario`; o usuário com um validador no `UsuarioDadosFilter` que chame o mesmo
ajudante) e responde a frase de data inválida ao lado da que cada tela já dá ao vazio. Não pede decisão do
dono: é a regra que as outras telas já seguem. Fica para outro item: o pedido deste era só o D432.


#### CORRIGIDO em 07/11/2026 -- as três datas, e a inauguração da unidade (a fatia 118, UNIDADES-TELA-ANTIGA)

Na direção deste registro, a do cadastro de cliente:

- **o nascimento do sócio:** `socioSalvarValidarFormulario` pergunta ao `DataHora::dataValida`, e a frase *"Data nascimento
  inválida: uma data que existe, em dd/mm/aaaa."* vai ao lado da do vazio (sozinha, ou depois da lista dos obrigatórios). A data
  sem o zero (`1/02/2026`), que o `createFromFormat` lia certo, passa a ser recusada, como no cliente: o campo é o do
  calendário, que escreve `dd/mm/aaaa`;
- **o nascimento do usuário:** o `UsuarioDadosFilter` ganhou um `Callback` com o mesmo ajudante, depois do `NotEmpty` (que
  quebra a cadeia), com *"Uma data que existe, em dd/mm/aaaa."* no campo -- no cadastro e na ficha;
- **a contratação do usuário já não tinha o defeito:** desde 18/10/2026 (as fatias 15 e 17) a regra da API a confere no
  cadastro e na ficha (`FRASE_DA_CONTRATACAO_NO_ERP`), e o texto que não é data é recusado antes do `flush`. Um segundo
  validador no filtro trocaria a frase dela, e não entrou; os casos novos provam a forma deste registro (`1/02/02026`);
- **a inauguração da unidade**, um dos vizinhos lidos acima, na mesma tela antiga: o `UnidadeFilter` ganhou o mesmo
  `Callback` (o `setDataInauguracao` é o mesmo `stringToDate`).

**Os outros vizinhos, conferidos:** o início e o fim do sorteio e a data limite do cupom passam hoje pela regra de cada um
(`RegraDoSorteio::conferir` e `RegraDoCupom::conferir`, nas telas antigas, desde as fatias deles) antes de gravar; o nascimento
do cliente e o vencimento do convênio têm o validador `Date`. As datas sem campo em filtro (férias, validade do pacote, parcelas,
mensagem, notificação, fatura) seguem sem o caminho traçado: o levantamento que o corpo deixava "para o item que consertar
este" não foi feito aqui, e foi ao **D740** (a verificação da fatia 118), junto com o que o validador `Date` do cliente
(`d/m/yy`) e o do convênio deixam passar, que também não foi conferido.

**Provado** nos dois bancos: `TelasDasUnidadesDoErpTest::testONascimentoDoSocioEUmaData` (quatro formas, no novo e na edição
do sócio, a frase sozinha e ao lado da do vazio, nada gravado, o controle grava) e `testOQueOFormularioRecusa` (a
inauguração); `UsuarioDadosPelaApiTest::testONascimentoDaFichaAntigaEUmaData` e
`UsuarioCadastroESenhaPelaApiTest::testONascimentoDoCadastroAntigoEUmaData` (cada forma volta a página com a frase no campo,
e não o 500; a contratação com a frase da regra; nada gravado; o controle grava). Mutantes: o `dataValida` arrancado do sócio
(o 500 de volta), o `Callback` do usuário sempre verdadeiro e o da inauguração -- vermelhos.

### D449 — A documentação da API manda o nascimento do cadastro do aplicativo com traço, e o backend só aceita a barra — REGISTRADO em 08/10/2026 — CORRIGIDO em 08/11/2026: cada data da página do aplicativo diz o que o backend lê

Achado na revisão do conserto do D432 (o item DATA-NA-FORMA, 08/10/2026). `ApiDocsController:526`, na
documentação embutida do aplicativo (`/api/docs`), descreve o `data_nascimento` do `POST /api/clientes/novo`
como "Formato esperado pelo backend (dd-mm-aaaa)", e o exemplo da linha 534 manda `10-01-1990`. O backend
nunca aceitou isso: o `dataValida` da importação (15/08/2026) separava por `/` e exigia três partes, e o de
hoje exige `dd/mm/aaaa` (o D432). Quem integra seguindo a página recebe `{sucesso: false, mensagem: "data
nascimento inválida"}` -- e o `D432DataNaFormaTest` tem o traço entre as entradas recusadas.

**O aplicativo publicado não segue a página:** na cópia de desenvolvimento (só contagem, 08/10/2026),
1.438 clientes têm a marca que só o cadastro do aplicativo põe (`cadastro_restricao_agendamento` ligado,
com a origem `Aplicativo`; a origem sozinha não basta, porque o modal do ERP também a oferece) e o
nascimento gravado. É dedução, sem o código do aplicativo no repositório: ele manda a barra, e o defeito é
do texto. As rotas de agenda da mesma página (linhas 649 a 731) também dizem `dd-mm-aaaa`, e não foram
conferidas aqui.

**A direção:** o texto e o exemplo do `cliente-novo` em `dd/mm/aaaa` (`10/01/1990`). Não muda comportamento
nenhum. Fica para outro item: o pedido deste era só o D432, e a página não é dele.

#### CORRIGIDO em 08/11/2026 -- a fatia 121 (DATAS-NOS-FILTROS)

A página do aplicativo (`ApiDocsController::getAplicativoEndpoints`) foi conferida data por data contra o código que a lê,
e não só o cadastro: as rotas de agenda também mentiam, e não do mesmo jeito.

| página | dizia | o backend lê | agora |
|---|---|---|---|
| `cliente-novo` (`data_nascimento`) | `dd-mm-aaaa`, exemplo `10-01-1990` | `dd/mm/aaaa` (`dataValida`; o traço é recusado) | `dd/mm/aaaa`, `10/01/1990` |
| `getAgenda` v1 e v2 (`data`) | `dd-mm-aaaa` | `aaaa-mm-dd`: a ação parte por traço e remonta ao contrário; o contrato manda `2026-12-24` | `aaaa-mm-dd` |
| `agendar` v2 e v3 (`data`) | `dd-mm-aaaa`, exemplo `26-03-2026` | o dia, o mês e o ano pela posição (`dataHoraBr_to_dateTime`); os testes e o ensaio mandam a barra | `dd/mm/aaaa`, `26/03/2026` |
| `agendar` v1 | "versão legada" | sem ação: 404 (o D14) | diz o 404 e manda à v2 ou à v3 |
| `cliente-agenda` e `cliente-indicacoes-listar` (as respostas) | `26-03-2026 10:00`, `14-04-2026 10:30` | `dd/mm/aaaa hh:mm:ss` (`dateTimeToString`) | os exemplos nessa forma |

Nada no comportamento mudou. **Provado:** `D449DatasDaDocumentacaoDaApiTest` (5 casos, verdes nos dois bancos): nenhuma
página ensina o dia com traço; o exemplo do cadastro passa no `dataValida`; a agenda do colaborador, escrita no padrão que a
rota documenta, devolve os horários nas duas versões (despachada em processo, num dia livre das fixtures), e no `dd-mm-aaaa`
de antes não; o exemplo do marcar é lido como o mesmo dia; as respostas na forma do `dateTimeToString`; a v1 continua sem
ação. Mutantes vermelhos nos dois bancos: o exemplo do cadastro de volta (M14), a rota da agenda de volta (M15), a resposta da
agenda do cliente de volta (M16).

### D450 — Tirar da venda um item com meio centavo deixa um centavo a mais no total, e a marca do "Quitar" o lê como débito somado — REGISTRADO em 08/10/2026 — CORRIGIDO em 10/10/2026

**Achado na verificação do item PDV-QUITAR-SEGUNDA-RODADA, lido no código e conferido com a conta do PHP
do contêiner (sem banco).** O total da venda é incremental. `VendaProdutoService` soma o item cru ao total
anterior, que `getValorTotal()` devolve arredondado, e, ao tirar o item (`remover`) ou ajustar a linha
(`alterar`, que tira e soma de novo), subtrai o item cru do total arredondado. O item de meio centavo é o
desconto em porcentagem do PDV: 5% de 45,90 dá 43,605. Ao somar, o total arredondado acompanha o item (dois
de 43,605 dão 87,22, que é 43,61 + 43,61). Ao tirar, arredonda para cima: 87,22 − 43,605 = 43,615, que o
total mostra como 43,62 -- um centavo acima do item que ficou (43,61).

Dois efeitos:

- **o cliente paga o centavo** no balcão das 32 lojas: o finalizar cobra o total;
- **a marca do "Quitar"** (`RegraDosDebitosNaVenda::excedente`, o que o total tem acima dos itens) lê o
  centavo como débito somado. O abrir comanda de outro cliente recusa com a frase dos débitos (o D433), e o
  cliente que deva até esse centavo teria a bandeira ligada na comanda reaberta. Desde a verificação a
  marca soma os itens arredondados, o que tirou o meio centavo de quem só lança itens (o D433). O centavo
  da remoção é o que sobra.

**Medido na cópia de desenvolvimento em 08/10/2026, só leitura** (o `round()` do PHP sobre as linhas do
banco): de 459 vendas com item fora do centavo, 3 têm o total acima dos itens arredondados (0,01, 0,02 e
0,04; de 2019, 2024 e 2025), todas concluídas. Pela conta antiga, com os itens crus, eram 9.

**A direção:** tirar e ajustar subtraem o item arredondado, e o total volta a ser a soma do que a tela
mostra. Isso muda o valor cobrado no balcão (um centavo a menos nesses casos), no serviço das 32 lojas, e é
escolha do dono: foi ao lote (08/10/2026) com essa recomendação. Até lá, a recusa falsa do D433 manda
finalizar a venda ou recarregar o PDV, e recarregar resolve.

**Decidido pelo dono em 10/10/2026** (página do lote, pergunta 56, a opção (a)): "Tirar e ajustar um item subtraem o
item ARREDONDADO: o total volta a ser a soma do que a tela mostra, e o cliente paga um centavo a menos nesses casos."

**O conserto**, num lugar só: `VendaProdutoService::totalSemOItem` -- o total menos o item arredondado ao centavo
(`round(..., 2)`, o mesmo da marca, `RegraDosDebitosNaVenda::excedente`, e o da tela) --, chamado pelo `remover` e pelo
`alterar`, que tira a linha antes de relançá-la. As duas portas passam por ele: o PDV antigo
(`VendasProdutosController::removerAction`) e a API da tela nova (`DELETE` e `PUT .../itens/:item`); o `alterar` só é
chamado pela API. O lançamento não mudou: ele já somava o item cru ao total arredondado, o que dá a soma dos
arredondados. No item de centavo inteiro nada muda. As colunas de valor são `double precision` no PostgreSQL
(`postgres/schema.sql`), e não `numeric`, como o `double` do MySQL: a conta é a mesma nos dois, e os casos passam nos
dois.

**Provado** em `D450CentavoDoItemRetiradoTest` (5 casos, novo), verde no MySQL e no PostgreSQL, com os itens lançados
pelas portas de verdade. Pelo PDV antigo, dois cortes de 45,90 com 5% (87,22), tirado um, ficam 43,61 (eram 43,62) e
nada acima dos itens; e depois disso a venda em andamento vira a comanda de outro cliente (era a recusa dos débitos do
D433, sem "Quitar" nenhum). Pela API, 89,90 com 5% duas vezes (170,82), tirado um, 85,41 (eram 85,42); e a segunda
linha ajustada para 2 cortes deixa 256,22, a soma dos itens (eram 256,23). O controle, o item sem fração de centavo,
sai como antes. Contra o código de antes, 4 vermelhos nos dois bancos (o controle verde). Os mutantes estão na tabela do
D380, "A terceira rodada".

**O que fica:** o centavo que já está no total de uma venda aberta, de uma remoção de antes do conserto, não sai -- a
conta nova tira o item arredondado, e o centavo antigo continua até a venda fechar; na cópia, as 3 vendas assim são
concluídas (medido em 08/10/2026). A tela antiga não foi aberta num navegador.

### D451 — O deploy do teste sem `SENHA_HTTP` publica aberto (o `publicar`, o `atualizar` e o `voltar`), e a escolha do dono desde 02/10 é a senha ligada — REGISTRADO em 08/10/2026 — CORRIGIDO em 08/11/2026: sem a escolha, nada liga o vhost

**Achado na estimativa da primeira versão de teste no servidor (08/10/2026), lido no código.** O
`conferir_env` do `scripts/deploy-teste.sh` (o bloco "A SENHA HTTP É UMA ESCOLHA QUE FICA") lê a
escolha da linha de comando e, sem ela, da linha `SENHA_HTTP` do `.env`; sem as duas, faz
`SENHA_HTTP="${SENHA_HTTP:-nao}"` -- só na memória, sem gravar --, e o `instalar_vhost` gera o vhost
sem `auth_basic`. Nenhum dos dois recusa nem avisa. O `.env.teste.example` não tem a linha, e o
`preparar` só a escreve quando a variável vem na linha de comando: o `.env` nasce sem ela, e o primeiro
`publicar` sem `SENHA_HTTP=sim` põe o ERP e a tela nova no ar **abertos**. O `conferir` mede o que está e
passa: imprime `ok  público: ERP em /, tela nova em /novo/`.

**Não é só o `publicar`** (a verificação do SUBIR-COM-SENHA, 08/10/2026, lida no código): o `atualizar` e
o `voltar` seguem no `_aplicar`, que chama o mesmo `conferir_env` e, no fim do `aplicar`, o `por_no_ar`
-- o mesmo `instalar_vhost` do `publicar`. Sem a linha no `.env`, os três religam abertos, e a sonda
aceita o 200 ou o 302.

- **Um caminho sem `publicar` nenhum:** o `base`, e o freio da saída de um passo que não chegou ao fim
  (o `AO_FALHAR=tirar_do_ar` -- um `publicar` que reprovou, por exemplo), tiram o site **sem** gravar a
  marca do `tirar` (`.deploy-teste/tirado`). Com o certificado e a base selada, o `conferir` diz
  `público: fora do ar` (ou `MEIO FORA`) e "Tudo conferido", e o `atualizar` seguinte não acha a marca,
  acha o certificado e o selo igual, e religa pelo `por_no_ar` com a escolha do `.env` -- aberto, sem a
  linha. A página manda agora o `tirar` em toda linha do endereço que não seja o 401.
- **O script manda o `publicar` sem a senha:** os "Próximos passos" do `preparar` (`EMAIL_CERTBOT=voce@exemplo
  bash scripts/deploy-teste.sh publicar`), o fim do `base` e o do `tirar`, o `aplicar` que deixa o site fora
  (tirado de propósito, ou com a base de outro código), a recusa do `decidir_a_base` e a do
  `recusar_se_base_de_outro_codigo`. Num `.env` sem a linha, quem copia o comando do terminal publica
  aberto. A página avisa ("Não copie o `publicar` que o script imprime").

- **A decisão é a outra:** em 02/10/2026 o dono decidiu subir a cópia "como está com senha HTTP"
  (D326), porque ela leva um CPF e 243 telefones que o anonimizador não alcança. O padrão do script
  ainda é o de 24/09 ("só noindex"), e dois comentários dele ainda o dão como a escolha do dono: o
  cabeçalho ("sem a senha HTTP na frente (escolha do dono, 24/09/2026)") e o ramo sem senha do
  `instalar_vhost`, que vai escrito no próprio vhost gerado.
- **A página mandava esse publicar:** até 08/10/2026 os comandos de "Subir" do
  `docs/ambiente-teste.md` não criavam o `htpasswd` nem passavam `SENHA_HTTP=sim`, e a tabela do topo
  dizia "sem senha HTTP". A página foi acertada no item SUBIR-COM-SENHA (o mesmo dia): os comandos levam
  a senha, e o topo manda, num servidor que já rodou, o `conferir` e, se a linha do endereço não for a do
  401, o `tirar` na hora; e o `atualizar` e o `voltar` só depois de o `.env` dizer `SENHA_HTTP=sim`. O
  script não mudou.
- **Não sei se o BVIP foi publicado assim.** Daqui não se vê; é a pergunta de em que pé está o BVIP.

**Não foi mexido, de propósito:** a cópia do teste está subindo no BVIP com este script, a carga dele
está congelada até lá, e não há ensaio do script versionado no repositório (o arnês do deploy usou
dublês e não está no git) para provar a mudança.

**O conserto proposto, fechado na falha, para o item 31 da fila (DEPLOY-TESTE-V9):** uma de duas, e
**no ponto por onde os três passam** -- o `instalar_vhost` (ou o `conferir_env`, guardando de onde veio
a escolha) --, e não no `cmd_publicar`: só nele, o `atualizar` de um site fora sem a marca seguiria
religando aberto (o conserto no ajudante que não cobre o irmão). (a) Ligar o vhost sem a escolha
explícita, na linha de comando ou no `.env`, recusa, e diz os dois comandos (o `htpasswd` e o
`SENHA_HTTP=sim ... publicar`); ou (b) o padrão passa a `sim`, pela decisão do D326, e o
`instalar_vhost` recusa sem o `/etc/nginx/.htpasswd-<domínio>` -- o que o `SENHA_HTTP=sim` já faz hoje.
As duas fecham o buraco; a (a) não muda o que um `.env` com a linha já diz, e a (b) já mora no
`instalar_vhost`. Junto: os dois comentários; as frases que mandam o `publicar` (acima), com a senha
enquanto o `.env` não tem a linha; e três casos no arnês do deploy: o `publicar`, o `atualizar` e o
`voltar` sem a escolha não ligam o vhost -- o do `atualizar` com o site fora sem a marca do `tirar`.

#### CORRIGIDO em 08/11/2026 -- a fatia 129 da fila dos débitos, DEPLOY-TESTE-E-PUBLICAR (a opção (a) da proposta)

Conferido antes, no código: o `conferir_env` ainda fazia `SENHA_HTTP="${SENHA_HTTP:-nao}"`, e o cabeçalho e o ramo sem senha
do `instalar_vhost` davam o "sem senha" como a escolha do dono de 24/09. O que mudou em `scripts/deploy-teste.sh`:

- **Sem padrão:** lida do `.env` e vazia, a escolha segue vazia. A função nova `exigir_a_escolha_da_senha` recusa, e diz os
  dois comandos (o `sudo htpasswd -c /etc/nginx/.htpasswd-<domínio> <usuario>` e o `SENHA_HTTP=sim bash
  scripts/deploy-teste.sh publicar`); é chamada logo depois do `conferir_env` no `publicar`, no `atualizar`, no `voltar` e no
  `_aplicar` -- antes de qualquer troca ou freio, menos no `publicar`, que arma o freio antes do `.env` desde a terceira revisão
  (um site no ar sem a escolha sai do ar) -- e de novo no `instalar_vhost`, o ponto por onde os três passam. A opção (a): um
  `.env` com a linha (`sim` ou `nao`) segue dizendo o que dizia; o `nao` explícito continua possível, e o vhost gerado diz que
  é contra a escolha de 02/10 (D326).
- **O `voltar` a um commit de antes:** o script de lá ainda tem o padrão `nao`. Como a recusa vem no script DAQUI, antes do
  checkout, um `voltar` só passa com a linha no `.env` -- e o script de lá a lê. Por isso a `VERSAO_DO_DEPLOY` e a mínima
  ficaram em 9 (a mínima subiria e travaria todo `voltar` aos commits de hoje).
- **O `conferir`** reprova o site no ar sem senha e sem a linha ("foi publicado pelo padrão de antes"), com o `tirar` e o
  comando de religar.
- **As frases:** o cabeçalho, o ramo sem senha do vhost, os "Próximos passos" do `preparar`, o fim do `tirar`, os três fins do
  `aplicar` que deixam o site fora, as recusas do selo (`decidir_a_base`, `recusar_se_base_de_outro_codigo`) **e o fim do
  `base`** dizem o `publicar` com a senha enquanto o `.env` não tem a linha (`comando_de_publicar`). O fim do `base` ficou de
  fora na primeira versão, sob o motivo de ser a carga; a verificação da fatia mostrou que o texto impresso depois da carga não
  é a carga (o selo é o dos `ARQUIVOS_DA_COPIA` e da `VERSAO_DA_CARGA`, e o comentário dela manda subi-la só quando o
  `cmd_base` muda no que faz na base), e ele mudou: a única linha do `cmd_base` diferente do HEAD é a do comando impresso.
- **A carga e o selo não mudaram:** o que o `cmd_base` faz na base, o `selo_da_copia`, o `selo_da_base`, o `registrar_selo`,
  o `selo_registrado`, o `ARQUIVOS_DA_COPIA` e a `VERSAO_DA_CARGA` são os do HEAD (comparados função a função). A `docs/ambiente-teste.md` diz o comportamento novo e avisa
  que um clone do BVIP com o script de antes ainda publica aberto até o `git pull`.

**Os três casos com dublês**, em `scripts/ensaiar-escolhas-do-deploy-teste.sh` (no `conferir-tudo.sh` e no CI): o roteiro
inteiro num clone de mentira, com o `docker`, o `sudo`, o `nginx`, o `systemctl`, o `hostname`, o `realpath` e o `curl` de
mentira -- o `publicar`, o `atualizar` com o site fora SEM a marca do `tirar` (o `.deploy-teste/` de um publicar, sem o
`tirado`), o `voltar` e o `_aplicar`, sem a linha: saem 1 com a recusa e os dois comandos, sem `install -m 644`, `ln -sf`,
`up` **nem o `docker compose config` do passo seguinte** no registro (a recusa vem na entrada, medida pelo efeito), o `.env`
sem a linha e o HEAD parado; e o `publicar` com o freio armado rodando (o `tirar_do_ar` desliga o vhost deste domínio). Com
`SENHA_HTTP=sim` no `.env` ou na linha de comando, os quatro passam e param no passo seguinte, com o `compose config` no
registro. E o `instalar_vhost` e o `conferir_publico` tirados do roteiro; e, lido sem os comentários, nenhuma frase manda o
`publicar` curto fora do `comando_de_publicar`. **O que a parte dos quatro não percorre:** o caminho `atualizar` ->
`_aplicar` -> `por_no_ar` -> `instalar_vhost` com o site fora sem a marca. Sem a escolha, a recusa vem antes dele, na entrada;
e o `instalar_vhost` recusa sozinho (o caso dele), para um caminho que não passe pela entrada. Mutantes: o padrão `nao` de
volta, a chamada arrancada do `instalar_vhost`, a do `cmd_atualizar` e a reprovação do `conferir` -- cada um vermelho; o
roteiro do HEAD inteiro, vermelho. Os da verificação, todos vermelhos: a recusa que imprime e segue (`( exigir... ) || true`) no `atualizar` e no `voltar` (a primeira versão do ensaio ficava verde com ela), o freio armado depois da recusa
no `publicar`, e o fim do `base` com o `publicar` curto de volta.

**Não provado:** o BVIP. Se o teste de lá foi publicado aberto, e em que pé está, segue a pergunta de antes; o `conferir`
desta versão, rodado lá depois do `git pull`, diz.

### D452 — A sessão do pacote ainda fica abaixo de zero por dois caminhos que a resposta 45 não alcança: duas vendas abertas do cliente, e o item maior que o saldo do pacote que vence antes — REGISTRADO em 08/10/2026; a pergunta foi ao lote — PARCIAL em 10/10/2026 — CORRIGIDO em 11/10/2026

**Achado ao escrever o conserto do D435** (o item AGENDA-SEGUNDA-RODADA), e não procurando por ele. O D435
fechou o caminho da pergunta 45: dentro da MESMA venda, a sessão já lançada conta. O dono escolheu exatamente
isso, a opção (a) -- e não a (b), que fazia o finalizar recusar a venda que deixaria o pacote negativo. Ficam
dois caminhos que a (a) não alcança, e os dois terminam no mesmo `quantidade_restante` negativo, gravado
pelo finalizar (`VendaPagamentoService::salvarPacotes`, que tira a quantidade do item sem piso):

- **Duas vendas abertas do mesmo cliente** -- a comanda de um caixa e a venda do PDV de outro, ou o PDV e a
  comanda aberta pela agenda. Cada uma vê o saldo gravado, menos as sessões dela; as duas dão a última
  sessão, e o segundo finalizar deixa o pacote em -1. A guarda do D52 impede duas COMANDAS do cliente na
  unidade, mas não a venda do PDV ao lado de uma comanda.
- **O item com quantidade maior que o saldo do pacote que vence antes.** A conta soma os pacotes (duas
  sessões cabem em 1 + 3), mas a linha aponta para UM -- o que vence antes, o D72 -- e o finalizar tira as
  duas dele: -1, com o outro intacto. Só o lançamento de item (o PDV e a API) leva quantidade maior que 1 (a
  comanda pela agenda lança um item por horário). E acontece TAMBÉM DENTRO DA MESMA VENDA (achado na
  verificação do item): o pacote escolhido é o primeiro com saldo NA VENDA (`selecionarPacote` confere
  `> 0`), e não o primeiro com saldo para a quantidade do item. Com o que vence antes em 3 e o outro em 1,
  dois itens de 2 sessões no PDV: o primeiro vai para o que vence antes (sobra 1 nele, na venda); o segundo
  cabe na soma (1 + 1) e vai para o mesmo, que o finalizar leva a -1 com o outro em 1. Nenhum item, sozinho,
  passa do restante gravado: quem esgota o pacote é a sessão que a própria venda já lançou. O título dizia
  "fora da mesma venda" até a verificação.

**Provado como fato de hoje, em pergunta** (`D452SessaoDoPacoteForaDaMesmaVendaTest`, 3 casos verdes no
MySQL e no PostgreSQL; o nome do arquivo ficou o de antes do terceiro caso): uma sessão, uma comanda e uma
venda do PDV do cliente, cada uma com o serviço como sessão, e o pacote em -1 depois dos dois finalizar; o
item de duas sessões no pacote que vence antes, com uma, que fica em -1 com o da fixture em 3; e, na mesma
venda, os dois itens de duas sessões que levam o que vence antes a -1 com o outro em 1. A validade do pacote
da fixture é posta no caso, um ano adiante: com a data fixa (2030-12-31), a ordem dos dois pacotes se
inverteria em dezembro de 2030. Os casos não dizem que isso é certo; quando o dono decidir, mudam
junto.

**Na cópia** (`franquiabv_dev`, MySQL, só contagens, em 08/10/2026): 5 itens de pacote, em toda a história,
têm quantidade maior que 1; 99 clientes têm dois pacotes ativos ou mais do mesmo serviço na mesma unidade;
nenhuma comanda aberta tem item de pacote. Quantos dos 17 pacotes negativos vieram por um destes caminhos, e
não pelo do D435, **não foi medido**.

**Por que não foi consertado aqui:** as duas saídas mudam o valor cobrado ou recusam venda no balcão das 32
lojas, e a resposta 45 foi "na mesma venda". Foi ao lote de 08/10/2026 com as saídas e a recomendação. O caso
de dentro da mesma venda também ficou: a saída que o fecha é a (a)(2) da pergunta com o saldo da venda, e,
quando nenhum pacote tem saldo para a quantidade inteira, o item sai pelo preço -- muda o valor cobrado, e a
pergunta ganhou essa linha na verificação.

**Decidido pelo dono em 10/10/2026** (pergunta 60 da página do lote): a opção (b), e não a (a) recomendada -- "o
finalizar recusa a venda que deixaria um pacote negativo", só para os dois caminhos daqui (as duas vendas abertas do
cliente na mesma unidade dando a mesma última sessão, e o item de 2 sessões ou mais que vai inteiro para o pacote que
vence antes sem saldo para a quantidade inteira, inclusive dentro da mesma venda), com a frase dizendo QUAL pacote e
QUANTAS sessões faltam; o operador tira a sessão e lança pelo preço. Nada é gravado na recusa. Nas duas portas que
finalizam, com a frase chegando ao operador nas duas telas. Os 17 pacotes já negativos não mudam, e a venda que não
gasta sessão deles não é recusada por eles.

**O conserto, em 10/10/2026** (o item PACOTE-TERCEIRA-RODADA). `ClientePacoteRepository::sessoesQueFaltamNaVenda` soma,
por pacote, as sessões que os itens da venda apontam para ele e as compara com o restante lido do banco naquele
instante -- e não com o da entidade na memória do pedido: a outra venda pode ter fechado depois que esta foi lida;
`faltam` é o que passa do que o pacote ainda tem, sem contar o negativo dele. `VendaPagamentoService::finalizar` recusa
(devolve falso) antes de qualquer escrita, ao lado das recusas do caixa de outro dia e da linha de crédito, e as duas
portas dizem a frase com a mesma pergunta (`VendaPagamentoService::sessoesQueFaltam`): o finalizar do ERP antigo
(`VendasPagamentosController::validarFinalizarVenda`: `sucesso: false` com a frase, que o `pdvFinalizarVenda` mostra
no aviso; os nomes vão escapados, porque o `toastr` desenha HTML) e o da API da tela nova
(`Adm\ComandasController::finalizarAction`: 409 com a frase, `detalhes.motivo = 'sessoes_do_pacote'` e os pacotes; a
tela mostra a frase como mostra todo 409 que não é `debitos_somados`, e nenhum `.tsx` mudou). A recusa olha só os
pacotes que a venda gasta: desde o D435 o lançamento desconta as sessões da mesma venda, então só os dois caminhos
daqui chegam a ela, e o pacote negativo que a venda não gasta não entra. O lançamento não mudou: o item de 2 sessões
continua indo para o pacote que vence antes, e é o finalizar que o recusa.

**A frase, provisória** (a redação é do item; o dono pediu o conteúdo): "O pacote "Pacote Teste" (Corte Teste) não tem
sessões suficientes para esta venda: falta 1 sessão. Tire da venda o item da sessão e lance-o de novo pelo preço." --
com "faltam N sessões" no plural, e uma frase por pacote quando são mais de um.

**Na cópia** (`franquiabv_dev`, MySQL, só contagens, em 10/10/2026): nenhuma venda aberta tem item de pacote, então
nenhuma seria recusada hoje; os pacotes negativos continuam 17.

**Provado** em `D452SessaoDoPacoteForaDaMesmaVendaTest` (11 casos; os 3 que diziam o fato em pergunta viraram a recusa),
verdes no MySQL e no PostgreSQL: a segunda de duas vendas com a mesma última sessão é recusada, nada é gravado (a
venda, os pacotes do cliente, o caixa, os pagamentos e os pontos iguais) e o pacote fica em 0, e não em -1; a saída da
frase -- tirar a sessão e lançá-la de novo, que sai pelo preço -- fecha a venda; o item de 2 sessões no pacote que tem 1,
e o segundo item de 2 na mesma venda, são recusados com os pacotes como estavam; o saldo exato passa; o pacote do
cliente em -25 não recusa a venda que não tem sessão dele, e não muda; a comanda de antes com a sessão de um pacote já
em -2 é recusada contando só a sessão dela ("falta 1", e não 3); pelo ERP, `sucesso: false` com a frase e nada gravado;
pela API, 409 com a frase, o motivo e o pacote, e nada gravado; o nome do pacote escapado no ERP e cru na API; e o
plural. Os arquivos vizinhos (os 43 que usam os serviços de venda, os controllers do PDV e da comanda ou o repositório
dos pacotes, e quatro do fiado e da comissão), 645 casos, verdes nos dois bancos.

A tabela de mutantes desta rodada (7 linhas, 31 casos) foi feita com versões anteriores dos arquivos de teste, e a
verificação a refez sobre os arquivos finais; está logo abaixo, com a corrida.

**Em 10/10/2026 ficava a corrida, e por isso PARCIAL:** dois finalizar no MESMO instante. A conferência lia o restante
sem trava, e a baixa (`salvarPacotes`) regravava o restante que o pedido lera: duas vendas com a mesma última sessão,
fechadas em dois caixas no mesmo segundo, passavam as duas, e o pacote ficava em -1 ou, conforme a ordem, em 0 com as
duas sessões gastas. Ficou no D464.

**A corrida, fechada na verificação do item (10 e 11/10/2026; o D464).** A verificação mostrou que fechá-la não pede
trava nem transação, nem esquema: pede que a recusa SEJA a baixa. `ClientePacoteRepository::baixarSessoesDaVenda` faz,
por pacote, um `UPDATE` só -- `quantidade_restante = quantidade_restante - ?` ONDE o restante cobre as sessões que a
venda aponta para ele --, e zero linhas é a recusa. Quem decide a corrida é o banco, sobre a linha que ele tem: o
segundo `UPDATE` espera o primeiro e confere o restante já baixado (o MySQL relê a linha, o PostgreSQL reavalia o
`WHERE` na versão nova). É a conta no banco que o dono escolheu para o estoque em 06/10/2026 (o D388, pergunta 20), com
a condição da resposta 60. O resto:

- **É a primeira escrita do finalizar**, depois das recusas do caixa e da linha de crédito e antes do crédito, do caixa
  e do estoque: "nada é gravado na recusa" continua valendo. Com sessões de dois pacotes, eles vão na ordem do id, e o
  que não cobre faz voltar os já baixados (`devolverSessoes`): tudo ou nada.
- **A conferência saiu do serviço.** `sessoesQueFaltam` fazia a mesma conta sem trava -- as sessões passam do
  restante (`lancadas - max(0, restante) > 0`) exatamente quando o restante é menor que elas, a condição do `UPDATE` --,
  e com a baixa não recusaria nada que ela não recuse; ficou nos controllers, só para dizer a frase. E eles a fazem DE NOVO
  depois da recusa: na corrida a conferência deles passa e a baixa recusa, e relida depois dela a pergunta vê a sessão
  que o outro caixa gastou. No ERP a frase chega no aviso -- e, sem sessão faltando, "Não foi possível finalizar a
  venda.", a frase da API, onde o aviso saía vazio (`notificacao('warning', '')`) para toda recusa só do serviço --; na
  API, o mesmo 409 `sessoes_do_pacote`.
- **O erro no meio do finalizar devolve as sessões baixadas se a venda não fechou** (`devolverAsSessoesDaVendaAberta`,
  que pergunta ao banco o `status` da venda: o `flush` dos débitos e o do troco gravam a venda fechada antes do fim). Sem
  isso, o finalizar seguinte baixaria de novo. Fechada, a sessão fica gasta -- e isso mudou: até 10/10/2026 a baixa era
  a última coisa, e o erro depois do `flush` da venda deixava a venda fechada SEM a baixa.
- **A entidade do pacote que o pedido já tinha lido é relida** depois do `UPDATE` (o molde do D388), e `salvarPacotes`
  não baixa mais nada: só cria os pacotes que a venda comprou.
- **O cancelamento devolve pela mesma conta** (`devolverSessoes`, `quantidade_restante + ?`): regravava o restante lido
  mais a quantidade, e apagava a baixa de um finalizar do mesmo pacote terminado no meio -- o espelho que o D464 não
  citava.
- **Na API, a recusa das sessões vem antes da do fiado sem autorização:** a saída dela é tirar o item da sessão, e tirar
  item revoga a autorização (`desautorizarFiado`). Na ordem de antes, o gerente autorizava, a sessão recusava, e ele
  autorizava de novo.
- **O avulso continua sem baixa** (`salvarPacotes` nunca rodou para ele), e por isso sem a recusa no serviço; na cópia
  (só contagens, em 11/10/2026) ele não tem pacote nem item de pacote.

**Os limites, ditos.** (1) O POST repetido da MESMA venda (a pergunta 2 do D321, aberta): com saldo para as duas, as
duas baixam, como todo efeito do finalizar repetido; com saldo para uma, a segunda agora é recusada. (2) Na tela nova o
formulário do fiado aparece antes de finalizar (`AutorizarFiado`): o operador que autoriza ANTES de ver a recusa das
sessões ainda autoriza duas vezes. Fechar pede a comanda dizer as sessões que faltam antes do finalizar e a tela
mostrar o aviso; não feito -- o caso pede a corrida das duas vendas com a mesma última sessão e fiado na mesma comanda.
(3) A frase manda lançar pelo preço também quando outro pacote do cliente cobriria o item de 2 sessões; foi ao lote
(a verificação achou a armadilha: relançar com o "Sim" padrão põe o item no mesmo pacote, e a recusa se repete). (4)
Dois pedidos ao mesmo tempo não foram medidos: os casos põem o outro caixa entre a conferência e a baixa
(`servicoQueNaoViuOOutroCaixa`), ou entre a leitura do pacote e a baixa.

**Provado na verificação** (`D452SessaoDoPacoteForaDaMesmaVendaTest`, 20 casos, 9 novos), verdes no MySQL e no
PostgreSQL: na corrida, pelo ERP e pela API, a venda é recusada, nada é gravado, o pacote fica em 0, e a frase chega
(no ERP `sucesso: false` com ela; na API o 409 com o motivo); com sessões de dois pacotes, o que não cobre devolve a do
outro, e com os dois cobrindo os dois baixam; o pedido que leu o pacote com 3, com o outro caixa baixando para 2 no meio,
deixa 1 (e não 2), e a entidade dele é relida; o erro no meio (quem finaliza sem caixa nenhum, que o serviço não confere,
o D164) devolve a sessão e deixa a venda aberta; o cancelamento com o pacote lido em 2 e o banco em 1 deixa 2 (e não 3);
a devolução pelo repositório soma no banco e relê a entidade; na API, a comanda fiada sem autorização com a sessão que
falta recebe o 409 das sessões; e o nome do serviço escapado no ERP, como o do pacote. Com os 15 do D453 e os 8 do
D435, 43 casos e 160 asserções nos dois bancos; os vizinhos (os 43 arquivos de antes e mais 10 que tocam venda, pacote
ou o finalizar), 783 casos verdes nos dois bancos, com 1 pulado (o `VendasNoRelatorioTest`, que pede o histórico da produção).

**Falsificado na verificação, sobre os arquivos finais** (11/10/2026; `medir-mutantes-d452-final.py`, do rascunho do
item, um mutante por vez, restaurado e conferido pelo sha256; os 42 casos do item em cada rodada -- 43 na da devolução,
rodada depois de o caso dela entrar --, com o mesmo vermelho nos dois bancos):

| mutante | caso vermelho |
|---|---|
| a baixa que não cobre não recusa (a venda fecha sem baixar) | 7: as duas vendas, a saída, o item de 2, o de 2 na mesma venda, o pacote já em -2, e a corrida pelo ERP e pela API |
| a baixa sem a condição (baixa sempre, sem piso) | 8: os 7 de cima e os dois pacotes |
| tudo ou nada sem devolver o já baixado | os dois pacotes |
| `salvarPacotes` baixando de novo, como antes | 14, nos três arquivos: todo caso que fecha venda com sessão e confere o restante |
| sem reler a entidade do pacote | o pedido que leu 3 |
| o erro no meio sem devolver a sessão | o erro no meio |
| o cancelamento regravando o restante lido | o cancelamento |
| a devolução subtraindo (o 34º, rodado com os 43 casos, depois do caso da devolução pelo repositório) | 4: os dois pacotes, o erro no meio, o cancelamento e a devolução pelo repositório |
| `faltam` sem o piso do restante (conta o negativo do pacote) | o pacote já em -2 ("falta 3") |
| a conferência recusando também o saldo exato (`>=`) | 2: o saldo exato (a pergunta) e o do "Quitar" (pelo ERP) |
| a conferência contando as sessões de qualquer venda | 5: as duas vendas, o ERP, a API e a corrida pelos dois |
| o ERP sem a frase na conferência | o do ERP (a frase ainda chega, pela pergunta depois da recusa da baixa, mas a resposta é a de quem ia finalizar) |
| o ERP sem escapar, na conferência | os dois do nome escapado (o do pacote e o do serviço) |
| o nome do serviço sem escapar | o do nome do serviço |
| o ERP sem a frase na recusa do serviço | a corrida pelo ERP |
| a API sem o 409 da conferência | o da ordem com o fiado (o 409 da corrida ainda vem, pela pergunta depois da recusa) |
| a API sem a frase na recusa do serviço | a corrida pela API |
| a API com a recusa do fiado antes da das sessões | o da ordem com o fiado |
| a devolução do erro no meio também com a venda já gravada fechada | nenhum: o erro depois de a venda ser gravada fechada não tem gatilho no arnês |
| a baixa sem a ordem do id | nenhum: a ordem só decide entre dois pedidos ao mesmo tempo |

Os dois verdes ficam ditos, e não são guarda provada: a pergunta ao banco antes de devolver e a ordem dos pacotes estão no
código pelo motivo escrito nele, e nenhum caso falha sem elas. O controle do pacote em -25 continua sem mutante natural.

**A corrida medida no banco** (11/10/2026, `medir-corrida-update-condicional.sh`, nos bancos que os roteiros de teste
remontam; o restante devolvido no fim): o pacote da fixture com 1; o pedido A baixa com o `UPDATE` condicional e segura a
linha 4 s; o B, 1 s depois, manda o mesmo `UPDATE`. Nos dois bancos, B esperou 3 s, afetou 0 linhas (A, 1), e o pacote
terminou em 0, e não em -1.

**Decidido pelo dono em 12/10/2026 (a resposta 82, opção a, e não a b recomendada):** a frase do item de 2 sessões no
pacote que vence antes fica como está -- manda lançar pelo preço, e o cliente paga, mesmo quando outro pacote dele
cobriria o item. É o limite (3) de "Os limites, ditos", acima: escolha registrada, e nada mudou no código.

### D453 — Tirar da venda o item que gastou a sessão do pacote deixa o outro do mesmo serviço pelo preço, com a sessão livre — REGISTRADO em 08/10/2026; a pergunta foi ao lote — CORRIGIDO em 10/10/2026 — PARCIAL em 12/10/2026: o Não do PDV fica no item; falta a tela nova (lote) — PREPARADO em 12/10/2026: a tela nova fica como está (a resposta 159); falta o passo 0 na produção

> **12/10/2026, o item PDV-QUITAR-E-PACOTE:** o dono respondeu a confirmação do "Não" (pergunta 80) com a (b), e NÃO a
> recomendada: o item lançado no PDV antigo com "Usar pacote contratado: Não" não herda a sessão do item tirado, e a
> escolha fica gravada em cada item. Feito no PDV antigo, com a coluna nova nos bancos da suíte, nas duas cópias locais e
> na migração do passo 0. O que a tela nova grava (ela não tem o "Usar pacote") a resposta não disse: por ora o valor
> que faz herdar, como hoje, e a escolha foi ao lote -- o PARCIAL do título. Ver "O 'Não' do PDV antigo fica no item",
> no fim deste débito.

**Achado na verificação do AGENDA-SEGUNDA-RODADA** (o conserto do D435), e não procurando por ele. Lido no
código e provado como fato nos dois bancos; não visto no navegador.

Desde a resposta 45 (o D435), com uma sessão no pacote e dois horários do mesmo serviço, a comanda aberta pela
agenda dá a sessão ao marcado primeiro, e o outro sai pelo preço (89,90 no cenário). Se o operador tira da
comanda o item da sessão -- o barbeiro errado, ou o cliente fez só um dos dois --, `VendaProdutoService::remover`
apaga a linha e não recalcula nenhuma outra: o que ficou continua a 89,90, o finalizar não baixa nada
(`VendaPagamentoService::salvarPacotes` só olha as linhas com pacote), e a sessão fica livre no pacote. O cliente
paga o serviço que o pacote cobriria. Antes da resposta 45, os dois itens apontavam para o pacote, e tirar um
deixava o outro como sessão: o pacote em 0, e o cliente pagava 0. Nesse caminho, é pior do que antes do
conserto.

O contorno depende da tela. No PDV antigo, o operador tira também o que ficou pelo preço e o lança de novo com
"usar pacote", e ele sai como sessão (o último passo de
`D435SessaoDoPacoteNaMesmaVendaTest::testNoPdvOSegundoSaiPeloPrecoEASessaoVoltaQuandoOItemSai`). Na tela nova
não há contorno: ela não manda `pacoteAtivo`, e a API recusa ajustar a linha de uma sessão com "Tire o item e
lance de novo" (`Adm\ComandasController`, linha 553) -- e o item lançado de novo também sai pelo preço.

**Provado como fato de hoje, em pergunta**
(`D435SessaoDoPacoteNaMesmaVendaTest::testNaComandaPelaAgendaTirarOItemDaSessaoDeixaOOutroPeloPreco`, verde no
MySQL e no PostgreSQL): uma sessão, os dois horários, a comanda pela agenda, o item da sessão tirado; o outro
fica a 89,90, e o pacote termina o finalizar com 1. O caso não diz que isso é certo; quando o dono decidir,
ele muda junto, e o último passo do caso do PDV também.

**Por que não foi consertado aqui:** passar a sessão ao item que ficou muda o valor cobrado e a comissão do
barbeiro dele (a do item de sessão é sobre o valor da sessão, 80,00 no cenário, e não sobre o preço), e a
resposta 45 não diz nada sobre tirar itens. A primeira versão do conserto do D435 decidiu sozinha ("o item já
lançado pelo preço continua pelo preço"); a verificação do item a devolveu ao dono. Foi ao lote de 08/10/2026
com as saídas e a recomendação.

**Decidido pelo dono em 10/10/2026** (pergunta 61 da página do lote): a opção (a) -- "ao TIRAR da venda um item que
gastou a sessão do pacote, a sessão passa ao item MAIS ANTIGO da mesma venda, do mesmo serviço, que saiu pelo preço e
que o pacote cobre: ele vira sessão (valor zero), o total da venda cai, e a comissão do barbeiro desse item passa a ser
a de uma sessão (sobre o valor da sessão, e não sobre o preço)". Em todas as portas que tiram item da venda; não mexe
em venda já finalizada.

**O conserto, em 10/10/2026** (o item PACOTE-TERCEIRA-RODADA). **As portas, derivadas do código:** o PDV antigo
(`VendasProdutosController::removerAction`) e a API da tela nova (`Adm\ComandasController::removerItemAction`), as duas
por `VendaProdutoService::remover`. Nenhuma outra escrita apaga linha de `vendas_produtos` (o `grep` de `remove(` e de
`DELETE` em todos os módulos), e o desmarcar da agenda, nas três portas, não toca a comanda. **A regra:** o `remover`
guarda o pacote e a quantidade do item que sai e, depois de apagá-lo, passa as sessões dele
(`passarASessaoDoItemTirado`): percorre os itens da venda do mesmo produto sem pacote, do mais antigo (o id) para o
mais novo, e o que o pacote do item tirado cobre -- saldo nesta venda para a quantidade inteira dele
(`ClientePacoteRepository::saldoDoPacoteNaVenda`: o restante menos as sessões que os outros itens dela apontam, com o
pacote ativo, no prazo, do cliente e do serviço) -- vira sessão pela conta do lançamento (`comoSessao` e
`calcularComissao`, tirados de `calcularLinha` sem mudar uma operação): valor zero, o unitário e o total do relatório
no valor da sessão, o nome do pacote no motivo, e a comissão sobre a sessão. O que o pacote não cobre fica pelo preço,
e o seguinte é perguntado; o item tirado de 2 sessões passa as duas, uma a uma, pela mesma regra. O total cai pela
conta do D450 (`totalSemOItem`, o item arredondado), e a marca do "Quitar" (`RegraDosDebitosNaVenda::excedente`) lê o
mesmo excedente de antes, porque o total e o item caem juntos. Só na venda que ainda se edita
(`RegraDaVendaNoPdv::editavel`) e que tem cliente.

**Os pagamentos, decididos pelo código, sem pergunta:** tirar item já apagava todos os pagamentos da venda e zerava o
recebido e o troco (`remover`, desde 09/09/2026). A venda com um pagamento que cobria o preço do item que virou sessão
fica sem pagamento nenhum, e o operador lança de novo pelo total novo; não nasce crédito nem troco.

**Na cópia** (`franquiabv_dev`, MySQL, em 10/10/2026): nenhuma venda aberta tem item de pacote, e venda finalizada não
muda -- nada a refazer.

**Os limites, ditos.** (1) O item lançado no PDV antigo com "Usar pacote contratado: Não" está pelo preço de propósito,
e o código não o distingue do que saiu pelo preço por falta de saldo -- a escolha vale para o lançamento e não é
gravada no item. Ele também herda, como a regra escrita pede; distinguir pede coluna nova, e foi ao lote como
confirmação (recomendação: como está). Na tela nova o caso é o de todo item (a verificação do item corrigiu a pergunta,
que dizia o contrário): ela não manda `pacoteAtivo`, a API lança com `pacote_ativo` 0, e o corte lançado nela sai pelo
preço mesmo com saldo -- e herda, que é o que a resposta 61 quer ali. (2) A comissão é a do lançamento do PDV e da API, que soma a comissão extra do
colaborador no produto; a comanda pela agenda calcula a sua sem a extra. O item que nasceu pela agenda e herda a sessão
ganha a da sessão com a extra, quando o barbeiro tem uma: a divergência entre as duas portas é anterior, e está no
D465, com a pergunta. (3) "O pacote" é o do item tirado, como a resposta diz: se ele deixou de valer com a venda aberta
(venceu de um dia para o outro, ou foi inativado), o item pelo preço não herda, mesmo com outro pacote do mesmo serviço
com saldo -- esse outro não deu sessão nenhuma ao item tirado (a verificação do item). (4) O item pelo preço de 2 ou
mais herda inteiro quando o pacote tem saldo para a quantidade dele, mesmo que o item tirado tivesse 1 sessão: o pacote
gasta a sessão liberada e mais uma, como se o item tirado nunca tivesse sido lançado e o de 2 entrasse com "usar
pacote". Na tela nova, o toque no corte que já está na comanda soma na linha (`linhaParaSomar`), e o de 2 nasce assim.

**Provado** em `D453SessaoPassaAoQueFicouPeloPrecoTest` (13 casos, novo) e nos dois casos do
`D435SessaoDoPacoteNaMesmaVendaTest` que diziam o fato em pergunta, renomeados
(`testNoPdvOSegundoSaiPeloPrecoEHerdaASessaoQuandoOItemDelaSai` e
`testNaComandaPelaAgendaTirarOItemDaSessaoPassaASessaoAoOutro`), verdes no MySQL e no PostgreSQL: na comanda pela
agenda, tirado o item da sessão, o outro vira sessão -- valor zero, unitário e relatório de 80,00, o nome do pacote no
motivo, a comissão de 35,96 (40% do preço) para 32,00 (40% da sessão) --, o total vai a zero e o finalizar leva o
pacote a 0 (era 1); pelo PDV antigo, a resposta do remover já traz "0,00"; pela tela nova, a comanda volta com o item
marcado `sessaoDePacote` e o total "0.00"; com dois pelo preço, só o mais antigo herda; o item de 2 que o pacote não
cobre fica pelo preço e o seguinte herda; o item tirado de 2 sessões passa aos dois mais antigos; a pomada pelo preço
não herda a sessão do corte; tirar o item pelo preço não mexe na sessão; a venda finalizada, a venda sem cliente e o
pacote inativado não passam nada; o saldo que a regra pergunta, pelo repositório (o restante menos a sessão da venda,
e zero para outro cliente ou outro serviço); e, com os débitos somados pelo "Quitar" e o que ficou com 5% de desconto (85,405, o
meio centavo do D450), os pagamentos saem, o total cai de 135,41 para 50,00, o excedente segue 50,00 e o finalizar com
a bandeira, pago o total novo, baixa os débitos.

**Na verificação do item** (11/10/2026), dois casos novos, verdes nos dois bancos, para o que nenhum caso pegava: **cada
sessão tirada passa a um item só** -- o pacote com 3, o corte da sessão e dois cortes lançados pela tela nova (pelo preço
com saldo, porque a API lança sem "usar pacote"): tirado o da sessão, só o mais antigo dos dois herda, e o total fica
89,90 (sem o `break`, ou sem descontar as sessões passadas, os dois herdavam e o total ia a zero -- com os itens pelo
preço lançados com "usar pacote", como em todos os casos de antes, o filtro do saldo parava o laço sozinho); e **o
pacote vencido** com a comanda aberta não passa a sessão (o filtro da validade de `saldoDoPacoteNaVenda` não tinha
caso).

**Falsificado.** A tabela de 10/10/2026 (11 linhas) foi feita com versões anteriores dos arquivos de teste -- as rodadas
dizem 31 casos, e o caso do saldo pelo repositório ainda não estava nela. A verificação a refez sobre os arquivos finais
(11/10/2026; `medir-mutantes-d452-final.py`, um mutante por vez, restaurado e conferido pelo sha256; os 42 casos do item
em cada rodada, com o mesmo vermelho nos dois bancos):

| mutante | caso vermelho |
|---|---|
| o `remover` sem passar a sessão | 10: as três portas, o mais antigo, o que não cabe, as duas sessões, uma por item, o do "Quitar" e os dois do D435 |
| sem conferir se o pacote cobre | 3: o que não cabe, o pacote inativo e o vencido |
| o mais novo no lugar do mais antigo | 2: o mais antigo e uma por item |
| só um herda (`$sessoes = 0`) | as duas sessões |
| sem o `break` | uma por item |
| sem descontar as sessões passadas | uma por item |
| sem a guarda da venda editável | a venda finalizada |
| sem a guarda da venda sem cliente | a venda sem cliente (erro fatal) |
| o total não cai | 8: as três portas, o mais antigo, o que não cabe, as duas sessões, uma por item e o do "Quitar" |
| o total cai pelo item cru (e não o arredondado) | o do "Quitar" (50,01 no lugar de 50,00) |
| a comissão fica a do preço | o da comanda pela agenda |
| o saldo sem as sessões que a venda já aponta | 2: o que não cabe e o saldo pelo repositório |
| o saldo sem o filtro do pacote ativo | o pacote inativo |
| o saldo sem o filtro da validade | o vencido |

#### O "Não" do PDV antigo fica no item — PARCIAL em 12/10/2026, o item PDV-QUITAR-E-PACOTE

**Decidido pelo dono em 12/10/2026** (pergunta 80 da página do lote, a opção (b), e **não** a recomendada): "O item
lançado com 'Usar pacote: Não' NÃO herda a sessão do item tirado." Pede gravar a escolha em cada item -- coluna nova em
`vendas_produtos`, esquema que só acrescenta, que a resposta 83 (a) leva às duas cópias locais. A pergunta dizia também
que era preciso decidir o que a tela nova grava; a resposta não decidiu, e isso foi ao lote (abaixo).

**O conserto:**

- **A coluna** `vendas_produtos.pacote_recusado`, `tinyint(1) NOT NULL DEFAULT '0'` (no PostgreSQL, `boolean NOT NULL
  DEFAULT false`, pelo gerador): 1 é o "Não" do PDV antigo; 0, o resto -- o "Sim", a tela nova, a comanda pela agenda e
  todo item de antes. Está no `mysql.sql`, no `postgres/schema.sql` (a saída do `gerar-ddl-postgres.php`, com uma linha a
  mais e nada mais), no `scripts/migrar-dados.sh` (a saída do `gerar-migracao-dados.php`: a coluna no `SELECT` e no
  `\copy` da `vendas_produtos`; ela vai crua, 0 ou 1, porque o gerador só converte o que o Doctrine declara booleano, e o
  `boolean` do PostgreSQL aceita os dois -- conferido no contêiner, `'0'::boolean` e `'1'::boolean`) e na migração nova,
  `scripts/migracao-vendas-produtos-pacote-recusado.sql`: idempotente, `ALGORITHM=INSTANT` (não copia a tabela; se o
  servidor recusar o `INSTANT`, o `ALTER` para com erro), a décima do laço do passo 0 do corte e do laço da cópia do
  ensaio geral, e com a linha dela no `procedimento-de-corte.md`.
- **Fora do mapeamento, no molde da R25** (`ISBase\Doctrine\ColunaNoBanco`, o **D473**): a entidade `VendaProduto` é lida
  por quase todo o ERP, e mapeada pediria a coluna em toda leitura -- a base do teste do BVIP, que só a ganha na próxima
  cópia exportada, cairia inteira. `VendaProdutoRepository` pergunta antes (`guardaARecusaDoPacote`), grava pela conexão
  (`gravarARecusaDoPacote`, um `UPDATE` só, e só no "Não") e lê os ids dos recusados da venda (`recusaramOPacote`). No
  banco sem a coluna nada grava nem lê: o "Não" vale só para o lançamento, como antes, sem erro.
- **Quem diz a escolha é a porta, e não o `pacote_ativo`.** O PDV antigo (`VendasProdutosController::adicionarAction`)
  passa `pacoteRecusado` quando o "Usar pacote contratado" (`#pdvcliente_pacote`, no `mod-vendas.js`, que não mudou)
  vem "0", e `VendaProdutoService::insert` só então grava. A API da tela nova lança com `pacote_ativo` 0 sem escolha de
  ninguém -- a tela não tem o "Usar pacote" -- e não passa nada: o item dela fica com o 0 da coluna, o valor que faz
  herdar, como a resposta 61 pede ali. A comanda pela agenda (`VendaService::comandaAbrirAgenda`) não passa pelo `insert`,
  e o padrão da coluna faz o mesmo. Ajustar o item (`alterar`) não toca a coluna: a escolha segue com a linha.
- **A remoção pula o "Não"** (`passarASessaoDoItemTirado`): o item recusado não herda, e o seguinte pelo preço é
  perguntado, pela regra da 61.

**Na cópia, as escritas** (12/10/2026). Conferido antes, só com leitura: a coluna **não existia** na `franquiabv_dev` (no
MySQL, a `vendas_produtos` com 12 colunas; no PostgreSQL, com 13, contando o `inquilino_id`) e existia nos bancos da
suíte, montados do `mysql.sql` e do `schema.sql` novos (`franquiabv_cilocal` e `franquiabv_pgtestes`); nenhuma transação
no `INNODB_TRX`, nenhum processo no MySQL além do agendador, nenhuma sessão de cliente no `pg_stat_activity`, nenhum PHP
no contêiner além do `php-fpm`, e o `local.php` na `franquiabv_dev`.

- **MySQL**, às 09:01:09: a própria migração, como o laço do ensaio a roda (`docker exec -i franquiabv-mysql-1 mysql
  -uroot … franquiabv_dev < scripts/migracao-vendas-produtos-pacote-recusado.sql`), saída 0, e a conferência dela:
  3.264.661 itens, 0 recusados. No `binlog.000048`, desde a posição anotada antes (32516955), **um evento só**: o `ALTER
  TABLE vendas_produtos ADD COLUMN pacote_recusado ... ALGORITHM=INSTANT` (lido pelo `SHOW BINLOG EVENTS`, até
  32517397); 2 s com a conferência que conta as linhas, medidos de fora. Na segunda vez, às 09:01:21, "coluna ja existe,
  nada a fazer", e o binlog parado. O `SHOW CREATE TABLE` de antes e o de depois diferem numa linha, a da coluna.
- **PostgreSQL** (esquema `bvip`), às 09:02:31, como `franquiabv`, o dono da tabela: a linha que o gerador emite para a
  coluna, numa transação só, com a conferência de ausência dentro dela, pelo `psql` do contêiner com o roteiro na entrada
  (`docker exec -i franquiabv-postgres-1 psql -U franquiabv -d franquiabv_dev < <o roteiro>`). Saída 0; na segunda vez,
  "a coluna ja existe: nada feito". O roteiro, inteiro:

  ```sql
  \set ON_ERROR_STOP on
  BEGIN;
  SET LOCAL search_path = bvip, public;
  SET LOCAL lock_timeout = '10s';
  SELECT count(*) = 0 AS ausente_antes
    FROM information_schema.columns
   WHERE table_schema = 'bvip' AND table_name = 'vendas_produtos'
     AND column_name = 'pacote_recusado' \gset
  \if :ausente_antes
  \else
    \echo 'a coluna ja existe: nada feito'
    ROLLBACK;
    \quit
  \endif
  ALTER TABLE vendas_produtos
      ADD COLUMN pacote_recusado                  boolean NOT NULL DEFAULT false;
  COMMIT;
  ```
- **A conferência, só leitura, contra os bancos da suíte:** no MySQL, a forma da `vendas_produtos` -- as 13 colunas
  (posição, tipo, nulabilidade, padrão, conjunto, comparação, extra, chave e comentário), os 5 índices, as 4 chaves
  estrangeiras com as regras e as opções da tabela, 23 linhas do `information_schema` -- igual linha a linha à da
  `franquiabv_cilocal`. No PostgreSQL, as 14 colunas (com o padrão e a identidade), os índices, as restrições, o RLS ligado
  e forçado e a política iguais aos da `franquiabv_pgtestes` (28 linhas), e a lista de privilégios da tabela também. A
  coluna 0 nos 3.264.661 itens do MySQL e nos 3.264.612 do PostgreSQL. E a pergunta do código: `guardaARecusaDoPacote`
  responde `true` contra a `franquiabv_dev` do MySQL (medido em processo, pelo PHP do contêiner), e no PostgreSQL a
  consulta da `ColunaNoBanco`, com o `search_path` do `bvip_app` (`bvip, public`), conta 1. O `SELECT` da carga do corte
  para a `vendas_produtos`, com a coluna, responde nos dois bancos (medido só ele, por leitura, num `COUNT(*)` por fora).
  Nada de `DELETE`, `UPDATE` de dado nem `ANALYZE`.
- **O passo 0 ensaiado**, num banco descartável montado do dump de produção (`git show 3b1f05c:mysql.sql`,
  `r35_descartavel_passo0`): o laço inteiro, as dez, duas vezes, as vinte com saída 0, e a estrutura do banco inteiro
  (1.410 linhas do `information_schema`) igual à da `franquiabv_cilocal`, menos a `iframe_bi`, que só desloca a ordem das
  colunas de `unidades`. O banco foi apagado em seguida.

**Os portões que mudaram:** o `conferir-passo-zero-do-corte.php` sai 0 com as dez nos dois laços, e 1 com o laço do
procedimento sem a décima, com o laço da cópia sem ela, e com o `mysql.sql` dizendo `int` no lugar de `tinyint(1)` (três
cópias dos arquivos, `--procedimento`, `--ensaio` e `--mysql-sql`). O `conferir-schema-tool.sh` foi de 6 a 7, com o motivo
no roteiro: a coluna fora do mapeamento é, para o `schema-tool`, coluna a apagar (o custo que o D473 previa). O
`verificar-schema-postgres.php` e o `conferir-tipos-do-mapeamento.php` passaram a exigi-la sem mudança neles, porque a
lista sai das chamadas do código (o **D475**): 0 contra as duas cópias, com as três colunas lidas; e o
`verificar-schema-postgres.php` contra um banco descartável montado do `schema.sql` de antes (sem a coluna) sai 1, com
"vendas_produtos.pacote_recusado (lida em module/ISVenda/src/Entity/VendaProdutoRepository.php)" -- o banco foi apagado
em seguida. **Achado por eles:** a primeira versão de `guardaARecusaDoPacote` passava a conexão como
`$this->getEntityManager()->getConnection()`, dentro da chamada; o leitor das chamadas não lê o primeiro argumento com
parênteses, e os dois portões saíram com 2, "não consegui ler". A conexão foi para uma variável, e o comentário diz por
quê. O `ColunasForaDoMapeamentoTest` ganhou a terceira coluna na lista, e o `conferir-cobertura-por-par.php` os três pares
novos do item (de 335 a 338, o ISVenda de 119 a 123, citados por nome nos casos; o "sem menção" ficou em 147).

**Provado** em `D453SessaoPassaAoQueFicouPeloPrecoTest` (19 casos; eram 15, quatro novos), verdes no MySQL e no
PostgreSQL, com os 141 do filtro do item (ver o D380, "A quarta rodada"): pelo PDV antigo, com os campos que o
`mod-vendas.js` manda, o corte com "Sim" sai como sessão e o com "Não" pelo preço, com a escolha gravada (`[0, 1]`) e
achada pela leitura; tirado o da sessão, o do "Não" fica a 89,90 e o total não cai; com a sessão, um "Não" e um "Sim"
pelo preço, o "Não" é pulado e o "Sim" herda; a coluna de cada porta -- o "Não" do PDV 1, o "Sim" 0, o da tela nova 0, os
dois da comanda pela agenda 0; e o banco sem a coluna, fingido pela resposta guardada da `ColunaNoBanco`: nada a grava
(nem a gravação chamada direto), e o "Não" escrito à mão não é lido -- o item herda, como antes da 80. Os casos de antes
seguem verdes: os da tela nova herdam (`testCadaSessaoTiradaPassaAUmItemSo`). Contra o código de antes, os 4 novos
vermelhos, nos dois bancos. **O caso que lê a coluna pelo banco** usa as linhas inteiras, e não o `fetchFirstColumn`: no
PostgreSQL ela é `boolean`, e o `fetchFirstColumn` do DBAL 2.13 para no primeiro `false` (medido no contêiner: `[false,
true]` volta `[]`). Nenhum código de produção lê coluna booleana assim (o `grep` em `module/`: os dois que o usam leem
ids).

| mudança | vermelho (o mesmo no MySQL e no PostgreSQL) |
|---|---|
| o lançamento sem gravar a recusa | 3: o "Não" que não herda, o "Não" pulado e a coluna de cada porta |
| a remoção sem pular o "Não" | 2: o "Não" que não herda e o "Não" pulado |
| o PDV antigo sem a escolha (sempre "Sim") | 3: os mesmos do lançamento |
| o PDV antigo recusando sempre | 3: os mesmos |
| o serviço lendo o `pacote_ativo`, e não a porta | 2: a coluna de cada porta (a tela nova gravaria "Não") e "cada sessão tirada passa a um item só" (os itens da tela nova deixariam de herdar) |
| a gravação sem o guarda da coluna | 1: o banco sem a coluna |
| a leitura sem o guarda da coluna | 1: o banco sem a coluna |

**O que fica:**

- **a tela nova -- o PARCIAL do título.** O que ela grava é escolha do dono, e foi ao lote (12/10/2026), com a
  recomendação de manter como está: os itens dela herdam (o padrão da coluna). As outras saídas escritas lá: gravar "Não"
  em todo item dela (desfaz a 61 na tela nova), ou dar a ela o "Usar pacote" (o campo `pacoteAtivo`, que a API já aceita
  e nenhuma tela manda, passa a ser a escolha);
- **a produção**, no passo 0 do corte; **a base do teste do BVIP**, na próxima cópia exportada daqui, pela mão do dono --
  até lá, nela, o "Não" vale só para o lançamento, sem erro; e o servidor de teste, cujo `atualizar` recusa pela migração
  nova até `ACEITAR_ESQUEMA=sim`, sem ALTER (`docs/ambiente-teste.md`);
- **a coluna fora do mapeamento** é o **D473**: mapeá-la depois da exportação;
- o PDV antigo não foi aberto num navegador com o "Não".

**Decidido pelo dono em 12/10/2026 (a resposta 159, opção a, a recomendada):** os itens da tela nova herdam a sessão,
como está; quem quer guardar a sessão lança pelo PDV antigo com "Não". Nada mudou no código, e o caso que o diz
(`testCadaSessaoTiradaPassaAUmItemSo`) passa a ser o comportamento escolhido. **O que resta** é a coluna
`vendas_produtos.pacote_recusado` onde ela ainda não existe -- a produção, no passo 0 do corte, e a base do teste do
BVIP, na próxima cópia exportada daqui, pela mão do dono --; o código está pronto, e sem a coluna o "Não" vale só para o
lançamento, sem erro. Por isso PREPARADO. A coluna fora do mapeamento segue no D473.

### D454 — O botão "Agendamentos" do modal de marcar abre a janela do cliente 7136, fixo no JavaScript, e não a do escolhido — REGISTRADO em 08/10/2026 — CORRIGIDO em 11/10/2026

**Achado na verificação do AGENDA-SEGUNDA-RODADA** (o D436), e não procurando por ele. Lido no código e
contado na cópia; não visto no navegador.

No modal de marcar da agenda antiga, ao escolher o cliente no autocompletar (o `select:` do `#cliente_nome`, no
`mod-cadastros.js`), aparece o link "Agendamentos" (`#btn_agendamentos`, `agendas/index.phtml:250`), com o
clique escrito assim:

    $("#btn_agendamentos").attr("onclick", "agendaModalHorariosMarcados('', '7136', '', '');");

O id é fixo, qualquer que seja o cliente escolhido. O clique manda `cliente_id=7136` ao
`horariosMarcadosAction`, e a janela "HORÁRIOS MARCADOS" abre com o nome do cliente 7136 e os horários dele, de
hoje em diante, na unidade da sessão; o "Abrir comanda" dela abre (ou mostra a já aberta) a comanda do 7136.
Vem do commit inicial (`eaf1fca`, pelo `git log -S`), e não deste item. Na cópia (`franquiabv_dev`, só
contagens, em 08/10/2026): o cliente 7136 existe e tem 102 horários ativos, nenhum de hoje em diante -- na
cópia, a janela mostraria o nome dele e a lista vazia; com horários futuros, os de uma pessoa que ninguém
escolheu.

O item não piora nada aqui: sem `agendamento_id`, o servidor usa a unidade da sessão, como antes
(`D436ModalDosHorariosMarcadosTest::testSemOHorarioClicadoOModalListaOsDaSessao` prova o servidor com o pedido
sem horário clicado, e não este botão). A primeira versão do registro do D436 citava o botão como o caminho
legítimo "sem horário clicado"; o texto foi corrigido.

**O conserto parece de uma linha** -- o `ui.item.id` do autocompletar no lugar do 7136, como as linhas seguintes
já fazem para o histórico de pontos e o de consumo --, com o `?cache=` do `mod-cadastros.js` subindo, e um caso
no arnês do front (`remarcarDaTelaAntiga.teste.ts` roda as funções do arquivo servido com um jQuery de mentira).
Não é decisão do dono; ficou fora porque o item era o D436, e a verificação recomendou registrar.

**O conserto (11/10/2026, no item D322-CONSERTOS).** O `onclick` do "Agendamentos" passou a levar o `ui.item.id` do
cliente escolhido, como o histórico de pontos e o de consumo logo abaixo já faziam -- pelo `parseInt`, porque o id vai
para dentro de um `onclick` escrito como texto, e só o número entra. O `?cache=` do `mod-cadastros.js` subiu de 166 para
167 no `layout.phtml` (não há `.min.js` dele), e o piso do `remarcarDaTelaAntiga.teste.ts` subiu junto. Nenhum outro
teste do front pede o endereço (o grep por `horariosMarcados`, `clientes/pesquisar` e `mod-cadastros.js` só acha esse
arquivo).

**Provado** no `remarcarDaTelaAntiga.teste.ts`, que roda o arquivo servido com o jQuery de mentira: o bloco do topo
(`$(document).ready`) roda inteiro, o caso chama o `select` do autocompletar que ele registrou e roda o `onclick`
montado -- o pedido ao `horariosMarcadosAction` sai com o `cliente_id` do escolhido (90001000), e escolher outro troca
(90001001); o id que não fosse só número vira só o número. Mutantes, cada um sozinho e restaurado byte a byte: o 7136 de
volta derruba os dois casos do id; sem o `parseInt`, cai o do id que não é número; o `?cache=166` derruba o piso. **Não
provado:** o jQuery e o jQuery UI de verdade, e a tela antiga num navegador -- não a abri.

**A verificação achou o conserto pela metade (11/10/2026), e ele foi completado.** Só o `select` do autocompletar
mostrava e reescrevia o "Agendamentos" (`#btn_agendamentos`), e nada o escondia: o "Agendar" de outro horário
(`agendaAgendarServico`, que limpa o cliente e esconde os dois históricos, mas não esse link), o "Cliente sem cadastro"
(`agendaClienteSemCadastro`, que põe o id 2) e o cliente novo salvo pelo "+" (`agendaClienteSalvar`, que põe o id novo no
campo sem tocar no link) deixavam o link no cliente da marcação anterior -- e, com ele, o "Fazer check-in" e o "Abrir
comanda" da janela "HORÁRIOS MARCADOS". Era o mesmo efeito do 7136 (o cliente que ninguém escolheu neste Marcar). O irmão
vinha de antes: no próprio `select`, o "Hist. de Pontuação" e o "Hist. de Consumo" só ganhavam `show`/`href` quando o
cliente tinha fidelidade ou visita, sem nada que os escondesse; escolhido um cliente sem fidelidade depois de um com, o
link seguia no anterior.

**O conserto:** os três links e os dois separadores saem de uma função só, `agendaMarcarLinksDoCliente(cliente)`
(`mod-cadastros.js`), que esconde tudo e mostra só o que é do cliente dado; sem cliente, ou com id que não é número,
nenhum. Os lugares que trocam o cliente do Marcar, derivados do grep por `#modal_agenda_marcar #cliente_id` no arquivo (o
único que o escreve; as outras telas com o mesmo modal têm funções próprias, `pdvClienteSalvar` e `pdvClienteSemCadastro`):
o `select` (o cliente escolhido), o `agendaAgendarServico` (nenhum), o `agendaClienteSemCadastro` (nenhum), o
`pesquisar` sem cadastro achado em `agendaClienteModal` (nenhum, já que limpa o campo) e o `agendaClienteSalvar` (o
cliente salvo, só com o "Agendamentos", quando ele não é o que já estava no Marcar; editado o mesmo cliente, os links
dele ficam). O `?cache=` segue no 167, que ainda não saiu do repositório.

**Provado** no `remarcarDaTelaAntiga.teste.ts` (o jQuery de mentira passou a pôr `display` no `show`/`hide`, e o caso
confere o que ficou à vista): o "Agendar" de outro horário, o "Cliente sem cadastro" e o cadastro que não se acha tiram
os links do cliente anterior; o cliente novo salvo ganha o "Agendamentos" dele (o clique pede o id novo), sem os
históricos; editar o mesmo cliente deixa os três links dele; o cliente sem fidelidade nem visita depois de um com fica
só com o "Agendamentos"; o id que não é número não mostra nenhum; e o separador depois do "Agendamentos" aparece com
qualquer histórico depois dele (abaixo). 25 de 25 no arquivo. Mutantes, cada um sozinho e
restaurado byte a byte, todos vermelhos: a função sem esconder o "Agendamentos" (3 casos), sem esconder o histórico de
pontos (5), sem a guarda do número (5), sem o `parseInt` (1), o 7136 de volta (7), o `select` sem a função (5), o
`agendaAgendarServico` com os três `hide` de antes (1), o "sem cadastro" sem a função (1), o cadastro não achado sem a
função (1), o `agendaClienteSalvar` sem reescrever (2) e reescrevendo sempre (1), o separador só com a fidelidade, como antes (1), e
sempre (2). **No navegador**, na mesma página de
ensaio do D226, com o jQuery 1.12.4 e o jQuery UI do ERP e a marcação dos links copiada do `agendas/index.phtml`: escolhida
a cliente com fidelidade e visita, aparecem "Agendamentos | Hist. de Consumo | Hist. de Pontuação"; o "Agendar" de outro
horário apaga os três (e o `onclick`); o cliente novo salvo fica só com "Agendamentos", no id dele; o "Cliente sem
cadastro" apaga tudo; só com visita, "Agendamentos | Hist. de Consumo"; só com fidelidade, "Agendamentos | Hist. de
Pontuação"; sem nenhum dos dois, só "Agendamentos", com o `href` do histórico de pontos de volta a `javascript:;`. **Não
provado:** a agenda antiga de verdade, com a sessão.

**E o separador, achado ao escrever a função:** o `#agendamentos_sepatador` só aparecia com a fidelidade, e o cliente só
com visita via "Agendamentos" e "Hist. de Consumo" grudados. Passou a aparecer com qualquer histórico depois do
"Agendamentos" -- é o que a ordem dos links no `agendas/index.phtml` pede, e não muda o que cada link abre.

### D455 — A família de um horário saía na ordem do plano, e o índice de `id_pai` pôs os filhos antes da raiz no PostgreSQL — CORRIGIDO em 09/10/2026

**Achado ao rodar a caracterização inteira no PostgreSQL com o índice do D220** (09/10/2026, o item
INDICE-ID-PAI-NO-CORTE). `AgendaRepository::selecionarHorariosFilhos` não tinha `ORDER BY`: a família
saía na ordem do plano. No MySQL o plano lê pela chave primária -- a varredura da tabela, e, com o índice,
o `index_merge`, que junta as duas leituras "sorted by row ID" --, e a ordem do id saía por acaso. No
PostgreSQL ela nunca foi prometida (a varredura paralela junta as linhas dos trabalhadores na ordem em
que chegam), e com o índice novo o planejador passou a ler a agenda das fixtures pelo índice de
`(inquilino_id, id_pai)`, em que o `id_pai` nulo da raiz vem por último. Na suíte inteira caiu
`AgendamentoCriadoTest::testServicoDeDoisPeriodosGravaDoisHorariosLigados` ("o primeiro é o pai"); numa
rodada só dos vizinhos da agenda, caíram também `EscritasDaAgendaTest::testDesmarcarApagaEArquivaAFamilia`
e `testDesmarcarPeloFilhoRetrataAFamiliaInteira` (o `removidos` da API em outra ordem) -- o plano muda de
uma rodada para outra, com as estatísticas da tabela.

**Quem depende da ordem:** `AgendaService::checkin` decide o push pela primeira linha e escreve as horas
na ordem da lista; `EscritasDaAgenda` devolve nessa ordem a família da chegada (`familia`) e o retrato do
Desmarcar (`removidos`) da API nova.

**O conserto:** `ORDER BY age.id` na consulta -- a raiz, depois os filhos, na ordem em que foram
marcados, nos dois bancos. É a ordem que o MySQL de produção já devolve, e o mesmo desempate pelo id que o
dono decidiu para os horários do cliente (D426, pergunta 46). Custa ordenar de 1 a 3 linhas: nos bancos
descartáveis do D220, com 400 mil linhas inventadas, o MySQL segue no `index_merge` da `PRIMARY` com o
índice novo, mais um `Using filesort` (0,14 ms no `EXPLAIN ANALYZE`), e o PostgreSQL faz um `Sort` sobre o
`BitmapOr` (0,23 ms).

**A frase da opção (a) da pergunta 50 -- "Nenhum código da aplicação muda" -- deixou de valer por esta
linha.** O índice não pede código; o que ele fez foi tirar do lugar uma ordem que o código supunha sem
pedir. A linha não muda nada no MySQL de hoje, onde a ordem já era essa.

**Provado:** os vizinhos da agenda (`AgendaApiTest`, `AgendamentoCriadoTest`, `DesmarcarAgendamentoTest`,
`EscritasDaAgendaTest` e `RemarcarAgendamentoTest`, 81 casos) verdes no PostgreSQL, e a caracterização
inteira nos dois bancos (os números no `docs/roadmap.md`, 09/10/2026). O mutante sem o `ORDER BY` derruba os
três casos acima no PostgreSQL. **No MySQL nenhum caso pega o mutante**, e não há como: lá a ordem do id sai
do próprio plano, com ou sem o índice.

### D456 — A campanha de push para "Usuários" usa a marcação dos clientes, e chegaria ao cliente de mesmo número — REGISTRADO em 10/10/2026 — CORRIGIDO em 10/10/2026

**Achado na comparação OneSignal x Firebase x Novu** (09/10/2026, a pedido do dono), e não procurando por ele.
Lido no código e contado na cópia; nunca aconteceu na cópia.

A tela de campanhas aceita os dois públicos para qualquer tipo (`NotificacaoEnvio::DESTINATARIOS = ['cliente',
'usuario']`; `Arrays::marketingDestinatarios`). No envio, o id do destinatário vai direto ao OneSignal
(`CronJobService::marketingEnvios`, o `enviarNotificacaoPush(..., $destinatarioId, ...)`), e o
`OneSignal::enviarNotificacaoPush` filtra os aparelhos pela marcação `usuario` igual a esse id. Essa marcação é
a que o aplicativo DOS CLIENTES grava com o id do cliente. Numa campanha de push para "Usuários", o id é o do
colaborador -- e o OneSignal entrega ao cliente cujo id é o mesmo número, se ele tiver o aplicativo.

**Na cópia (`franquiabv_dev`, só contagem, em 10/10/2026):** nenhuma campanha de push para "Usuários" foi feita
até a data da cópia (2.186 de push, todas para clientes; para usuários, 5, todas de WhatsApp). O defeito é
latente: a tela permite, e ninguém usou.

**O conserto sem decisão:** recusar o tipo push para o público "Usuários" no formulário e no serviço (não há
aplicativo de colaborador no mesmo OneSignal, que se saiba). Se houver um aplicativo de colaboradores, a escolha
é outra (uma marcação própria) e é do dono. Vale para qualquer provedor: na troca do OneSignal (a comparação de
09/10), o público entra no identificador do assinante (`cliente:123`, `usuario:45`).

**O conserto, em 10/10/2026 (PUSH-FCM-ENVIO):** o push vai só a cliente (`RegraDaCampanha::canalAceito`), em quatro
portas. O **formulário** recusa a campanha nova de push para "Usuários" -- o `validarFormulario`, que a tela chama
antes, e o `salvar` --, com a frase "O push vai só ao aplicativo dos clientes: para colaboradores, use o WhatsApp.";
a **edição** confere o destinatário GRAVADO (a campanha de WhatsApp para usuários editada para push é recusada); o
**serviço** (`NotificacaoService::salvar`) lança, para quem chegar a ele por outro caminho; e a **rodada**
(`enviarCampanha`) não assume a de push para usuários que já estivesse gravada -- nada sai, e ela fica agendada para
a tela cancelar. O WhatsApp para usuários continua, e o push para clientes também.

**Provado** no `PushNasRotinasTest`, nos dois bancos -- os casos e os mutantes, um por porta (os mutantes rodaram
primeiro só no MySQL, e o registro dizia "nos dois bancos"; a verificação de 10/10/2026 os repetiu no PostgreSQL, um
de cada vez): tirar a do formulário, a da edição, a do serviço ou a da rodada deixa o caso dela vermelho nos dois (a
da edição cai no serviço, com a exceção, em vez da frase). Se
um dia houver um aplicativo de colaboradores, o público dele é escolha do dono (uma marcação ou um assinante próprio).

### D457 — Um backup do local.php guarda as chaves antigas do OneSignal fora do git — REGISTRADO em 10/10/2026

**Achado na mesma comparação.** O arquivo `config/autoload/local.php.backup-20260815194759`, que o git ignora
(`.gitignore`), tem as quatro chaves do OneSignal preenchidas (o app e o web push; conferidos só os
comprimentos, 36 e 48 caracteres, o formato da chave legada; os valores não foram impressos). São as mesmas que
estiveram no código antes de 15/08/2026 (D3) e que o D3 manda trocar. O arquivo fica fora do webroot (`public/`
é o que o nginx serve) e nesta máquina; o risco é o de qualquer cópia de chave esquecida.

**O que falta, e é do dono:** apagar o arquivo depois da troca das chaves (o D3, que o dono adiou junto com o
resto do OneSignal: "por hora, ignorar tudo do onesignal"). Não apaguei: o arquivo não é meu, e apagar é
irreversível.

### D458 — O convite do aplicativo conta a quem está logado que o telefone já está na base, e não tem teto — REGISTRADO em 10/10/2026; a pergunta foi ao lote — DECIDIDO em 11/10/2026: fica como está

**Achado no item ADESAO-ETAPA-6** (10/10/2026), lido no código. O convite do aplicativo
(`AplicativoService::indicacaoCadastrar`, pelas duas rotas: a congelada `POST /api/cliente/indicacao`, com a credencial
do sistema, e a `POST /api/v4/clientes/:id/indicacoes`, com a sessão do cliente) procura o telefone em todos os
clientes, ativos e inativos, e responde "Este telefone já está vinculado a uma indicação." quando acha um. Para o mesmo
telefone, a página da adesão passou a responder como ao amigo aceito (o D304), porque é pública. E o aplicativo não tem
o teto do dia: os convites dele entram na conta da página, mas nenhum é recusado por ela.

A diferença para a página: ali é preciso estar logado, e o pedido fica com quem o fez. O que vaza é só "este número é
de algum cliente da rede", sem nome. Mudar a resposta muda o que o aplicativo publicado mostra (o convidado que já é
cliente não apareceria em "minhas indicações", e a tela diria que deu certo), e o teto no aplicativo é regra nova --
as duas coisas são do dono. Foram ao lote numa pergunta só, com a inferência pelo teto da página (o D304). Nada mudou
no código.

**Decidido pelo dono em 11/10/2026 (a resposta 69, opção a, a recomendada):** as duas portas ficam como estão -- o
aplicativo segue respondendo "Este telefone já está vinculado a uma indicação." e sem o teto do dia, e a inferência
pelo teto da página também fica (o D304). O que vaza é "este número é de algum cliente da rede", sem nome; fechar
pedia mudar o aplicativo publicado e uma tabela nova no corte. Nada mudou no código, por decisão (o item
ADESAO-QUARTA-RODADA, que só o registra).

### D459 — A primeira compra cancelada do convidado deixa a comissão com quem indicou, e a venda refeita não paga — REGISTRADO em 10/10/2026; a pergunta foi ao lote — DECIDIDO em 11/10/2026: fica como está

**Achado na verificação do item ADESAO-ETAPA-6** (10/10/2026), lido no código e caracterizado no teste
(`D304EtapaSeisDaAdesaoTest::testAPrimeiraCompraCanceladaGuardaAComissaoEARefeitaNaoPagaDeNovo`, nos dois bancos). A
comissão da primeira compra do convidado (`VendaPagamentoService::gerenciarComissaoIndicacaoPrimeiraVenda`) soma o
percentual da unidade ao `clientes_creditos.valor` de quem indicou e marca a ligação (`indicacoes.data_venda`,
`comissao`, `primeira_venda`). O `VendaService::cancelar` põe a venda em 0, estorna o caixa e o crédito do próprio
cliente, e apaga os históricos de pontos presos à venda -- também os 10 pontos de quem indicou (o D284) --, mas não
toca na ligação nem no crédito da comissão: `grep -i indicac` em `module/ISVenda/src/Service/VendaService.php` só acha
o comentário do D284. Quem indicou fica com o crédito da venda que deixou de existir, gastável no caixa; a venda
refeita é a única finalizada do convidado, e a guarda da marca (`getDataVenda() !== null`) a impede de pagar.

Já era assim no convite do aplicativo; desde a resposta 53 (o D446), vale também para o amigo da etapa 6. Na cópia,
nenhuma primeira compra de convidado foi cancelada (há 2 primeiras compras nas 10 linhas de `indicacoes`). O D383 fala
desta escrita só pela falta de trava. Desfazer a comissão no cancelamento -- e se a venda refeita paga, e o que fazer
se o crédito já foi gasto -- é regra do dono: foi ao lote, com a recomendação de desfazer e deixar a refeita pagar
(o cancelamento já tira, sem olhar o saldo, o crédito de uma venda de crédito). Nada mudou no código.

**Decidido pelo dono em 11/10/2026 (a resposta 68, opção c -- e não a recomendada, a (a)):** fica como está. A comissão
da primeira compra cancelada continua com quem indicou, a ligação segue marcada pela venda cancelada, e a venda refeita
não paga de novo. O caso que caracterizava o defeito passa a prender a regra decidida
(`D304EtapaSeisDaAdesaoTest::testAPrimeiraCompraCanceladaGuardaAComissaoEARefeitaNaoPagaDeNovo`, verde nos dois bancos
em 11/10/2026, com a resposta no comentário). Nada mudou no código, por decisão (o item ADESAO-QUARTA-RODADA). O D383
continua falando desta escrita pela falta de trava.

### D460 — O indicado nasce sem país, o formulário público pula a etapa do CEP, e ele termina inativo — REGISTRADO em 10/10/2026; a pergunta foi ao lote — PARCIAL em 11/10/2026: o de fora do país, no lote — PARCIAL em 12/10/2026: a etapa 3 é de todo país (a resposta 146); o sem país e a etapa 3 de fora, no lote — CORRIGIDO em 13/10/2026 (as respostas 163 e 164)

**Achado na verificação do item ADESAO-ETAPA-6** (10/10/2026), lido no código e contado na cópia (só leitura). O
construtor do `Cliente` lê o país da unidade da sessão do ERP (`SessaoAcl::getUnidade("pais")`); a página da adesão é
pública, e o amigo que o `FidelidadeService::indicarAmigo` cria nasce com `pais` nulo -- o convite do aplicativo grava
"Brasil" (`AplicativoService::indicacaoCadastrar`). O `FidelidadeController::cadastroAction` põe o país do cliente no
campo oculto `pais`; vazio, o `salvarDadosAction` leva da etapa 2 à 4 quem não é "Brasil", e o
`fidelidade_cadastro.js` faz o mesmo na tela. A etapa 3 é a que deixa o cliente ativo, troca a origem
"Indicação - <id>" por "Indicação" e marca `data_adesao` na ligação. Sem ela:

- o amigo conclui a adesão **inativo**, e quando indica alguém na etapa 6 dele, indica inativo -- e a ligação é
  gravada, e a comissão paga a ele (o convite do aplicativo recusa o indicador inativo; ver o D446);
- quem o indicou segue ganhando **10 pontos por compra** do amigo, pela origem com o id (o D446);
- a ligação nunca ganha `data_adesao`.

| na cópia (`franquiabv_dev`, 10/10/2026) | |
|---|---|
| amigos da etapa 6 com a origem `Indicação - <id>` | 183 |
| ... sem país | 125 (os 110 inativos, todos, e 15 ativos) |
| ... com país (todos ativos) | 58 |

O teste prende o país nulo (`D304EtapaSeisDaAdesaoTest::testOAmigoNovoNasceInativoComALigacaoDoConviteDoAplicativo`):
o amigo nascendo com "Brasil" o deixa vermelho (o mutante, no PostgreSQL).
Não provei a etapa 2 do amigo em processo: a validação do e-mail consulta o DNS (`IdealizeUtil::validarEmail`, o
`checkdnsrr`), e nenhum teste sai da máquina. Dar o país ao amigo muda a jornada dele (uma etapa a mais, o endereço, e
o fim inativo vira ativo) e se cruza com as perguntas dos 10 pontos por compra e do indicador inativo: foi ao lote,
com a recomendação de o amigo nascer com o país da unidade de quem indicou. Já era assim antes da resposta 53; nada
mudou no código.

**Decidido pelo dono em 11/10/2026 (a resposta 66, opção a, a recomendada):** o amigo da etapa 6 nasce com o país da
unidade de quem indicou (no Brasil, "Brasil", o mesmo que o convite do aplicativo grava) e passa pela etapa 3; vale
daqui em diante -- os 125 de antes não mudam.

#### O conserto (11/10/2026, o item ADESAO-QUARTA-RODADA)

O `FidelidadeService::indicarAmigo` dá ao amigo o país da unidade de quem indica (`$cliente->getUnidade()->getPais()`;
`unidades.pais` não aceita nulo, e na cópia as 63 unidades do Brasil dizem "Brasil" e a de Portugal, inativa,
"Portugal"). Com o país, a página do amigo leva "Brasil" no campo oculto `pais`, o `fidelidade_cadastro.js` vai da
etapa 2 à 3, e a etapa 3 o deixa ativo, troca a origem por "Indicação" e marca `data_adesao` na ligação. A etapa 1 do
amigo também muda: o telefone dele aparece inteiro (o `Cliente::getTelefone(false)` mostrava só o "(47)" a quem não é
do Brasil). Os amigos de antes, sem país, seguem como estão: nada foi gravado neles.

**Provado nos dois bancos, em série** (`D304EtapaSeisDaAdesaoTest`, 33 casos; os vizinhos, 758 -- o D304): o amigo novo
nasce com "Brasil"; a página dele, renderizada pelo `ViewRenderer`, leva `value="Brasil"` no campo `pais` e o telefone
inteiro; a etapa 3 dele conclui (a próxima é a 4) e o deixa ativo, com a origem "Indicação" e a data da adesão na
ligação, sem os 10 pontos da etapa 6 de novo a quem indicou; e o amigo de quem indica numa unidade de Portugal nasce
com "Portugal" (o país é o da unidade, e não um "Brasil" escrito). **Não provei a etapa 2 despachada:** a validação do
e-mail consulta o DNS (`IdealizeUtil::validarEmail`), e nenhum teste sai da máquina -- o que leva à etapa 3 é o campo
oculto que a página renderizada mostra, e que o JavaScript lê (lido, não executado).

| mutante (sozinho, restaurado e conferido pelo `sha1`; no PostgreSQL) | caso vermelho |
|---|---|
| o amigo sem o país (a linha arrancada) | o amigo novo; a etapa 3 do amigo (o campo `pais` vazio); o de Portugal; o de quem indica inativo |
| o país "Brasil" escrito, e não o da unidade | o de Portugal |

#### O que resta: o amigo de fora do Brasil (a verificação, 11/10/2026) -- no lote

**O título dizia CORRIGIDO e o corpo falava só do Brasil.** A etapa 3 é o endereço pelo CEP, e a página só a mostra a
quem é do Brasil: o `fidelidade_cadastro.js` (o `.min.js` que a página carrega diz o mesmo) vai da etapa 2 à 3 só com
"Brasil" no campo `pais`, e o `FidelidadeController::salvarDadosAction` devolve a próxima etapa 4 a quem não é. Com o
país da unidade, o amigo de quem indica por uma unidade de fora do Brasil leva o país dela no campo (o de Portugal,
"Portugal"), vai da etapa 2 à 4, e nem a 4 nem a 5 o ativam: ele **conclui a adesão inativo**, com o número de quem
indicou na origem e a ligação sem a data da adesão -- o defeito do título, fora do Brasil. E chega inativo à etapa 6
dele, onde indica sem a ligação (a resposta 67; o D466). Hoje não alcança ninguém: na cópia, a única unidade de fora do
Brasil (Portugal) está inativa. O que fazer com o amigo de fora -- deixar, ou fazer a etapa 4 ativá-lo como a 3 faz no
Brasil, ou mostrar a etapa 3 a todo país -- é do dono: a 66 disse "passa pela etapa 3", e a etapa 3 é do Brasil. Foi ao
lote, com a recomendação de a etapa 4 fazer pelo amigo de fora o que a 3 faz no Brasil, menos o endereço. Por isso a
marca é PARCIAL: no Brasil, corrigido; fora dele, no lote. Nada mudou no código.

**Provado nos dois bancos, em série** (`D304EtapaSeisDaAdesaoTest`, 37 casos e 242 asserções): o amigo de uma unidade
de Portugal leva `value="Portugal"` no campo `pais`, conclui a etapa 4 e a 5, e termina aderido e inativo, com a origem
"Indicação - <id>" e a ligação sem `data_adesao`; e o amigo que ele indica na etapa 6 dele nasce sem a ligação
(`testOAmigoDeUnidadeForaDoBrasilPulaAEtapa3EConcluiAAdesaoInativo`). Também caracterizado, para a pergunta do D466:
o amigo novo do Brasil conclui a etapa 3, a 4 e a 5, chega ativo à etapa 6 dele e indica com a ligação; o amigo de antes
sem país leva o campo `pais` vazio e indica sem ela. A etapa 2 continua sem ser despachada (o DNS, acima).

| mutante (sozinho, restaurado e conferido pelo `sha1`; no PostgreSQL) | caso vermelho |
|---|---|
| a etapa 4 deixa o cliente ativo | só o do amigo de fora do Brasil |
| a etapa 3 sem deixar o cliente ativo | a etapa 3 do amigo; o amigo novo do Brasil que chega ativo à etapa 6 |
| a ligação sem olhar se quem indica é ativo | o amigo de antes sem país; o de fora do Brasil; os cinco de quem indica inativo (o D446 e o D304) |

**Decidido pelo dono em 12/10/2026 (a resposta 146, opção c, e não a b recomendada):** a etapa 3 da adesão aparece para
todo país, com o endereço digitado, sem a busca pelo CEP. Fora do Brasil, o amigo da etapa 6 passa pela etapa 3 e fica
ativo ao concluí-la, como no Brasil.

#### O conserto (12/10/2026, o item PDV-E-CADASTRO)

- **A etapa seguinte** é a de `FidelidadeService::etapaDepoisDa`: da 2, a 3 para todo país, e a 4 só para quem não tem
  país gravado -- como antes: são os amigos de antes da resposta 66, que ela deixou como estavam (a pergunta nova está no
  lote). O `salvarDadosAction` a devolve **pelo país gravado**, e não pelo do campo oculto: era ele que decidia, e quem
  mandasse o pedido escolhia pular o endereço.
- **A página** (`fidelidade_cadastro.js` e o `.min.js`, que a página carrega, agora com o `?cache=162`): depois da etapa
  2 mostra a etapa que o servidor diz, e não decide mais pelo campo `pais`; e a cidade só ganha as sugestões (as cidades
  do Brasil, pelo estado) no Brasil.
- **A etapa 3 de fora do Brasil** (`cadastroAction` e `cadastro.phtml`): sem a busca pelo CEP e sem o estado (a lista é a
  dos estados do Brasil, e o `select`, sem a opção vazia, escolhia "Acre" sozinho); o código postal, com o rótulo
  "Código postal", sem a máscara do CEP e até 20 caracteres, o tamanho da coluna. A validação
  (`validarFormularioCadastro`, também pelo país gravado) exige a rua, o número, o bairro e a cidade, como no Brasil, e
  não o CEP nem o estado; o `salvarDados` lê os dois com `?? null`, porque a página de fora não manda o estado.
- A etapa 3 continua sendo a que ativa, troca a origem por "Indicação" e marca a data da adesão na ligação: nada mudou
  nela.

O que a etapa 3 exige fora do Brasil não estava na resposta: fiz assim, e a confirmação foi ao lote (PDV-E-CADASTRO, o
D460), com as outras saídas.

**Provado** nos dois bancos, em série, em `D304EtapaSeisDaAdesaoTest` (30 métodos; o caso de Portugal reescrito e dois
novos): o amigo de quem indica por uma unidade de Portugal tem a página sem a busca pelo CEP e sem o estado, com o código
postal sem a máscara e no tamanho da coluna; conclui a etapa 3 sem o estado (a próxima é a 4), a 4 e a 5, e termina
**ativo**, com a origem "Indicação", a data da adesão na ligação e o endereço gravado (o estado nulo); e o amigo que ele
indica nasce com a ligação. A validação de fora do Brasil pede a rua, o número, o bairro e a cidade, e recusa o código
postal maior que a coluna, sem mudar nada; e a do Brasil continua pedindo o CEP e o estado -- as duas pelo país gravado,
com o campo oculto dizendo o contrário. E a etapa seguinte: a 3 para o Brasil, Portugal e os Estados Unidos, a 4 para
quem não tem país. **O JavaScript**, rodado em Node com um jQuery de mentira (`medir-fidelidade-js.cjs`, no rascunho do
item): com o servidor dizendo 3, o `.min.js` e o `.js` de agora mostram a etapa 3 para Portugal, e o do HEAD mostrava a
4; com 4 (o sem país), a 4; e as sugestões da cidade só no Brasil (no HEAD, em todo país).

**Falsificado** (`medir-mutantes-pdv-cadastro.py`, no rascunho do item: um mutante por vez, o arquivo devolvido e
conferido pelo sha256 depois de cada um, contra os 181 casos do filtro do item; o mesmo vermelho no MySQL e no PostgreSQL):

| mudança | vermelho |
|---|---|
| o sem país também vai à etapa 3 | 1: a etapa seguinte |
| a regra de antes (só o Brasil vai à 3) | 1: a etapa seguinte |
| o controller sem a regra da etapa seguinte (`etapa + 1`) | **nenhum**: só a etapa 2 a distingue, e ela não se despacha aqui (o DNS) |
| a etapa 3 exigindo o CEP e o estado de todos | 2: o amigo de Portugal e a validação de fora do Brasil |
| a validação pelo país do campo oculto | 1: a validação, com o campo oculto dizendo o contrário |
| a página com a busca pelo CEP para todos | 1: o amigo de Portugal (a página dele) |
| o código postal com a máscara do CEP | 1: o amigo de Portugal (o campo dele) |
| o estado lido sem o `?? null` | 1: o amigo de Portugal -- o aviso "Undefined array key", que na rota pública é saída, vira erro no caso; antes de o caso o converter, o mutante só deixava um aviso na contagem |

**Não provado:** a etapa 2 despachada (a validação do e-mail consulta o DNS, e nenhum teste sai da máquina): a etapa
seguinte a ela é a regra do serviço, e as etapas 3 a 6 despachadas a leem pelo controller. E a página não foi aberta num
navegador.

#### A verificação (12/10/2026): PARCIAL, e a etapa 2 despachada

**A marca era CORRIGIDO, e volta a PARCIAL.** O corpo manda duas perguntas ao lote, e as duas são deste débito: o
cliente sem país, que continua indo da etapa 2 à 4 e, inativo, conclui a adesão inativo -- o defeito do título, para os
amigos de antes da resposta 66 (117 com a origem "Indicação" entre os sem país da cópia) --, e o que a etapa 3 exige
fora do Brasil, que o conserto decidiu e mandou confirmar. Enquanto as duas estão abertas, a regra da casa é PARCIAL,
como na verificação de 11/10/2026 e nos irmãos (o D453 e o D380 até a resposta).

**A etapa 2 se despacha.** A linha do controller que dá a etapa seguinte (`etapaDepoisDa`, pelo país gravado) não tinha
caso: trocada por `etapa + 1`, nada ficava vermelho, porque só a etapa 2 a distingue e a validação do e-mail consulta o
DNS. Agora o `tests/Caracterizacao/DnsDoEmailDeTeste.php` faz "existir" um domínio só, que não existe de verdade
(`ISBase\Util\checkdnsrr`, que o `IdealizeUtil::validarEmail` chama sem a barra e por isso acha antes da função do PHP;
todo outro domínio vai à função do PHP, como antes), e nenhum teste sai da máquina. O caso novo
(`testAEtapa2DespachadaDaAEtapaSeguintePeloPaisGravado`) despacha a etapa 2: o cliente de Portugal vai à 3 com o campo
oculto vazio, o sem país à 4 com "Brasil" no campo, o do Brasil à 3, e o e-mail fica gravado.

**Falsificado** (`medir-mutantes-pdv-cadastro-verif.py`, no rascunho do item, com o mesmo método; o mesmo vermelho no
MySQL e no PostgreSQL):

| mudança | vermelho |
|---|---|
| o controller com `etapa + 1` (o mutante que ficava verde) | 1: a etapa 2 despachada (o sem país vai à 3) |
| o controller com a regra de antes (o campo oculto; só o "Brasil" vai à 3) | 1: a etapa 2 despachada (Portugal, com o campo vazio, vai à 4) |
| a etapa seguinte pelo país do campo oculto | 1: a etapa 2 despachada (o campo oculto diz o contrário do gravado) |

**O que fica -- o PARCIAL do título:** o sem país e a etapa 3 de fora do Brasil, no lote (as duas perguntas
PDV-E-CADASTRO do D460). E a página não foi aberta num navegador.

#### 13/10/2026 — AS DUAS PERGUNTAS RESPONDIDAS: CORRIGIDO — pelas respostas 163 e 164 do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver resposta"*

**A resposta 163** (a opção "a", a recomendada): *"O cliente sem país continua pulando a etapa 3, como a 66 deixou para os amigos de
antes."* **A resposta 164** (a opção "a", a recomendada): *"Fora do Brasil a etapa 3 exige rua, número, bairro e cidade; o
código postal é opcional, sem máscara; sem estado; a cidade não sugere as do Brasil -- como a R27 fez."*

As duas confirmam o que roda desde 12/10/2026, e **nada mudou no código** (nem nos arquivos da adesão desde o commit
`9e8dd76`, conferido no `git diff`): o cliente sem país vai da etapa 2 à 4 (`FidelidadeService::etapaDepoisDa`) e, inativo,
conclui a adesão inativo, como a resposta 66 deixou para os amigos de antes; e a etapa 3 de fora do Brasil é a do conserto
de 12/10/2026 (acima): sem a busca pelo CEP e sem o estado, o código postal sem a máscara e opcional, até 20 caracteres, a
rua, o número, o bairro e a cidade obrigatórios, e a cidade sem as sugestões do Brasil.

**Provado de novo nesta data**, nos dois bancos, em série, no filtro do item (793 casos): o `D304EtapaSeisDaAdesaoTest`
inteiro, que prende as duas respostas -- a etapa seguinte (a 4 para quem não tem país, a 3 para o Brasil, Portugal e os
Estados Unidos), a etapa 2 despachada pelo país gravado, a validação de fora do Brasil (a rua, o número, o bairro e a
cidade; o código postal no tamanho da coluna) e a página do amigo de Portugal. Os mutantes das duas rodadas de 12/10/2026
(acima) foram medidos contra este mesmo código.

Com as duas respondidas, o PARCIAL do título não tem mais o que esperar: **CORRIGIDO**. Na cópia, os 2.355 clientes sem
país e sem a adesão continuam como estão (a 163), e a única unidade de fora do Brasil (Portugal) segue inativa.

**Não provado:** a página não foi aberta num navegador -- o JavaScript rodou em Node, com um jQuery de mentira, em
12/10/2026 (`medir-fidelidade-js.cjs`).

### D461 — A troca do push pelo Firebase: o envio está pronto, e faltam os aparelhos, as chaves e o aplicativo — PARCIAL em 10/10/2026 — PARCIAL, com os aparelhos, em 10/10/2026 — PARCIAL, com a cópia e o teste, em 11/10/2026 — DECIDIDO em 25/10/2026 (254 c): a campanha de push acaba; PARCIAL: o resto do push

**Decidido pelo dono em 10/10/2026** (o texto está em `docs/notificacoes-push-comparativo.md`, "A decisão do
dono"): a opção A -- o servidor fala direto com o FCM --, pelo custo mensal do OneSignal ao escalar, com o caminho
aberto para o Novu auto-hospedado (a C). O código entra já no servidor de teste; a produção segue no OneSignal até o
aplicativo novo ser publicado. A migração tem duas partes; a primeira está feita.

**A primeira parte (R21a), feita em 10/10/2026:**

- **A camada de envio**, em `module/ISBase/src/Push/`: a interface `EnviadorDePush`, o `OneSignalEnviador` (o mesmo
  pedido de hoje, byte a byte, com tempo limite, SSL e a resposta lida; e a API nova quando a chave nova vier, o
  D3), o `FcmEnviador` (a HTTP v1, uma mensagem por token, o título e o texto na `notification` e de novo no `data`
  com o link; o token OAuth2 da conta de serviço por um JWT RS256 assinado com `openssl_sign`, sem biblioteca nova
  por causa do D11; o cache do token em `data/cache/`, até cinco minutos antes de expirar; a retentativa no 429 e no
  5xx; o 401 que troca o token; o UNREGISTERED e o INVALID_ARGUMENT do token devolvidos como mortos) e o
  `DespachanteDePush`, montado pela `FabricaDoPush`.
- **A convivência pela configuração**, `push.provedor` no `local.php`: `onesignal` (o padrão), `ambos` (o FCM a quem
  tiver aparelho, o OneSignal aos demais) ou `fcm`. Sem a conta de serviço -- o caminho vazio, o arquivo ausente,
  dentro do repositório ou ilegível --, vai tudo pelo OneSignal. O `local.php.dist`, o `docker/config/local.php.docker`
  e o `scripts/verificar-local-php.php` conhecem as chaves novas.
- **Os dois chamadores** (`marketingPush` e o envio das campanhas de `marketingEnvios`, agora em `envioDeCampanha`)
  passam pelo despachante; os nove produtores da fila `push_app` não mudaram. O web push morto saiu.
- **O D456** (o push só para clientes), **o D67** (as duas chamadas do OneSignal), **o D68** (a falha do push no
  registro) e **o D3** (a chave nova), cada um com o seu registro.
- **Os ambientes sem saída**: `api.onesignal.com`, `fcm.googleapis.com` e `oauth2.googleapis.com`, no IPv4 e no IPv6,
  nos três `docker-compose` (desenvolvimento, homologação, teste) e no `HOSTS_MORTOS` do `scripts/deploy-teste.sh`
  (só a lista; a carga do roteiro não mudou). O contêiner desta máquina foi recriado e o `--conteiner` do portão
  está verde. **No BVIP, a lista só vale depois de recriar o app** de cada pilha (`docker compose up -d app` e
  `docker compose restart nginx`; o `deploy-teste.sh conferir` mede a do teste).
- **A medição** (o volume, o custo e o ritmo do envio contra um FCM de mentira) está na comparação.

**A verificação do mesmo dia (10/10/2026)** achou oito pontos, e os oito foram consertados:

- **o 401 da chave da APNs** (o THIRD_PARTY_AUTH_ERROR, que todo aparelho iOS recebe enquanto a `.p8` não estiver no
  Firebase) era tratado como o token de acesso recusado: apagava o cache que as outras rotinas usam e pedia um JWT ao
  Google por aparelho, e uma troca que falhasse derrubava os Android da rodada por um minuto. Agora só o 401 sem
  código do FCM troca o token; o da APNs é falha da mensagem, sem repetir;
- **as esperas do 429 e do 5xx** eram 1, 2 e 4 s, contra a página de escala do FCM: agora o `Retry-After`, senão 60 s
  no 429 e 10, 20 e 40 s no 5xx, nunca menos de 10 s, com um sorteio de até 20%; e um **disjuntor**: a espera que não
  cabe (mais de 60 s, ou mais de 300 s somados na rodada, ou a quarta tentativa) não se faz, e os destinatários
  seguintes saem como falha sem pedido até passar o tempo que o FCM pediu (no mínimo um minuto). Antes, numa queda,
  cada destinatário repetia as esperas: 12,8 h para os 6.573;
- **o `fcm` sem o cadastro dos aparelhos** não mandava a ninguém e não escrevia nada: agora cede ao OneSignal, com uma
  linha no registro, e o `verificar-local-php.php` avisa;
- **o caminho da configuração do OneSignal até o pedido** (o `Config` da rotina, a fábrica, o enviador) não tinha caso:
  agora tem, na fábrica e na rotina, nos dois bancos;
- **os tempos da produção** (30 s e 5 s) e **o nome do certificado** não tinham guarda (o D67);
- **o padrão da fábrica** (`onesignal` sem o `provedor`) só estava preso na constante;
- **a medição chamava de teto a régua dos 30 dias**: as rotinas mandam a quem já abriu o aplicativo alguma vez, de 7 a
  8 vezes mais gente; refeita, com as duas réguas (a comparação, "A medição de 10/10/2026");
- **o D456 dizia que os mutantes rodaram nos dois bancos**, e rodaram só no MySQL: repetidos no PostgreSQL.

**Provado:** `EnvioDePushTest` (29 casos, sem banco: o pedido legado byte a byte contra a classe velha, a API nova,
o JWT conferido com a chave pública de um par gerado no caso, a mensagem do FCM, o cache, o 429 e o 5xx com as
esperas da página do Google, o disjuntor e o teto das esperas da rodada, o token morto, o 401 do token e o da APNs,
o despachante nos três modos e o `fcm` sem cadastro, a fábrica com a configuração do OneSignal até o pedido, os
tempos da produção, o verificador, e o tempo limite, a cadeia e o nome do certificado contra servidores de mentira
no 127.0.0.1) e `PushNasRotinasTest` (8 casos, nos dois bancos: a fila e a campanha pelo despachante, a configuração
da rotina até o pedido, e as quatro portas do D456). Os mutantes, um por regra, e os portões estão no roadmap de
10/10/2026. **Não provado:**
nenhum caso fala com o FCM, com o Google nem com o OneSignal de verdade -- que o Google aceite o JWT, que o FCM
entregue e que a API nova do OneSignal aceite o corpo só se prova com as credenciais, fora dos testes; e a latência
real até o Google não foi medida.

**A segunda parte (R21b), feita em 10/10/2026 (PUSH-FCM-APARELHOS):**

- **A tabela dos aparelhos**, `clientes_aparelhos`: o cliente (chave estrangeira em cascata: o cliente que sai leva
  os aparelhos), o identificador do Firebase -- o token de registro do FCM ou o FID, para onde o FCM está migrando
  (as páginas do Firebase de 07 e 08/10/2026 dão o token por obsoleto, os dois aceitos) --, o `tipo` que diz qual, a
  plataforma, a versão do aplicativo, as datas e o telefone da sessão que o registrou (abaixo, a rota). **O
  identificador é ÚNICO** (`ascii_bin` no MySQL: o token diferencia
  maiúscula), e o mesmo aparelho que chega de outro cliente **muda de dono** numa instrução só (`INSERT ... ON DUPLICATE
  KEY UPDATE`; `ON CONFLICT` no PostgreSQL). No `mysql.sql`, na migração nova do passo 0 do corte
  (`scripts/migracao-clientes-aparelhos.sql`, idempotente, no laço; ensaiada num banco montado do dump de produção,
  o laço inteiro duas vezes, e a estrutura saiu igual à do `mysql.sql` de hoje), no `postgres/schema.sql` regerado
  (com o inquilino na frente da chave e o RLS, como as vizinhas), no `scripts/migrar-dados.sh` regerado, na entidade
  `ISCadastro\Entity\ClienteAparelho` e no `ClienteAparelhoRepository`. Os portões de esquema a conhecem: a unicidade
  declarada no `conferir-unicidade-do-schema.php`; a limpeza da unicidade a diz ausente da origem e única no próprio
  MySQL; e o de tipos do mapeamento e o `verificar-schema-postgres.php` a dizem "ausente de propósito" na cópia local
  enquanto a migração tiver a frase "NAO rode na copia local" (`scripts/tabelas-fora-da-copia.php`).
- **A cópia local NÃO a recebeu**: criar tabela na `franquiabv_dev` pede a autorização do dono, e a pergunta está no
  lote (criar já nos dois bancos da cópia, como o índice da pergunta 62). Sem ela, medido nos dois bancos da cópia: com
  `push.provedor` em `onesignal` (o padrão) o envio não faz consulta nenhuma à tabela; em `ambos` ou `fcm`, uma consulta
  que falha, uma linha no registro das rotinas ("o cadastro dos aparelhos nao respondeu (a tabela clientes_aparelhos
  nao existe neste banco...)") e tudo pelo OneSignal. E o ensaio geral do corte sobre a cópia não passa da conferência
  (**D462**).
- **O cadastro pelo banco**, `ISCadastro\Service\AparelhosNoBanco`, no lugar do `SemAparelhos` no `ambos` e no `fcm`:
  todos os aparelhos do cliente, o mais recente primeiro; **em lote** -- a campanha e a fila pedem os de todos os
  destinatários antes do primeiro envio (`DespachanteDePush::prepararLote`), uma consulta a cada mil clientes, e o
  envio responde da memória (sem N+1 nos 6.573 da maior campanha) -- **uma memória curta**: cada lista (a campanha, a
  fila) é uma foto nova, e o que foi lido há mais de 30 segundos é relido, com os mil seguintes da lista numa
  consulta, para o aparelho retirado ou passado a outro cliente no meio da campanha sair dela; e **os mortos** que o FCM devolve (UNREGISTERED, ou
  INVALID_ARGUMENT do próprio identificador, `message.token` ou `message.fid`) saem do banco e da memória da rodada. O
  banco que não responde vira `CadastroIndisponivel`, sem o SQL e sem os parâmetros na mensagem, e o despachante manda
  tudo pelo OneSignal, com uma linha só, sem perguntar de novo na rodada. O FCM recebe o tipo: o token em
  `message.token`, o FID em `message.fid`.
- **A fábrica** (`FabricaDoPush`) só monta o cadastro do banco no `ambos` e no `fcm` com o FCM ligado; no `onesignal`,
  e em todo modo que cede a ele, o despachante leva o `SemAparelhos` e não toca a tabela. A regra do despachante que
  faz o `fcm` ceder ao OneSignal sob o `SemAparelhos` **ficou**, para quem montar o despachante sem o cadastro; pela
  fábrica, com o cadastro, o `fcm` vale. O aviso do `verificar-local-php.php` mudou: o `ambos` e o `fcm` lembram a
  migração da tabela, e o `fcm` lembra que quem tem o aplicativo velho deixa de receber.
- **A rota**, da API V4, com a sessão do cliente (o Bearer do login por código no WhatsApp, o D59):
  `POST /api/v4/clientes/:id/aparelhos` registra ou renova (o identificador, o tipo, a plataforma, a versão; 200), e
  `DELETE` no mesmo endereço retira, **só o do cliente da rota** (o aparelho que já mudou de dono não sai pelo pedido do
  antigo). O identificador vai no corpo, em JSON, nunca no caminho nem na busca (o registro de acesso). Sem sessão,
  401; o `:id` de outro telefone, 403; o corpo que não serve, 422 com o campo; sem a tabela, ou com o banco que
  recusou (o deadlock, a conexão que caiu), 503 em JSON -- e o registro do servidor leva só a classe e o SQLSTATE, sem
  o SQL e sem os parâmetros (o identificador do aparelho e o id do cliente). **O aparelho
  vai para o cliente escolhido** -- o `:id` da rota, conferido pela `barreira()` como em todas as sub-rotas da V4 --, e
  não para todos do telefone: um aparelho é de um cliente por vez, e a família que troca de cliente no aplicativo o
  move. **E só enquanto o telefone do cliente for o da sessão que o registrou:** a linha guarda o telefone da sessão,
  e o envio só lê o aparelho cujo telefone ainda é o `telefone_sem_mascara` do cliente -- o telefone digitado errado,
  ou reciclado pela operadora, que a unidade corrige deixa de entregar o push do cliente ao aparelho de quem tinha o
  número (cujo `DELETE` já é 403). Um teto **provisório** de 10 aparelhos por cliente (o décimo primeiro tira o mais antigo sem renovar); a
  pergunta está no lote. Os portões da autorização privilegiada e das rotas sem login conhecem as duas ações.
- **O guia para quem mantém o aplicativo**, `docs/push-guia-do-aplicativo.md`: o SDK do Firebase, a permissão, o FID
  (ou o token) mandado à rota depois do login e a cada renovação, a retirada no logout, o link do `data` no toque, a
  exibição com o aplicativo aberto, os arquivos do projeto, a `.p8` da APNs, o Xcode 26 e o target SDK 36, a lista do
  teste em aparelho, as chamadas da rota com exemplos, e a convivência (`onesignal` -> `ambos` -> `fcm`), com as fontes.
- **A porta do Novu** (a opção C), sem código, em `docs/notificacoes-push-comparativo.md`: um `NovuEnviador` atrás de
  `EnviadorDePush`, o cliente como assinante, as credenciais do aparelho pela API do Novu a partir da nossa tabela, o
  que muda e o que fica.
- **O servidor de teste (o BVIP)**: o `deploy-teste.sh` não roda migração, e a base de lá (a cópia de 02/10) não tem a
  tabela. O passo manual -- a migração rodada pelo dono depois do `ACEITAR_ESQUEMA=sim ... atualizar`, com o comando
  exato -- e o `push.provedor` do teste (`onesignal`) estão em `docs/ambiente-teste.md`, "A tabela dos aparelhos do
  push". A carga do `deploy-teste.sh` e os cinco arquivos do selo não mudaram.
- **A próxima cópia exportada precisa tratar `clientes_aparelhos`** (os identificadores ligam um aparelho a um
  cliente, e a linha guarda o telefone): como a irmã de token, `clientes_sessoes`, com o `DELETE` no
  `endurecer-base-teste.sql` e a contagem zero no `conferir-base-teste.sql`, que faz o `conferir` falhar fechado. O
  gatilho é **a primeira cópia tirada da produção depois do passo 0** (ela traz a tabela com o que o aplicativo tiver
  gravado), ou a cópia local que a receber antes. As duas mudanças mudam o selo, e por isso ficam para depois da cópia
  que está subindo (D327; o texto de antes dizia "o anonimizador", e a verificação de 10/10/2026 o corrigiu).
- **No `ambos`, a escolha é por cliente, e não por aparelho**: o cliente com algum aparelho registrado recebe só pelo
  FCM, e o OneSignal não é chamado para ele -- o tablet ainda no aplicativo velho de quem já registrou o celular no
  novo deixa de receber na convivência. O guia o diz (e a medida da virada para o `fcm` conta por cliente); mudar a
  regra é do dono, e a pergunta está no lote.
- **Achados da verificação (10/10/2026), consertados no mesmo dia:** o telefone da sessão na linha (acima, a rota);
  a memória curta do cadastro (acima); o 503 com só o SQLSTATE no registro quando o banco recusa (acima); e, no guia,
  o logout que apaga o identificador no próprio aparelho (o `DELETE` da sessão vencida, ou sem rede, não chega), o
  reenvio a cada abertura do aplicativo (a `data_atualizacao` que o teto usa), e a regra por cliente do `ambos`; no
  `docs/ambiente-teste.md`, a migração de novo depois de todo `base` do BVIP, que recria o banco sem a tabela.

**Provado na segunda parte:** `AparelhosDoClienteTest` (21 casos, nos dois bancos: a chave única que recusa a segunda
linha, a maiúscula, a troca de dono -- com o telefone --, o token e o FID juntos, o teto, a cascata, o lote de 2.500
clientes em três consultas e nenhuma depois, os mortos fora do banco e da memória, a memória que vence e é relida em
lote, a lista nova que não usa a anterior, o aparelho que sai do cliente quando o telefone dele muda, a rota com 401,
403, 200, a troca de dono pela rota, o DELETE só do próprio cliente, o 422, o 503 do banco que recusa sem o
identificador no registro e o 503 da tabela ausente, que continua o dela, e a rotina montada da configuração nos três modos -- no `onesignal`, nenhuma
consulta à tabela; no `ambos`, o cliente com aparelho pelo FCM e o sem aparelho pelo OneSignal, a fila num lote só; no
`fcm`, só o FCM); `EnvioDePushTest` (35 casos, 6 novos: o FID no campo dele, o tipo até o FCM, o cadastro que não
responde, o lote só no modo que consulta, a retirada que falha, a fábrica nos modos); `PushNasRotinasTest` (9 casos, 1
novo: a campanha em `ambos` num lote só); `ApiV4Test` (4 casos novos, contra o banco de contrato, pela porta HTTP: as
recusas sem token, o verbo, e o registro e a retirada com token, com os snapshots). Sem a tabela, por ensaio nos dois
bancos da cópia (só leitura) e em dois bancos descartáveis: o despachante e a fábrica nos três modos, e a rota com
503. Os mutantes e os portões estão no roadmap de 10/10/2026 ("PUSH-FCM-APARELHOS"). **Não provado:** nenhum caso
fala com o FCM de verdade -- que o FCM aceite o `message.fid` (a referência REST o lista, lida por busca) e que um
aparelho receba só se prova com as credenciais e um aparelho; e a rota nunca foi chamada por um aplicativo.

**O que ainda falta, sem decisão do dono:**

- guardar para a rodada seguinte o push que o disjuntor do FCM não tentou (hoje ele se perde, como o que falha no
  OneSignal: a linha da `push_app` sai, e a campanha conta o destinatário), e levar a pausa do disjuntor de uma
  rodada à outra (hoje ela vale para a rodada; a seguinte, um minuto depois, tenta um destinatário de novo);
- um disjuntor para o provedor que aceita e cala (hoje, 30 s por destinatário; o D67) -- o do FCM cobre o 429 e o
  5xx, que são resposta, e não o tempo limite --, e o `curl_multi` se a latência real pedir (o ritmo medido está na
  comparação);
- tirar do cadastro o aparelho que não renova: hoje só sai o que o FCM recusa; a página do FCM chama de velho o
  registro sem atividade há um mês, e o Android expira o de 270 dias (manage-tokens, lida em 10/10/2026). A
  `data_atualizacao` já está na tabela para isso;
- o `deploy-teste.sh conferir` não exige vazias a `app_api_key` nem a `conta_de_servico` no teste, como exige as
  outras chaves: a mudança é fora da lista do `HOSTS_MORTOS`, e ficou para depois da cópia (o loopback corta a saída
  de todo jeito);
- o endurecimento e a conferência da cópia com a tabela nova, na primeira cópia depois do passo 0 (acima).

**O que espera o dono** (as perguntas estão no lote, PUSH-FCM-APARELHOS):

- **o login V4 no aplicativo novo**, que a rota exige: a recomendação é adotá-lo (mais seguro, e mais trabalho no
  aplicativo); a alternativa, uma rota com a confiança da marcação de hoje, reabre o que o D59 manda fechar. As 8
  unidades sem WhatsApp não mandam o código, e o token da sessão vale 30 dias, sem renovação;
- **a tabela na cópia local** (criar já, nos dois bancos, como o índice da pergunta 62) e **na base do teste do BVIP**
  (o passo manual de `docs/ambiente-teste.md`);
- **o teto de aparelhos por cliente** (10, provisório);
- **a convivência por cliente ou por aparelho** no `ambos` (acima): a recomendação é manter por cliente;
- **o teste em aparelho**: abrir no servidor de teste a saída para os dois nomes do Google, com um projeto Firebase só
  de teste, ou provar o envio só na produção em `ambos`;
- **as credenciais**: o projeto Firebase (o que o OneSignal já usa, ou um novo) e a conta de serviço dele, guardada
  fora do webroot no servidor; a chave `.p8` da APNs, da conta Apple Developer, carregada no Firebase; a App API Key
  nova do OneSignal para a convivência, e a revogação das antigas (o D3 e o D457);
- **o aplicativo**: quem o mantém, e a versão nova nas lojas com o SDK do Firebase (o guia);
- o liga-desliga do push (Configurações > App > Push), que não desliga nada: a pergunta está no lote.

**Como ligar, quando houver tudo:** a tabela no banco (`scripts/migracao-clientes-aparelhos.sql`: na produção, pelo
passo 0 do corte ou antes dele; no teste, pelo passo manual); o JSON da conta de serviço em, por exemplo,
`/etc/franquiabv/` (0640, dono root, grupo do php-fpm); no `local.php`, `push.provedor` em `ambos` e
`push.fcm.conta_de_servico` com o caminho; o
`php scripts/verificar-local-php.php` dizendo "o arquivo serve"; e a saída para `fcm.googleapis.com` e
`oauth2.googleapis.com` aberta no servidor que envia (a produção não tem o loopback; o teste e a homologação têm, de
propósito). Até lá, a produção não muda: o padrão é o OneSignal.

#### A terceira rodada, em 11/10/2026 (PUSH-TERCEIRA-RODADA: as respostas 71 a 76 do dono)

**As respostas, e o que cada uma fez:**

| | a resposta | o que mudou |
|---|---|---|
| 71 | (a) o aplicativo novo adota o login V4 | a renovação da sessão V4 no servidor (o **D59**); as unidades sem WhatsApp foram ao lote, com a contagem |
| 72 | (c) a tabela na cópia local, nos dois bancos; o BVIP pela mão do dono | a tabela criada na `franquiabv_dev`, abaixo; a frase saiu do cabeçalho e a migração entrou no laço do ensaio, juntas; o passo do BVIP conferido (`docs/ambiente-teste.md`); o selo não mudou (o **D327**) |
| 73 | (a) o teto de 10 aparelhos fica | só registro: deixou de ser provisório, aqui e no guia |
| 74 | (a) o teste falando com o Google, com um projeto Firebase só de teste, em `ambos`, o OneSignal cortado | preparado no repositório, abaixo; ligar é do dono |
| 75 | (a) a convivência por cliente fica | só registro, aqui e no guia |
| 76 | (c) o liga-desliga como está até o aplicativo novo | só registro, aqui, no guia e no **D67** |

**A tabela na cópia local (a resposta 72)**, a única escrita na `franquiabv_dev` deste item, com a suíte, o contrato e
os ensaios parados (conferido antes: nenhuma transação no `INNODB_TRX`, nenhuma consulta na lista de processos do
MySQL além do agendador, nenhuma no `pg_stat_activity`, nenhum PHP além do `php-fpm` no contêiner), e conferido antes
que ela não existia (o `information_schema.tables` do MySQL, 0 linhas; o `to_regclass('bvip.clientes_aparelhos')` do
PostgreSQL, nulo):

- **MySQL**, às 06:33:19: a própria migração, como o laço do ensaio a roda
  (`docker exec -i franquiabv-mysql-1 mysql -uroot -p… franquiabv_dev < scripts/migracao-clientes-aparelhos.sql`),
  saída 0, e a conferência dela com os três índices (`PRIMARY`, `idx_clientes_aparelhos_cliente`,
  `uq_clientes_aparelhos_identificador` com `nao_unico` 0). No `binlog.000048`, desde a posição anotada antes
  (16120056), **um evento só**: o `CREATE TABLE IF NOT EXISTS` (posição 16120135) — sem `ANALYZE` nem outro comando.
- **PostgreSQL** (esquema `bvip`), às 06:33:29, como `franquiabv`, o dono do esquema: a tabela que o gerador emite,
  tirada linha a linha do `postgres/schema.sql` (igual byte a byte à saída do `gerar-ddl-postgres.php` nesse dia),
  numa transação só e com a conferência de ausência dentro dela — o `CREATE TABLE` com o `inquilino_id` na frente, os
  dois índices com o inquilino (o único do identificador e o do cliente), a chave estrangeira para `clientes` em
  cascata, a do inquilino, o `ENABLE` e o `FORCE ROW LEVEL SECURITY` e a política `inquilino_isolado`. Saída 0. Os
  privilégios do `bvip_app` vieram dos privilégios padrão do esquema, sem `GRANT` — e, com eles, um `SELECT` ao
  `bvip_bi`, que o banco da suíte não dá (abaixo, a verificação). **O comando e as instruções** (a verificação do
  mesmo dia achou o registro sem eles), para quem refizer noutra cópia: o roteiro foi tirado do `postgres/schema.sql`
  pelas linhas que citam a tabela, na ordem do arquivo (refeito depois, saiu igual byte a byte), e rodado pelo `psql`
  do contêiner como `franquiabv`, com o roteiro na entrada:
  `docker exec -i franquiabv-postgres-1 psql -U franquiabv -d franquiabv_dev < <o roteiro>`. O roteiro, inteiro:

  ```sql
  \set ON_ERROR_STOP on
  BEGIN;
  SET LOCAL search_path = bvip, public;
  SELECT to_regclass('bvip.clientes_aparelhos') IS NULL AS ausente_antes \gset
  \if :ausente_antes
  \else
    \echo 'a tabela ja existe: nada feito'
    ROLLBACK;
    \quit
  \endif
  CREATE TABLE clientes_aparelhos (
      inquilino_id                     integer NOT NULL DEFAULT current_setting('app.inquilino_id')::integer,
      id                               integer GENERATED BY DEFAULT AS IDENTITY,
      cliente                          integer NOT NULL,
      telefone                         varchar(30) NOT NULL,
      identificador                    varchar(255) NOT NULL,
      tipo                             varchar(10) NOT NULL,
      plataforma                       varchar(10) NOT NULL,
      versao_app                       varchar(30) DEFAULT NULL,
      data_criacao                     timestamptz NOT NULL,
      data_atualizacao                 timestamptz NOT NULL,
      PRIMARY KEY (id)
  );
  CREATE UNIQUE INDEX clientes_aparelhos_uq_clientes_aparelhos_identificador ON clientes_aparelhos (inquilino_id, identificador);
  CREATE INDEX clientes_aparelhos_idx_clientes_aparelhos_cliente ON clientes_aparelhos (inquilino_id, cliente);
  ALTER TABLE clientes_aparelhos ADD CONSTRAINT FK_CLIENTES_APARELHOS_cliente FOREIGN KEY (cliente) REFERENCES clientes (id) ON DELETE CASCADE ON UPDATE RESTRICT;
  ALTER TABLE clientes_aparelhos ADD CONSTRAINT clientes_aparelhos_inquilino_fk
      FOREIGN KEY (inquilino_id) REFERENCES inquilinos (id);
  ALTER TABLE clientes_aparelhos ENABLE ROW LEVEL SECURITY;
  ALTER TABLE clientes_aparelhos FORCE  ROW LEVEL SECURITY;
  CREATE POLICY inquilino_isolado ON clientes_aparelhos
      USING      (inquilino_id = current_setting('app.inquilino_id')::integer)
      WITH CHECK (inquilino_id = current_setting('app.inquilino_id')::integer);
  COMMIT;
  ```
- **A conferência, só leitura:** `COUNT(*)` 0 nos dois. No MySQL, contra o banco da suíte (`franquiabv_cilocal`):
  as 9 colunas iguais (o tipo, a nulabilidade, o padrão, o conjunto, a comparação — o `ascii_bin` do identificador),
  os índices, a chave estrangeira e as opções (InnoDB, `utf8mb3_general_ci`) — 0 diferenças em cada. No PostgreSQL,
  contra o da suíte (`franquiabv_pgtestes`): o retrato, 24 linhas (as 10 colunas com o padrão e a
  identidade, os 3 índices, as 3 restrições, o RLS ligado e forçado, a política, os 4 privilégios do `bvip_app` e a
  sequência), igual linha a linha **no que ele mede** — os privilégios, só os do `bvip_app`. **A verificação do
  mesmo dia mediu a lista inteira** (`pg_class.relacl`, só leitura): na cópia,
  `franquiabv=arwdDxt/franquiabv,bvip_app=arwd/franquiabv,bvip_bi=r/franquiabv`; na da suíte, o mesmo **sem o
  `bvip_bi=r`**. Vem do privilégio padrão do esquema `bvip` na cópia (`pg_default_acl` do `franquiabv`:
  `bvip_app=arwd` e `bvip_bi=r`), que as 89 tabelas da cópia têm e nenhuma das 89 da suíte; dos sete bancos do
  PostgreSQL desta máquina, só a cópia dá algo ao papel. O `bvip_bi` saiu do projeto em 09/09/2026 com o Power BI
  (**D9**; os comentários de `scripts/criar-papeis.sh` e `scripts/conferir-papeis.sh`), e segue no servidor desta
  máquina, com login e sem `BYPASSRLS`. **Não é vazamento novo** — o papel já lia as outras 88 tabelas da cópia, a
  `clientes_sessoes` entre elas —, mas a tabela nova herdou um privilégio de um papel aposentado, e o registro o
  escondia. Tirá-lo da cópia é escrita nela, sem autorização: a pergunta foi ao lote (o resíduo está no **D9**).
- **No repositório, as duas mudanças juntas:** a frase "NAO rode na copia local" saiu do cabeçalho da migração (no
  lugar, o dia e a resposta), e a oitava entrou no laço da cópia em `docs/ensaio-geral-do-corte.md`. O
  `conferir-passo-zero-do-corte.php` sai 0 ("copia: 8 migracoes ... o cabecalho tira nenhuma"), e 1 com cada metade
  sozinha: a frase de volta ao cabeçalho, no arquivo de verdade, devolvido pelo sha256 ("o cabecalho dela diz 'NAO
  rode na copia local' -- falta a autorizacao do dono"); e o laço com sete, por uma cópia (`--ensaio`).
- **Os dois portões que conferem o mapeamento contra a cópia passaram a exigi-la** (a tolerância do
  `scripts/tabelas-fora-da-copia.php` some sem a frase, sem mudar código): contra a cópia, o
  `conferir-tipos-do-mapeamento.php` sai 0 com 754 campos mapeados (os da tabela entre eles) e o
  `verificar-schema-postgres.php` sai 0 com 0 tabelas ausentes. **Falsificados** contra dois bancos descartáveis meus
  (`fx_semaparelhos`, um MySQL e um PostgreSQL, montados do `mysql.sql` e do `schema.sql` e com a tabela apagada só
  neles, apagados no fim): sem a frase, o de tipos sai **1** ("CAMPO MAPEADO SEM COLUNA", os 8 campos da tabela) e o
  do PostgreSQL sai **1** ("Tabelas mapeadas e ausentes 1", `clientes_aparelhos`); com a frase de volta no cabeçalho,
  os dois saem 0 dizendo-a "ausente de proposito". Para o de tipos, que lê o banco do `local.php`, o `local.php` apontou
  para o descartável nas duas rodadas e voltou à `franquiabv_dev`, conferido pelo sha256 e pelo `dbname`.
- **E o resto dos portões de esquema, contra a cópia com a tabela:** `verificar-schema.php` 0; `conferir-schema-tool.sh`
  0 ("Igual a linha de base", 4 colunas que apagaria, as de sempre); `conferir-limpeza-da-unicidade.php` 0, agora com
  a linha `clientes_aparelhos (identificador) 0 limpa, e nao precisa de bloco`, medida na cópia (antes, "ausente na
  origem").

**O teste falando com o Google (a resposta 74), preparado:**

- **O `docker-compose.teste-firebase.yml`**, novo, que só entra quando o dono o acrescenta ao fim do `COMPOSE_FILE` do
  teste: a lista do loopback do app passa a ser exatamente `HOSTS_MORTOS` (`extra_hosts: !override`), e a pasta da
  conta de serviço do host (`/etc/franquiabv-teste`) entra só para leitura em `/etc/franquiabv` no app. **Por que um
  arquivo, e não a lista do `docker-compose.teste.yml`:** o teste carrega também o `docker-compose.homologacao.yml`, que
  corta o Google (D360), e o compose JUNTA as listas — tirar os dois nomes do arquivo do teste não abriria nada. Só o
  `!override` (docker compose 2.24.4 ou mais novo) desfaz a junção, e a versão do BVIP não é conhecida daqui: num
  arquivo à parte, a pilha de hoje (e a cópia que subia) não muda até o dono conferir a versão e ligá-lo. Medido com
  `docker compose config` (só renderiza) nesta máquina (v5.2.0): sem o arquivo, os 12 nomes no loopback; com ele, 0
  `googleapis`, os 10 nomes nos dois endereços e a pasta montada só para leitura.
- **O `HOSTS_MORTOS` do `deploy-teste.sh` sem os dois do Google** (só a lista e o comentário dela; a carga e o
  `VERSAO_DA_CARGA` não mudaram, e o selo é dos cinco arquivos, que não mudaram): com o arquivo ligado, o `conferir`
  seguiria exigindo o Google no loopback e nunca passaria. *(Assim, o `conferir` deixava de medir o Google também no
  teste de sempre, sem o arquivo; a verificação, abaixo, o devolveu pelo `HOSTS_DO_GOOGLE`.)*
- **O `conferir-provedores-no-loopback.php`** conhece os dois do Google como `$foraDoTeste`: o desenvolvimento, a
  homologação e o `docker-compose.teste.yml` os cortam (como antes), e o arquivo do Firebase tem de trazer o
  `!override`, exatamente `HOSTS_MORTOS` nos dois endereços, nenhum dos dois do Google e só a pasta da conta (do host,
  fora do `/var/www`, `:ro`). Saída 0 nos arquivos e no `--conteiner` (os 12 nomes no app desta máquina). **Os nove
  mutantes, cada um sozinho e devolvido pelo sha256, saem 1:** o arquivo sem o `!override`; sem o `onesignal.com`; com o
  `fcm.googleapis.com` no loopback; a pasta sem o `:ro`; a pasta dentro do repositório; uma porta no arquivo; a
  homologação sem o `oauth2.googleapis.com`; o `docker-compose.teste.yml` sem o `fcm.googleapis.com`; e o Google de
  volta em `HOSTS_MORTOS`.
- **O passo do dono** (o projeto de teste, a conta de serviço no BVIP, a versão do compose, o `COMPOSE_FILE`, o
  `ambos` no `local.php`, as conferências e como desfazer) está em `docs/ambiente-teste.md`, "O push pelo Firebase no
  teste". Nenhum segredo no repositório: o caminho é `/etc/franquiabv-teste/firebase-conta-de-servico.json` no host,
  lido como `/etc/franquiabv/firebase-conta-de-servico.json` no app. **Nada rodou no BVIP.**
- **Achado ao preparar, e ficou de fora (D467):** o aparelho de teste não tem como entrar no login V4 do servidor de
  teste — o código vai pelo WhatsApp, cortado lá, e o endurecimento apaga as chaves das unidades. A pergunta está no
  lote.

**A verificação do mesmo dia (11/10/2026)** achou oito pontos, seis distintos, todos de gravidade baixa; os seis foram
tratados, e um deixou uma pergunta ao lote:

- **O registro da criação no PostgreSQL** não tinha o comando nem as instruções, e chamava o retrato de igual linha a
  linha sem dizer que ele só media os privilégios do `bvip_app`: a cópia dá também `SELECT` ao `bvip_bi`, o papel do
  Power BI aposentado em 09/09. Os dois estão acima, na criação e na conferência; o resíduo do papel, no **D9**, e a
  pergunta (apagá-lo do PostgreSQL desta máquina, que é escrita na cópia; recomendo apagar) foi ao lote.
- **O `docs/ambiente-desenvolvimento.md`** dizia ter a lista inteira do que a cópia tem a mais que o dump de produção, e
  não tinha a `clientes_aparelhos`: acrescentada, com os índices; e as tabelas medidas de novo (as 88 da cópia contra as
  83 do dump: as cinco da lista, nenhuma faltando).
- **O `deploy-teste.sh conferir` deixou de medir o Google no teste de sempre**: com os dois nomes fora de
  `HOSTS_MORTOS`, o `conferir_hosts_mortos` não os perguntava mais ao contêiner, com ou sem o arquivo do Firebase. Agora
  eles moram em `HOSTS_DO_GOOGLE`, e a função os mede sempre que o `COMPOSE_FILE` do `.env` não traz o
  `docker-compose.teste-firebase.yml` (com ele, saem da medida, e os outros dez seguem). Fora da carga: a função do
  `conferir` e duas linhas de lista. **O caso que pega**, `scripts/ensaiar-hosts-mortos-do-deploy.sh` (novo, no
  `conferir-tudo.sh` e no CI): roda a função inteira, tirada do roteiro, sob o `set -euo pipefail` dele, com um dublê do
  contêiner, em 27 casos (o teste de sempre com tudo no loopback, com cada um dos 12 nomes aberto e com o Google mudo;
  com o arquivo, o Google aberto, com e sem aspas no `.env`, e cada um dos dez abertos). Verde; e vermelho com a função
  como estava (6 casos), com o Google medido também com o arquivo (2), com nada medido com o arquivo (10), com o `case`
  casando qualquer `teste` (6) e sem a resposta vazia reprovando (2) — cada mutante numa cópia do roteiro, que o ensaio
  recebe. E o `conferir-provedores-no-loopback.php` segura o `HOSTS_DO_GOOGLE` igual ao `$foraDoTeste` e o nome do
  arquivo: 1 com a lista sem o `oauth2.googleapis.com` e com o arquivo de outro nome, no roteiro de verdade, devolvido
  pelo sha256.
- **O portão de cobertura do contrato** dava a renovação por coberta só com o GET que prova o 404, e lia os comentários
  (vinha de antes do item). Agora a rota com verbo na árvore (o `Method::class`, 14 das 47) só conta como pedida por um
  pedido daquele verbo, com o caminho inteiro num literal — o verbo de cada ajudante lido do `ContratoTestCase` —, os
  testes são lidos sem os comentários (`token_get_all`), e o snapshot que nenhum `assertContrato` do código nomeia
  reprova (58 snapshots, 58 nomeados). Verde nos arquivos de hoje; e 1, onde o portão de antes dava 0, com os dois casos
  do POST da renovação apagados (a rota e o snapshot órfão), com o caminho dos países só num docblock, com o
  `testRenovarSemToken` apagado (o snapshot órfão) e com os `DELETE` dos aparelhos trocados por `POST` — cada um no
  arquivo de verdade, devolvido pelo sha256.
- **O passo do teste com o Google não dizia como um push sai do servidor de teste**, onde nenhum cron roda: o passo 9
  novo de `docs/ambiente-teste.md` diz a ordem (o login e o registro antes, porque a rotina da fila apaga cada linha que
  processa, também a do código de entrada do D467), a linha na `push_app`, o token das rotinas sorteado só para a
  medida e tirado depois (o do teste é vazio, e sem ele a rotina responde 401), a chamada ao `marketingPush` pela porta
  do app (e nunca o `marketingEnvios` nem o `marketingPushOutros`) e a conferência. E a linha "uma rotina pode ser
  rodada à mão" passou a dizer o token e o caminho das de marketing.
- **Comentários e um documento** ficaram falsos com a lista nova: o `docker-compose.homologacao.yml` e o
  `docker-compose.desenvolvimento.yml` (a lista é `HOSTS_MORTOS` e `HOSTS_DO_GOOGLE`; só o arquivo do Firebase abre o
  Google, e só no teste) e o "O que falta" de `docs/notificacoes-push-comparativo.md` (a terceira rodada, e o que a 71 e
  a 72 responderam). Só comentário nos dois arquivos de compose; o portão os lê sem os comentários e segue 0 (os
  arquivos e o `--conteiner`). O `docker-compose.homologacao.yml` está nas camadas do `deploy-teste.sh`: um `voltar` a
  antes deste commit pedirá a confirmação.

**O estado do que esperava o dono (a lista de cima, de 10/10):** o login V4 no aplicativo novo, a tabela na cópia
local, o teto, a convivência, o teste em aparelho (preparado; ligar é do dono) e o liga-desliga estão respondidos. Seguem
com o dono: as credenciais (o projeto Firebase da produção e a conta de serviço dele, a `.p8` da APNs, a App API Key
nova do OneSignal e a revogação das antigas, o D3 e o D457), a conta de serviço do projeto de teste, quem mantém o
aplicativo e a versão nova nas lojas, a tabela na base do teste do BVIP (o passo de `docs/ambiente-teste.md`), e as
quatro perguntas novas do lote (as unidades sem WhatsApp, o prazo máximo da sessão renovada, o login no teste, D467, e,
da verificação, o papel `bvip_bi` na cópia, D9). E segue
sem decisão do dono a lista de "O que ainda falta" de cima, e o endurecimento e a régua da cópia com a tabela, que são
o passo antes da próxima exportação (D327).

**Não provado:** que o docker compose do BVIP aceite o `!override` (o passo manda conferir a versão antes); que o Google
aceite o JWT da conta de serviço de teste e o FCM entregue a um aparelho; e que a cópia que subia no BVIP em 11/10 não
tenha a tabela (pela data da exportação, anterior à criação, ela não a tem).

**A campanha de push acaba (25/10/2026, a resposta 254 (c) do dono):** *"O push de campanha acaba nas duas
telas: as unidades que o usam passam ao WhatsApp, com o teto de 200 por campanha."* O que muda: nenhuma tela cria campanha
de push (a fatia 92a tira o canal do formulário antigo, e a tela nova nunca o ofereceu); as gravadas aparecem só para ler;
a agendada antes da troca sai pela rotina como hoje, a menos que alguém a cancele ou que ela passe do prazo da atrasada (a
262 (b), o D609: a de push que passou do prazo fica "atrasada", só com o cancelar, porque remarcá-la seria um push novo). **Nada do OneSignal nem do Firebase se
toca**, como o dono mandou: o envio das campanhas de push (`envioDeCampanha`, um dos dois chamadores do despachante) fica
no código para as agendadas e fica sem uso quando elas acabarem; o push da fila (`marketingPush`) e o
`marketingPushOutros` seguem com este débito. Medido na cópia (25/10/2026, só contagem): as 7 unidades que fizeram push de
campanha em 2026 têm o WhatsApp ligado hoje, e 45 das 49 campanhas passavam de 200 clientes.

### D462 — A carga do corte dá por carregada, com zero linhas, a tabela que a origem não tem — REGISTRADO em 10/10/2026 — MEDIDO em 11/10/2026: na cópia, a contagem responde — CORRIGIDO em 08/11/2026

Achado ao ler o `scripts/migrar-dados.sh` (gerado pelo `scripts/gerar-migracao-dados.php`) para a tabela nova dos
aparelhos (**D461**). Cada tabela sai do MySQL por `mysql_`, que joga fora o erro do cliente (`2>/dev/null`), e entra
no PostgreSQL por `\copy ... FROM STDIN`; a conferência aceita a linha `COPY <n>` como o sucesso. **Uma tabela que não
existe na origem vira entrada vazia, e o `COPY` responde `COPY 0`**: a carga a dá por carregada, sem aviso. Pela
leitura, sem rodar a carga.

No corte isso não morde enquanto o passo 0 estiver completo: o portão dele exige que toda tabela nova do `mysql.sql`
tenha a migração no laço, e a produção a terá. Morde no ensaio sobre uma cópia sem a migração -- a cópia local sem
`clientes_aparelhos`, enquanto o dono não autorizar --, e morde em qualquer tabela que o passo 0 esquecer: a carga
passa, e só o `conferir-migracao.php` do passo 5 tropeça (a contagem dele é um `UNION ALL` de todas as tabelas do
`mysql.sql`, que falha inteira no MySQL sem a tabela, e todas saem "DIFERE").

**O conserto:** o `mysql_` da carga sem o `2>/dev/null` (ou conferindo o código de saída do cliente MySQL antes do
`COPY`), para que a tabela ausente pare a carga com o nome dela. É mudança no gerador e no roteiro gerado, que o corte
roda: fica para quando o corte for revisto, com o ensaio.

**Medido de novo em 11/10/2026, com a tabela na cópia** (a resposta 72 do dono; o **D461**). O ensaio geral do corte
sobre a cópia **não rodou**: ele escreve no MySQL da cópia (a fase `limpeza` e a `passo3`) e roda a carga, e a
autorização deste item foi para o `CREATE` da tabela, só. Medido, por leitura, o pedaço que este débito dizia
quebrado: a contagem da prova 1 do `conferir-migracao.php`, montada como ele a monta (as 86 tabelas do `mysql.sql`,
num `UNION ALL` de `COUNT(*)`), **responde inteira nos dois bancos da cópia** -- 86 linhas no MySQL (9 s) e 86 no
PostgreSQL, com o inquilino declarado (3 s), `clientes_aparelhos` com 0 nos dois; antes, a tabela ausente fazia a
do MySQL falhar inteira. **O defeito deste débito continua de pé:** o `mysql_` da carga ainda descarta o erro do
cliente MySQL, e a próxima tabela que o passo 0 esquecer passa como `COPY 0`. O que saiu foi só o caso da cópia
local, que era o único conhecido. Que o ensaio inteiro passe da conferência sobre a cópia não está provado: ele
para nela pelas fixtures, de todo jeito (o `ensaio-geral-do-corte.md`, D378).

**CORRIGIDO em 08/11/2026 (a fatia 128, ROTEIROS-DO-CORTE-E-DA-CARGA).** No gerador
(`scripts/gerar-migracao-dados.php`), e regerado o `scripts/migrar-dados.sh`: o `mysql_` não joga mais fora o erro do
cliente -- a senha vai pelo `MYSQL_PWD` (o mesmo valor), e o aviso do `-p`, que era o motivo do `2>/dev/null`, não sai mais
--; o `sh` do contêiner guarda o código de saída do `mysql` num arquivo (o cano devolve o do `sed`) e sai com ele; o
`mysql_` o anota em `$FALHA_MYSQL` (um `mktemp`, porque a função roda num subshell do cano), e a `conferir()` o lê ANTES da
linha do `COPY`: a tabela ausente para a carga nela, com "A leitura de <tabela> no MySQL falhou" e o erro do cliente
("Table '...' doesn't exist") logo acima. Medido no MySQL 8.4.13 do contêiner daqui, com um `SELECT` só: o `MYSQL_PWD`
não escreve aviso, e a tabela ausente sai com 1. **O ensaio** `scripts/ensaiar-carga-que-para.sh` (no `conferir-tudo.sh` e
no CI) roda a cópia do roteiro gerado INTEIRO, com `sh`, num diretório temporário, com um `docker` de mentira no PATH
(o `sh -c` do `mysql_` roda o texto do próprio roteiro sobre um `mysql` de mentira; o `psql` responde `COPY <linhas>`):
a carga inteira verde (as 92 tabelas lidas e copiadas uma vez); a tabela ausente (a 5ª) para nela, com o nome e o erro,
e nenhuma depois dela é lida; a leitura que morre no meio (a 7ª) também; o `COPY` que falha (a 9ª) continua parando. O
projeto do compose e os bancos são nomes que não existem, e o dublê nunca chama o `docker` de verdade. Mutantes, nas
cópias, vermelhos: o roteiro do HEAD (a tabela ausente passava com `COPY 0`), o `|| echo $? > $FALHA_MYSQL` arrancado, o
`exit "${s:-1}"` trocado por `exit 0`, o bloco da `conferir()` arrancado e o `2>/dev/null` de volta com o código 0. **Não
rodou:** a carga de verdade (ela só roda na janela e nos clones do ensaio geral; nunca contra o `franquiabv_dev`).

### D463 — O "Quitar" depois do pagamento deixava o troco da conta de antes — REGISTRADO em 10/10/2026 — CORRIGIDO em 10/10/2026

**Achado na verificação do item PDV-TERCEIRA-RODADA (10/10/2026), ao escrever o caso da tabela do "Quitar"** (o D380, a
verificação da terceira rodada). O "Quitar" -- `VendasPagamentosController::pagarDebitosAction` no ERP e
`Adm\ComandasController::adicionarDebitosAction` na API -- sobe o total pelo devido e não mexe nos pagamentos. O item
mexe: lançar ou tirar um item apaga os pagamentos e zera o troco (`VendaProdutoService`, desde 09/09/2026). O troco
ficava o da conta de antes:

| | total | recebido | troco |
|---|---|---|---|
| a comanda de 30,00, paga com 100,00 | 30,00 | 100,00 | 70,00 |
| o "Quitar" soma 50,00 de débitos | 80,00 | 100,00 | **70,00** -- devia ser 20,00 |

A tabela de pagamentos mostrava os 70,00, o finalizar pedia o destino deles, e o `VendaPagamentoService::finalizar` os
dava ao cliente (crédito), ao colaborador (gorjeta) ou os tirava do caixa (o troco em dinheiro, sem destino) -- e
baixava os 50,00: **a dívida paga com o troco que era dela**. O defeito se curava sozinho quando entrava outro pagamento
(o lançamento recalcula o troco), e só sobrevivia quando o operador finalizava depois do "Quitar" sem lançar nada. As
duas portas tinham o caminho.

**Medido na cópia de desenvolvimento em 10/10/2026, só leitura:** 1 venda concluída com o rastro exato -- de 2019, total
e recebido de 106,00, troco de 36,00, e os mesmos 36,00 acima dos itens (a marca do "Quitar"). Outras 4 concluídas, de
2015 a 2018, têm o troco acima do recebido menos o total sem nada acima dos itens: são de outra causa. A venda cujo troco
teve destino zera a coluna e não deixa rastro, então o número é piso. Nenhuma venda aberta na cópia tem o troco acima da
conta.

**O conserto**, num lugar só: `VendaPagamentoService::pagarDebitos`, que as duas portas chamam depois de somar, recalcula
o troco antes de gravar, com a conta do lançamento do pagamento -- o que o recebido passa do total, ou zero. No ERP a
resposta do "Quitar" traz também a tabela de pagamentos e o `fiado` da conta nova (a verificação da terceira rodada do
D380), e a tabela passa a mostrar o troco certo; na API o recurso da comanda já devolvia `valorTroco`.

**Provado** em `D380QuitarComMarcaTest`, no MySQL e no PostgreSQL: pelo ERP, o troco de 50,00 depois do "Quitar" (pagos
200,00 por 100,00, somados 50,00), a tabela com "R$ 50,00" e o crédito do cliente de 50,00 depois do finalizar com o troco
para a conta dele -- eram 100,00 (`testOQuitarRecalculaOTrocoEOFinalizarDaSoOQueSobra`); pela API, `valorTroco` 50.00
(`testOPostDosDebitosDaApiRecalculaOTroco`); e o controle, com o recebido abaixo do total novo, que zera o troco e diz o
fiado (`testOQuitarComOTrocoMenorQueOsDebitosZeraOTroco`). Os mutantes: o serviço sem o recálculo, 3 vermelhos (os três);
zerando sempre, 2 (os dois de 50,00) -- os mesmos nos dois bancos.

### D464 — Dois finalizar no mesmo instante com a sessão do mesmo pacote passam os dois, e a baixa regrava o restante que o pedido leu — REGISTRADO em 10/10/2026 — CORRIGIDO em 11/10/2026

**Achado no conserto do D452 (o item PACOTE-TERCEIRA-RODADA), lido no código; não medido nem provado** -- a corrida
pede dois pedidos ao mesmo tempo, e a suíte não os faz.

O finalizar (`VendaPagamentoService::finalizar`) não tem transação nem trava -- cada `flush` grava sozinho, o D383 -- e
a baixa da sessão (`salvarPacotes`) grava o restante como número calculado na memória do pedido
(`setQuantidadeRestante(restante - quantidade)`, sobre a entidade que ele leu), a forma da baixa de estoque antes do
D388. Dois efeitos:

1. **A recusa do D452 lê o restante sem trava.** Duas vendas com a mesma última sessão, fechadas em dois caixas no
   mesmo instante, passam as duas pela conferência. Conforme a ordem das gravações, o pacote termina em -1 (o D452 de
   antes) ou em 0 com as duas sessões gastas (o efeito 2).
2. **Duas baixas no mesmo pacote podem gravar o mesmo número**, mesmo com saldo para as duas: o pacote de 3, com uma
   sessão em cada venda, fica em 2 -- a sessão que o cliente usou some da conta, e ele ganha uma.

**O conserto provável**, em duas partes: (a) a baixa como conta no banco (`quantidade_restante = quantidade_restante -
?`, o molde do D388), que fecha o efeito 2 e deixa o 1 visível como -1, em vez de escondido; (b) para o efeito 1, a
linha do pacote travada (`PESSIMISTIC_WRITE`) e o restante relido sob a trava, dentro de uma transação que cubra o
finalizar inteiro -- a mudança de forma que o D383 adiou ("pôr a trava ali muda a forma da gravação inteira do PDV"),
e que, com a baixa de estoque dentro dela, seguraria a linha do produto que o D388 escolheu não segurar. Nenhuma das
duas pede esquema. Não feito no item porque mexe no finalizar das 32 lojas por um caminho que a resposta 60 não
pediu, e o item era a recusa. A recomendação é (a) num item próprio, com o caso dos dois finalizar seguidos lendo a
entidade velha, e (b) junto do D383.

**Corrigido na verificação do item PACOTE-TERCEIRA-RODADA (10 e 11/10/2026), sem a trava e sem a transação.** A
verificação leu o pedido da nota 3 do item ("se a trava pedir mudança de esquema, não faça, registre") e o registro
acima: nenhuma das duas partes pede esquema, e a (b) não é necessária -- a conferência pode SER a baixa. As duas partes
viraram uma só: `ClientePacoteRepository::baixarSessoesDaVenda`, a primeira escrita do finalizar, faz por pacote
`quantidade_restante = quantidade_restante - ?` ONDE o restante cobre as sessões da venda, e zero linhas é a recusa da
resposta 60 (o efeito 1: o segundo `UPDATE` espera o primeiro e confere o restante já baixado); e a conta é do banco (o
efeito 2: nenhuma baixa regrava o restante que o pedido leu). O espelho também: o cancelamento (`VendaService::cancelar`)
devolve por `quantidade_restante + ?` (`devolverSessoes`) -- regravava o restante lido mais a quantidade, a forma do
efeito 2 do outro lado, e o conserto (a) daqui não o citava. Tudo ou nada com dois pacotes, o erro no meio do finalizar
que devolve a sessão se a venda não fechou, a frase que chega quando só a baixa recusa, e o que fica (o POST repetido da
mesma venda, a pergunta 2 do D321): no D452, na seção da corrida. A trava do crédito (o D383) não muda.

**Provado em processo, não medido com dois pedidos ao mesmo tempo:** os casos do `D452SessaoDoPacoteForaDaMesmaVendaTest`
põem o outro caixa entre a conferência e a baixa (a recusa e a frase, pelo ERP e pela API) ou entre a leitura do pacote
e a baixa (o pedido que leu 3 com o banco em 2 deixa 1; o cancelamento que leu 2 com o banco em 1 deixa 2). Que o
`UPDATE` condicional decide a corrida é do banco (o MySQL relê a linha travada no `UPDATE`; o PostgreSQL, em READ
COMMITTED, reavalia o `WHERE` na versão nova da linha), e não foi medido aqui.

### D465 — A comissão extra do barbeiro some quando o corte entra pela agenda, e o PDV e a API a pagam — REGISTRADO em 10/10/2026; a pergunta foi ao lote — CORRIGIDO em 15/10/2026 (a resposta 81)

**Achado no conserto do D453 (o item PACOTE-TERCEIRA-RODADA), lido no código.** A comissão do item é calculada em dois
lugares. O lançamento do PDV antigo e da API da tela nova (`VendaProdutoService::calcularComissao`, e desde 10/10/2026
também o item que herda a sessão do item tirado) soma ao percentual do colaborador a comissão extra dele naquele
produto (`UsuarioComissaoExtraRepository::selecionarPorUsuarioProduto`, a tela de comissões extras do usuário). A
comanda aberta pela agenda (`VendaService::comandaAbrirAgenda`) usa só o percentual (`getComissaoServico()`). O mesmo
corte, pelo mesmo barbeiro, rende comissões diferentes conforme a porta; e o item que nasceu pela agenda e herda uma
sessão (o D453) ganha a comissão da sessão com a extra.

**Na cópia** (`franquiabv_dev`, MySQL, só contagens, em 10/10/2026): 35 comissões extras; 11 em serviço (`ser` ou
`lavavip`) com percentual acima de zero, de 5 colaboradores. Quantas comandas pela agenda pagaram a menos, não foi
medido: a venda não guarda a porta por onde o item entrou.

**É pergunta ao dono**, porque é o que o barbeiro recebe: (a) a agenda passa a somar a extra, como o PDV e a API; (b) o
PDV e a API deixam de somar, e a extra perde o efeito; (c) deixar como está. A recomendação, no lote de 10/10/2026, é
(a).

#### CORRIGIDO em 15/10/2026 -- a resposta 81 (a fatia 4 da fila das telas que faltam, RESPOSTAS-DO-BALCAO)

**Decidido pelo dono em 12/10/2026 (a resposta 81, opção "a", a recomendada):** *"A agenda passa a somar a extra, como
o PDV e a tela nova -- o barbeiro com extra recebe mais no corte aberto pela agenda."* O pedido do dono: *"Faça o commit
e push. Continue para a fatia 4"*.

**O conserto:** a comanda aberta pela agenda (`VendaService::comandaAbrirAgenda`) deixa a sua cópia da conta e chama a
do lançamento, `VendaProdutoService::calcularComissao` (pública desde esta data): o percentual do colaborador mais a
extra dele naquele produto (`UsuarioComissaoExtraRepository::selecionarPorUsuarioProduto`), sobre o valor da linha mais
o das sessões do pacote. As três portas que lançam item -- o PDV antigo, a API da tela nova e a agenda -- passam pela
mesma conta. Sem extra, a comissão é a de sempre; a base não mudou.

**Na cópia** (`franquiabv_dev`, só contagens, 15/10/2026): 11 extras com percentual acima de zero em serviço, de 5
colaboradores (e 12 em produto de barbearia, de 6; e 12 em pacote, de 5, tipo que a conta não lê, antes e agora). Desde
2025, 3 itens de serviço concluídos com a extra cadastrada no par, os 3 com a extra na comissão gravada. A venda não
guarda a porta por onde o item entrou, e o passado não se separa melhor que isso.

**Provado** nos dois bancos, em série: `D363D465ComandaPelaAgendaComoOPdvTest` (novo, 14 casos; 6 da comissão) -- a
extra entra (44,95, os 40% do barbeiro e os 10% de extra sobre 89,90), a agenda e o PDV pagam o mesmo no mesmo corte,
sem extra 35,96, a extra de outro barbeiro no mesmo corte e a do barbeiro noutro produto não entram, e na sessão do
pacote a extra vai sobre os 80,00 da sessão (40,00). **Mutante**, aplicado sozinho no MySQL e restaurado com o sha1
conferido: a agenda de volta à cópia sem a extra -- 3 vermelhos (a extra, a mesma comissão nas duas portas, a sessão).

**A linha lançada antes da troca** (a verificação da fatia, 15/10/2026): a comissão é gravada no lançamento, e o item
que a agenda pôs numa comanda ainda aberta na hora do corte fica com a de antes, sem a extra, como toda venda do
passado (a venda não guarda a porta, e não se separa). O "+" ou o "−" da tela nova nessa linha a refazem pela conta do
lançamento, já com a extra (`alterar` zera a comissão e o `calcularLinha` chama `calcularComissao`). O código não tem
resto: são as comandas abertas na hora do corte, e somem quando fecham.

### D466 — Pela origem do amigo, quem o indicou desativado segue ganhando 10 pontos por venda dele — REGISTRADO em 11/10/2026; a pergunta foi ao lote — CORRIGIDO em 15/10/2026: a 145 (c)

**Achado no item ADESAO-QUARTA-RODADA** (11/10/2026), provado no teste
(`D304EtapaSeisDaAdesaoTest::testOAmigoDeQuemIndicaInativoDaAEleOs10PontosPorCompra`, nos dois bancos). Duas respostas
do dono, cada uma feita como está escrita, se cruzam:

- a **65**, opção (b): "só a comissão para o amigo que TEM a ligação; os amigos de antes, SEM a ligação, seguem dando
  os 10 pontos por compra" -- feita no `VendaPagamentoService::salvarPontuacao` pela ligação (`clientes.indicacao`);
- a **67**, opção (c): com quem indica inativo (ou o cadastro do sistema), "o amigo nasce, a mensagem sai, mas SEM a
  ligação (sem comissão)" -- feita no `FidelidadeService::indicarAmigo` (o D446).

O amigo de quem indica inativo nasce com a origem "Indicação - <número de quem indicou>" e sem a ligação: pela 65, é um
amigo sem a ligação, e cada venda dele dá 10 pontos ao inativo, até o amigo concluir a etapa 3 (que troca a origem). O
`salvarPontuacao` não olha se quem indicou é ativo. O inativo perde a comissão e ganha os pontos -- o mesmo prêmio em
outra moeda. Antes desta rodada era igual (o inativo ganhava a comissão e os pontos); o que a 67 tirou foi só a
comissão.

**Na cópia** (`franquiabv_dev`, só contagens, em 11/10/2026): dos 183 amigos com a origem do número de quem indicou, 1
é de indicador inativo, e nenhum do cadastro do sistema; dos 79 históricos "Somou mais 10 ponto(s), indicação do ...",
de 70 indicadores, nenhum foi para indicador inativo.

**Quem o caso alcança daqui em diante** (corrigido na verificação, 11/10/2026: a primeira redação, aqui e na pergunta
do lote, dizia que era o amigo novo da etapa 6, que "nasce inativo e, até concluir a etapa 3, indica inativo" -- e pela
página ele não indica antes da etapa 3). A etapa 6 vem depois da 5, a 5 depois da 3 (no Brasil), e a 3 é a que ativa
(`FidelidadeService::salvarDados`); a página só a mostra a quem tem "Brasil" no campo `pais`. O amigo novo de uma
unidade do Brasil, que desde a resposta 66 nasce com o país, chega ativo à etapa 6 dele e indica com a ligação: fica de
fora. Chega inativo à etapa 6 quem a página leva da etapa 2 à 4 -- o cliente sem país, com o país vazio ou de fora do
Brasil, ainda sem a adesão -- e o cadastro do sistema. Na cópia (só contagens), os inativos sem a adesão são **118**
fora do "Brasil" (113 sem país, 110 deles amigos de antes; 4 de fora do Brasil; 1 com o país vazio), e 3.124 do Brasil,
que a etapa 3 ativa antes da 6. O caso é o dos amigos que esses 118 (e o cadastro do sistema) indicarem, e o do amigo
novo de uma unidade de fora do Brasil (o resto do D460). Provado nos dois bancos: o amigo novo do Brasil indica com a
ligação (`testOAmigoNovoDoBrasilChegaAtivoAEtapa6DeleEIndicaComALigacao`); o amigo de antes sem país
(`testOAmigoDeAntesSemPaisChegaInativoAEtapa6EIndicaSemALigacao`) e o de fora do Brasil indicam sem ela. A pergunta do
lote foi corrigida; a recomendação (c) não muda.

**É do dono**, e foi ao lote com as opções: (a) como está (a 65 como escrita); (b) os 10 pontos por compra só para os
amigos de antes -- o amigo nascido sem a ligação desde a 67 não os dá; (c) os 10 pontos por compra só para quem indicou
e está ativo na hora da venda, também para os de antes. Nada mudou no código: o caso prende o que ele faz.

#### CORRIGIDO em 15/10/2026 -- a resposta 145 do dono, opção (c) (a fatia 5, RESPOSTAS-DO-PROGRAMA)

**A decisão:** *"Os 10 pontos só para quem indicou e está ativo na hora da venda — também para os amigos de antes; é a
regra do convite do aplicativo e da sua 67."* A mesma conta da 92 (b) (o **D312**), no mesmo trecho.

**O conserto:** a condição do convite do aplicativo (`AplicativoService::indicacaoCadastrar` recusa o indicador inativo e
o cadastro do sistema, o cliente 2) passou a morar numa regra só, `RegraDaFidelidade::premiaQuemIndica` — o ativo que não
é o cadastro do sistema —, e as três portas a perguntam: o próprio convite do aplicativo, onde ela nasceu (que ficou com a
cópia dele até a verificação do mesmo dia); a ligação da comissão, no `FidelidadeService::indicarAmigo` (a 67 (c), que já
a fazia numa cópia privada); e os 10 pontos por compra do amigo sem a ligação, no `VendaPagamentoService::salvarPontuacao`,
**na hora da venda**: quem é reativado volta a ganhar na compra seguinte do mesmo
amigo, e o amigo de antes cujo indicador ficou inativo deixa de dar os pontos. O cadastro do sistema fica de fora também
dos pontos, por ser a mesma condição ("a regra do convite do aplicativo"): na cópia, nenhum amigo tem a origem dele.

**Na cópia** (`franquiabv_dev`, só contagens, 15/10/2026): nenhum dos 79 lançamentos foi para quem hoje está inativo; muda
o caso de 1 amigo (o de indicador inativo, contado em 11/10/2026).

**Provado** nos dois bancos: em `D312PontosDaIndicacaoTest`, quem indicou inativo na hora da venda não ganha linha nem
saldo, e o amigo pontua pela compra dele como sempre; reativado, ganha na compra seguinte; e o cadastro do sistema, ativo,
não ganha. Em `D304EtapaSeisDaAdesaoTest`, o caso que prendia o comportamento de antes virou
`testOAmigoDeQuemIndicaInativoNaoLheDaOs10PontosPorCompra` (o nome do achado, acima, é o de antes), e entrou
`testOConviteDoAplicativoRecusaQuemIndicaInativoEOCadastroDoSistemaPelaRegra`: o convite recusa os dois pela regra, sem
gravar a ligação, e o ativo passa. E a regra, sem banco, em `RegraDaFidelidadeTest`. Os mutantes: o `salvarPontuacao` sem
a regra deixa quatro casos vermelhos; o convite sem a condição, um (o do convite); a regra sem o "ativo", dez (os da página
da adesão e o do convite também, que a perguntam pela mesma função); a regra sem o cadastro do sistema, quatro — com a
cópia de antes no convite, o caso dele ficaria verde nos dois últimos, e é isso que a regra só impede.
**Não regrediu** a 65 (b) — o amigo com a ligação segue sem os 10 pontos — nem a 68 (c) — a primeira compra cancelada
guarda a comissão (os casos do D304, verdes nos dois bancos).

### D467 — O aparelho de teste não entra no login V4 do servidor de teste — REGISTRADO em 11/10/2026; a pergunta foi ao lote — DECIDIDO em 12/10/2026: pela mão do dono, no banco do teste (a resposta 149)

**Achado ao preparar o teste em aparelho** (PUSH-TERCEIRA-RODADA, a resposta 74; o **D461**). O servidor de teste
pode falar com o Google (o `docker-compose.teste-firebase.yml`), mas o aparelho só recebe push depois de se registrar
(`POST /api/v4/clientes/:id/aparelhos`), e registrar exige a sessão V4, que nasce do código de seis dígitos mandado
pelo WhatsApp da unidade (a resposta 71). **No teste, o código não chega a ninguém:** o WhatsApp está no loopback
(`HOSTS_MORTOS`), e o endurecimento da base apaga as chaves de WhatsApp das unidades -- sem a chave, o
`ClienteAutenticacaoService::pedirCodigo` nem cria a sessão (a regra de não guardar promessa que ninguém cumpre).
Pela leitura do código e do `docs/ambiente-teste.md`; nada rodou no BVIP.

**É do dono**, e foi ao lote com as opções: (a) pela mão dele, no banco do teste, um cliente com o telefone de quem
testa numa unidade marcada com o WhatsApp ligado e uma chave qualquer, e o código lido na fila `push_app` (o cron está
desligado no teste, e nada sai); (b) um roteiro só do teste que cria a sessão e mostra o token uma vez; (c) abrir o
WhatsApp de uma unidade no teste. A recomendação é a (a), sem código novo. Até a resposta, o teste em aparelho prova
o lado do aparelho pelo console do Firebase, e o servidor, só quando houver uma sessão.

**A ordem, se for a (a)** (a verificação de 11/10/2026): o código fica na fila `geral` da `push_app`, a mesma que o
`marketingPush` lê para mandar o push de teste, e a rotina **apaga cada linha que processa** — também a do código,
mandada ao WhatsApp do loopback. Lido o código antes, nada se perde; o passo 9 de "O push pelo Firebase no teste"
(`docs/ambiente-teste.md`) põe o login e o registro antes da rotina.

**Decidido pelo dono em 12/10/2026 (a resposta 149, opção a, a recomendada):** o aparelho de teste entra pela mão do
dono, no banco do teste: um cliente de teste com o telefone de quem testa, numa unidade marcada com o WhatsApp ligado; o
código fica na fila sem sair, e ele o lê lá e o passa a quem testa. Nenhum código muda. **O passo escrito** está em
`docs/ambiente-teste.md` ("O push pelo Firebase no teste", o passo 9.1): a unidade com `whatsapp_ativo` e uma chave
qualquer (o login só cria a sessão assim, `ClienteAutenticacaoService::unidadeQuePodeEnviar`), o cliente com o número,
o pedido do código pelo aplicativo, a leitura da linha na `push_app` (o código vale 10 minutos, e são no máximo 5 pedidos
por telefone por dia), e o desfazer da unidade; e a rotina da fila não se chama antes de o código ser lido. O
`docs/push-guia-do-aplicativo.md` aponta para lá. Lido no código; nada rodou no BVIP.

**Atualizado em 12/10/2026 (o item WHATSAPP-F0-E-CENTRAL, a resposta 147 ao D59; a verificação dele):** o que está acima
sobre o login e a fila é de antes. O código de entrada sai agora pelo **WhatsApp central da franqueadora**, e não pelo da
unidade: `ClienteAutenticacaoService::unidadeQuePodeEnviar` saiu, ligar o WhatsApp de uma unidade não basta mais, e a
sessão só nasce com o `whatsapp.central` do `local.php` preenchido (a instância e a chave; valores inventados servem no
teste). E a rotina da fila **não apaga mais** a linha que falha: a do código, mandada ao loopback, fica marcada como
`falhou:geral` (o D302), sem nova tentativa. O passo 9.1 de `docs/ambiente-teste.md` e o `docs/push-guia-do-aplicativo.md`
já dizem isso; a decisão do dono (a 149, o código lido na fila) não muda.

### D468 — A retirada do PDV antigo não confere quem pediu: qualquer id vira solicitante, inativo ou de outra franquia — REGISTRADO em 11/10/2026 — CORRIGIDO em 27/10/2026

**Achado na verificação do item RESPOSTAS-SEM-RODADA (11/10/2026)**, ao conferir o D365 contra a especificação
da Retirada (`docs/f3-relatorios/spec-retirada.md`). É o **D-r4** dela, que não tinha número: a seção 4 o dá como
"CORRIGIDO na F2, nas duas entradas", e a parte do PDV antigo da F2 (`:911-915`) põe quatro itens em
`retiradaAction` -- a guarda, a frase, o produto nulo e o solicitante. O D365 fez a guarda e a frase, o produto
nulo é do D387, e **o solicitante não foi feito**. Pela leitura do código; nada rodou.

- `ProdutosController::retiradaAction` (`module/ISConfiguracao/src/Controller/ProdutosController.php:467`) só
  confere `!empty($request["solicitado_por"])`, e `ProdutoService::retirada`
  (`module/ISConfiguracao/src/Service/ProdutoService.php:233`) grava o id cru, com `getReference`, sem perguntar
  quem é. A `RegraDaRetiradaDeEstoque` da especificação não existe.
- **O efeito:** um POST à retirada, de quem tem `vendas/nova`, com o `solicitado_por` de um usuário inativo ou
  de outra franquia, é aceito e gravado -- e a coluna "uso interno" passa a contar a retirada para essa pessoa.
  O que o campo "Solicitado por" do modal oferece (o formulário da retirada, em `novo.phtml` e `comanda.phtml`)
  não foi lido.
- **A regra já está escrita** (`spec-retirada.md:838-841`, sem decisão do dono): `solicitanteAceito` é "existe,
  ativo e da unidade do produto", com a frase "Quem pediu tem de ser alguém ativo da unidade do produto.";
  medido em julho pela especificação, 0 das 186 saídas tinham o solicitante de outra unidade.
- **A outra entrada**, a retirada na comanda nova, ainda não existe: nasce com a mesma regra, na F2.

**O conserto** é o da especificação, com casos em processo nos dois bancos (o inativo, o de outra unidade, o
id que não existe, e o ativo da unidade, que passa). Fica para a rodada da F2 da Retirada.

#### CORRIGIDO em 27/10/2026 -- a fatia 49 (REL-RETIRADA-NA-COMANDA; a F2 de `spec-retirada.md`)

**O conserto, nas duas entradas:** a `module/ISConfiguracao/src/Regra/RegraDaRetiradaDeEstoque.php` (`solicitanteAceito`:
existe, está ativo e é da unidade do PRODUTO; a frase `SOLICITANTE_RECUSADO`, "Quem pediu tem de ser alguém ativo da unidade
do produto.") é chamada pelo modal do PDV antigo (`ProdutosController::retiradaAction`, com o ajudante privado
`solicitanteDaRetirada`, que lê o usuário pelo id do `<select>`; o texto que não é número é recusado) e pela retirada nova da
comanda (`Adm\ProdutosController::retirarDoEstoqueAction`, 422 no `solicitadoPor`). O portão do recorte vigia as três
(`conferir-recorte-por-unidade.php`, quatro marcas novas). **Para quem usa:** nada muda no modal (ele já oferecia só os ativos
da unidade da sessão); o POST com o id de um inativo ou de outra franquia passa a ser recusado com a frase. **Medido na cópia
antes (só contagens, sem a fixture 90001000):** das 542 retiradas com solicitante de 2026, 0 têm o solicitante de outra unidade que a do produto; 47
têm o solicitante inativo HOJE (eram ativos ao lançar; a regra vale para a retirada nova). **Provado** no
`tests/Caracterizacao/RetiradaDeEstoqueNaComandaTest.php`, nos dois bancos (o inativo, o de outra unidade, o que não existe e o
texto, nas duas portas; o ativo da unidade passa), e pelos mutantes da fatia 49 (o roadmap).

### D469 — O ensaio geral do corte não passa do começo no laboratório: o local.php de lá lê o banco do ambiente, e o roteiro procura o banco escrito — REGISTRADO em 11/10/2026 — CORRIGIDO em 08/11/2026

**Achado na verificação do item RESPOSTAS-SEM-RODADA (11/10/2026)**, ao escrever o ensaio dos clones do D378. Pela
leitura e por um `grep`; nada rodou no BVIP.

- O laboratório sobe com o `local.php` do modelo do Docker (`docs/ambiente-homologacao.md:448`,
  `cp docker/config/local.php.docker config/autoload/local.php`), que lê a conexão do ambiente:
  `'host'     => getenv('DB_HOST')     ?: 'mysql'` e `'dbname'   => getenv('DB_NAME')     ?: 'franquiabv_dev'`
  (`docker/config/local.php.docker:30-32`).
- O `scripts/ensaiar-corte.sh` reconhece o `local.php` da origem pelo texto: `'host' => 'mysql'` e, desde
  11/10/2026, `'dbname' => '<BANCO_MYSQL>'` -- um espaço de cada lado da seta e o valor entre aspas. **No modelo
  do Docker, a busca pelo host dá 0** (`grep -c "'host' => 'mysql'" docker/config/local.php.docker`, medido).
  A guarda do `apontar` já fazia essa busca antes do D378, e por isso **o ensaio do laboratório nunca passou do
  `apontar`**: parava depois da carga, com "local.php nao aponta para o MySQL". Desde 11/10/2026 ele para no
  começo, antes de escrever na cópia, com a mesma frase.
- E o `local.php` dos clones (`apontar_para_o_clone`) troca o `dbname` escrito por `sed`. Com o `getenv` não há
  o que trocar, e o banco viria do `DB_NAME` do contêiner, que o `sed` não alcança.
- O ensaio de 27/08 rodou no desenvolvimento, onde o `local.php` tem os valores escritos.

**O conserto** (técnico, sem decisão do dono): o bloco do laboratório escrever, antes do ensaio, um `local.php`
com o host e o banco escritos; ou o roteiro ler a conexão pelo PHP do contêiner (o `local.php` avaliado) e
escrever o dos clones com o banco literal. Com um caso no `scripts/ensaiar-clones-do-ensaio-de-corte.sh` com o
`local.php` do modelo do Docker. Até lá, o bloco "O ensaio geral do corte, no laboratório" de
`docs/ambiente-homologacao.md` para no começo.

**CORRIGIDO em 08/11/2026 (a fatia 128, ROTEIROS-DO-CORTE-E-DA-CARGA).** O `scripts/ensaiar-corte.sh` lê a
conexão pelo PHP do contêiner da aplicação: o ajudante novo `scripts/conexao-do-local-php.php` avalia o `local.php` (ou
o arquivo pedido) e imprime `<host>|<dbname>|<user>`, sem a senha, com o `getenv` resolvido no ambiente de quem roda; o
ensaio o chama por `docker compose -p "$PROJETO" exec -T app php ...`. A guarda do começo e a do `apontar` passaram a ele
(o MySQL de `BANCO_MYSQL`, lido como a suíte o lê); o `apontar_para_o_clone` escreve o banco do clone ENTRE ASPAS no lugar
do valor escrito ou do `getenv('DB_NAME') ?: '...'` do modelo do Docker (o `sed` antigo não achava o que trocar), e confere
pelo arquivo avaliado; e o usuário do GRANT do clone também vem do avaliado (no modelo do Docker ele é o `DB_USER`; o
`sed` antigo devolvia vazio e a `clone_mysql` parava). Medido no contêiner daqui: o `local.php`, o
`docker/config/local.php.docker` e o `local.postgres.php.dist` dão `mysql|franquiabv_dev|...`, `mysql|franquiabv_dev|...` e
`postgres|franquiabv_dev|bvip_app`. **O caso** no `scripts/ensaiar-clones-do-ensaio-de-corte.sh` (13 casos): o dublê do
contêiner avalia o `local.php` com o PHP de verdade e o ambiente de mentira de cada caso; com o `local.php.docker` o ensaio
vai inteiro (a suíte nos dois clones, as fixtures só neles, o GRANT para o `DB_USER`, o `local.php` igual no fim), e com o
`DB_NAME` de outro banco ele recusa antes de qualquer pedido. Mutantes, nas cópias do roteiro, vermelhos: a guarda pelo
texto de volta, o `sed` do clone de antes, o usuário pelo texto, e o roteiro do HEAD. O texto do bloco do laboratório em
`docs/ambiente-homologacao.md` diz o novo. **Não rodou no BVIP:** o bloco do laboratório continua por rodar lá, pelo dono.

### D470 — Os casos de arrastar da agenda agiam antes dos efeitos do desenho, e caíam sob carga — REGISTRADO em 11/10/2026 — CORRIGIDO em 11/10/2026

**O "arrastar instável"** (o item 26 da fila, FRONT-ARRASTAR-INSTAVEL), visto desde 27/09/2026: um caso de arrastar
do `frontend/src/telas/AgendaLigada.teste.tsx` caía às vezes na suíte inteira do front e passava sozinho, cada vez um
caso diferente -- 4 vezes em 14 rodadas em 29/09, e no R17 (10/10) 2 de 3 rodadas com `--maxWorkers=4`
("os destinos do arrasto contam a família inteira, e não só a desenhada" e "os destinos são os da janela: o horário
tomado não cabe, e soltar ali não abre nada", os dois no `toHaveTextContent` com nulo). O arquivo não mudava desde
333bd71, e nenhum componente da agenda tinha mudado.

**A causa**, medida em 11/10/2026. O caso faz `await screen.findByText('Joaquim')` e, na linha seguinte, o gesto
inteiro com `fireEvent` síncrono. O `findBy` volta assim que o desenho põe a grade no DOM; os `useEffect` desse
desenho ficam numa tarefa à parte do Scheduler do React (no Node, o `setImmediate`). No host calmo eles rodam antes do
gesto; no carregado, às vezes depois. Quando rodam depois, o `setState` do `pointermove` (dentro do `act` do
`fireEvent`) os solta antes de desenhar, e o efeito `[tela]` da `AgendaDeHoje` (`Agenda.tsx`: "a tela mudou no meio
do gesto, e ele desiste") roda **pela primeira vez** com o gesto já começado, e desiste dele: a sombra
(`.agenda-alvo`) não aparece, e soltar não abre a janela.

**A prova** é o amplificador, novo: `frontend/src/testes/amplificador.ts`, o primeiro dos `setupFiles`, desligado a
não ser que `AMPLIFICAR_EFEITOS_MS` diga quantos milissegundos. Ele atrasa no `setImmediate` só o
`performWorkUntilDeadline` do Scheduler, e a corrida deixa de depender do host. No arquivo de antes: 160 de 160 sem
ele; 9 vermelhos a 10 ms; 12 a 50 ms -- os 4 casos do arrasto que esperam a sombra ou a janela (os dois do R17 e
"soltar abre a janela..." e "depois de remarcar pelo arrasto...") e 8 fora do arrasto. **Com o efeito `[tela]`
neutralizado no componente, só para medir, os 12 casos do `remarcar > arrastando` passam a 50 ms**: é ele, e só ele, no
arrasto. E o amplificador posto depois do `preparo.ts` não age (160 de 160, mesmo com a espera arrancada): o Scheduler
guarda o `setImmediate` quando carrega, e o `preparo.ts` já carrega o React.

**Os 8 de fora do arrasto são a mesma causa noutra forma**, achados pelo amplificador:

- o relógio falso anda antes de o efeito `[ehHoje]` da `AgendaLigada` ligar o relógio de minuto, e a recarga não sai
  (4: "no dia de hoje, a grade é relida a cada minuto" e três de "a recarga que falha");
- o pedido da grade de novo sai de um efeito, e o caso o contava logo depois do clique (2: "registrar chegada chama o
  servidor e pede a grade de novo" e "com agendamento no período, pergunta -- e só o segundo envio confirma");
- o caso esperava um sinal que chega antes do desenho, e lia em seguida o que o desenho traz (2): o título do dia 05,
  que troca no clique junto com o "carregando", antes do bloco das 09:00 do dia 05 ("o pedido no ar de um dia não trava
  o mesmo horário de outro dia"); e o foco no título, que a recusa põe direto no DOM, antes do aviso "A agenda não foi
  atualizada" ("a recarga que falha depois de remarcar leva o foco ao título, e não prende a marca").

**E os casos em que nada pode acontecer mediam o efeito, e não a guarda.** Com a corrida, o efeito atrasado desistia do
gesto também em "quem só lê não arrasta", "a família com um horário em atendimento não se arrasta" e "o agendamento
que o servidor diz que não se move não se arrasta"; e o relógio atrasado não relia em "noutro dia, não relê". A 50 ms,
no arquivo de antes, arrancar a guarda do componente (o `grade.podeEscrever` do `arrasto` na `AgendaLigada`; o `seMove`
do `destinosDe`; a data no `ehHoje`) deixava os quatro verdes.

**Não é defeito do componente, e ele não mudou.** O efeito `[tela]` só desiste de um gesto que começou antes de a
primeira volta dele rodar. No navegador, o gesto pede o botão descer sobre o bloco já desenhado e o mouse andar 6 px,
em eventos separados, e os efeitos do desenho que pôs o bloco na tela rodam antes disso. É argumento, e não medida:
não reproduzi no Chrome.

**O conserto**, no arquivo do caso (`AgendaLigada.teste.tsx`), esperando um sinal do que o caso precisa -- sem tempo
limite maior e sem repetição:

- `esperarOsEfeitosDaAgenda()` espia o `observe` do `ResizeObserver` e espera a `section.agenda` observada. A
  `AgendaDeHoje` chama o `observe` na mesma leva de efeitos do `[tela]` e do `[ehHoje]`, porque só monta no desenho em
  que a grade chega, e a leva roda inteira de uma vez. A espia não muda o `observe` do happy-dom, que não mede nada.
  Entra em 12 casos: os 7 do arrasto que fazem o gesto logo depois do `findBy` (os 4 que esperam a sombra ou a janela,
  e os 3 em que nada pode acontecer) e os 5 do relógio falso (os 4 que esperam a recarga, e o "noutro dia, não relê",
  em que ela não pode sair). Sem ela, caem os 8 que esperam alguma coisa; os 4 negativos caem pela guarda arrancada do
  componente, nos mutantes abaixo. Os casos que clicam com o
  `userEvent` antes não esperam: o clique muda estado dentro do `act`, e isso solta os efeitos pendentes;
- o pedido de novo: `waitFor` (2 casos);
- o dia 05: o `findByRole` do bloco das 09:00, e não o título;
- o aviso antes do foco: espera-se o aviso, e o foco tem de seguir no título.

**Provado** em 11/10/2026: o arquivo sozinho, 160 de 160 sem o amplificador, a 10 ms e a 50 ms; a suíte inteira do front com `--maxWorkers=4`, dez rodadas seguidas, todas verdes, 1.611 de 1.611 em 44
arquivos (as cinco últimas com a árvore final; as cinco primeiras antes de o amplificador ganhar os tipos que o
build pede, com o mesmo comportamento); e os
irmãos da agenda (`ArrastoDaAgenda`, `FormasDaAgenda`, `GradeDaAgenda` e `Agenda`), 127 de 127 a 50 ms, sem mudança.
Os mutantes, cada um sozinho a 50 ms, todos vermelhos (a tabela no roadmap de 11/10/2026): a espera arrancada, 8; cada
`waitFor`, o `findByRole` do dia 05 e a ordem do aviso, 1 cada; e quatro guardas do componente, que agora derrubam os
casos negativos -- três delas (o `grade.podeEscrever` do arrasto, o `seMove` do `destinosDe` e a data no `ehHoje`)
ficavam vivas no arquivo de antes. Build e lint verdes: o lint no piso de dois avisos em `src`, e os quatro de
`.design-sync`, que não são deste item.

**O instrumento, conferido na revisão do mesmo dia: ligado e sem agir, ele ficava calado.** Com o `amplificador.ts`
de antes, a espera arrancada e `-t arrastando`, o amplificador posto depois do `preparo.ts` deu 12 de 12 e saída 0, e
`AMPLIFICAR_EFEITOS_MS=abc` ou `=5O` (o `Number` dá NaN, que não passa de zero) também -- rodadas iguais à de um caso
consertado; na ordem certa, 4 vermelhos. Agora o valor que não é só algarismos recusa o arquivo ao carregar ("não é um
número de milissegundos", saída 1; vazio ou 0 seguem desligando), e, ligado, um `afterAll` do próprio amplificador pede
ao Scheduler uma tarefa vazia e reprova o arquivo se nenhum `performWorkUntilDeadline` passou pelo embrulho desde o
começo dele ("O AMPLIFICADOR NÃO AGIU", saída 1): a ordem trocada dá os mesmos 12 verdes e o arquivo vermelho, com a
espera arrancada ou sem ela. A tarefa vazia é o que deixa passar o arquivo que não desenha nada. Sem alarme falso: na
ordem certa, a espera arrancada segue em 4 vermelhos e sem o aviso; o arquivo da agenda, 160 de 160 a 10 e a 50 ms,
com 0 e sem a variável; o `formato.teste.ts`, que não desenha nada, 20 de 20 a 50 ms; e a suíte inteira a 50 ms, duas
rodadas, 27 e 28 vermelhos, todos nos seis arquivos do D471, e nenhum aviso. Os mutantes da guarda, cada um sozinho: sem
o reprovar, a ordem trocada volta a 12 de 12 e saída 0; sem a tarefa vazia, o `formato.teste.ts` cai com o aviso falso
(20 de 20, e saída 1); sem a recusa do valor, o `abc` volta a 12 de 12 e saída 0. Com a guarda, a suíte inteira sem a
variável, com `--maxWorkers=4`: oito rodadas seguidas, 1.611 de 1.611 em cada (as cinco primeiras com o `import` do
Scheduler escrito literal, que o `tsc -b` recusou com o TS7016; ele só roda com a variável ligada, e as três últimas
são da árvore final); build e lint verdes, no mesmo piso.

**Não provado:** que o navegador não tenha a mesma corrida (o argumento está acima). E o resto da suíte não está livre
da mesma forma: com o amplificador a 50 ms, ela derruba 27 casos de outros seis arquivos (o D471, registrado).

### D471 — Seis telas do front conferem o foco e a descrição sem esperar os efeitos: 27 casos caem com o amplificador — REGISTRADO em 11/10/2026 — CORRIGIDO em 08/11/2026

**Achado ao provar o D470** (11/10/2026). Com `AMPLIFICAR_EFEITOS_MS=50 npx vitest run --maxWorkers=4` (em
`frontend/`), a suíte inteira do front dá 27 vermelhos em 1.611, todos fora da agenda, em seis arquivos:

| Arquivo de caso | Casos | Como caem |
|---|---|---|
| `CatalogoLigado.teste.tsx` | 4 | 3 no `toHaveAccessibleDescription`, 1 no `toHaveFocus` |
| `ClientesLigada.teste.tsx` | 8 | 5 no `toHaveFocus`, 2 no `toHaveAccessibleDescription`, 1 no `toBe(true)` |
| `ComandasLigada.teste.tsx` | 3 | 2 sem achar o elemento (um título), 1 no `toHaveFocus` |
| `ConveniosLigado.teste.tsx` | 3 | 3 no `toHaveAccessibleDescription` |
| `DevedoresLigado.teste.tsx` | 3 | 3 no `toHaveFocus` |
| `PacotesLigado.teste.tsx` | 6 | 4 no `toHaveAccessibleDescription`, 1 no `toHaveFocus`, 1 sem achar o elemento |

A 10 ms, nos mesmos seis arquivos: 7 vermelhos em 420 (5 do `ClientesLigada` e 2 do `DevedoresLigado`). Sem o
amplificador, os seis passam: nenhum caiu nas dez rodadas da suíte inteira de 11/10/2026, com `--maxWorkers=4`.

**A lista não é fixa, mesmo a 50 ms** (medido na revisão do D470, no mesmo dia, com mais duas rodadas da suíte
inteira): 27, 27 e 28 vermelhos, sempre nos mesmos seis arquivos, mas não sempre os mesmos casos. No `ClientesLigada`,
"o cliente gravado sem país escolhe um para salvar, e ele vai no corpo" entrou no lugar de "o grupo inteiro recusado
marca o primeiro campo dele..."; o `ComandasLigada` variou mais, 3, 3 e 4, com casos diferentes a cada rodada ("estornar
pede confirmação...", "abrir outra comanda tira a frase da encerrada", "o 409 de outro motivo não relê a comanda",
"quando a autorização do fiado cai, a tela explica"), os novos todos sem achar o botão logo depois de um gesto. Sozinhos,
o `ClientesLigada` deu 8 e depois 7, e o `ComandasLigada`, 2 e 2. Nestes arquivos o atraso não torna toda corrida
certa: a prova de cada conserto pede várias rodadas, e não uma, e a tabela acima é a primeira rodada.

**A forma, lida pelas mensagens, e não caso a caso:** o caso confere o foco ou a descrição acessível (o
`aria-describedby` que liga o campo à recusa do servidor) logo depois de um gesto ou de um `findBy`, e quem os põe é um
efeito, ou um desenho, que o Scheduler do React ainda não rodou -- a corrida do D470, noutras telas. No host carregado,
cada um destes pode cair como o arrastar caía. Não investiguei cada caso: algum pode ser de outra causa.

**O conserto**, técnico e sem decisão do dono: o do D470, caso a caso -- esperar o sinal do que o caso precisa (o
foco, a descrição, o elemento do desenho seguinte), e não o sinal que vem antes; provar com o amplificador a 10 ms e a
50 ms (que, desde a revisão do D470, reprova o arquivo em que não agiu: o verde dele é medida); e arrancar a guarda de cada caso negativo da tela para ver que ele cai. Ficou de fora do item
FRONT-ARRASTAR-INSTAVEL, que era a agenda.

**Relido em 15/10/2026 (a fatia PERGUNTA-NO-BOTAO).** Nos onze arquivos de caso das seis telas da pergunta no
botão, com `AMPLIFICAR_EFEITOS_MS=50`: 13 vermelhos na HEAD (medidos numa cópia do `git archive HEAD frontend`) e 12
com a fatia, os mesmos casos menos um que oscila (o "desativar com o formulário do item aberto" dos pacotes), todos no
`CatalogoLigado`, no `ConveniosLigado` e no `PacotesLigado`, e todos da forma acima (o 422 que marca o campo, o foco
depois da recusa). A fatia não os tocou; os três arquivos de caso novos dela e os três casos novos das telas passam com
o amplificador. Na verificação da fatia (15/10/2026), com os cinco casos que ela acrescentou às telas: 12 de 526
vermelhos, nos mesmos três arquivos e da mesma forma, e nenhum caso novo entre eles.

**Relido em 20/10/2026 (a fatia 20 da fila das telas que faltam, ETIQUETAS-SEM-ETIQUETA).** O `CatalogoLigado.teste.tsx`
sozinho, com `AMPLIFICAR_EFEITOS_MS=50`, segue com os mesmos 4 vermelhos da tabela (o foco depois da página que falha, e
três `toHaveAccessibleDescription` do 422 e do estoque), e na HEAD também (os quatro arquivos da fatia trocados pelos da
HEAD e devolvidos, no mesmo roteiro): a fatia não os tocou, e os quatro casos novos dela passam com o amplificador. O
`EtiquetasLigado.teste.tsx`, o `formularioDaEtiqueta.teste.ts` e o `Console.teste.tsx` passam inteiros com ele (60 e
119); na rodada dos quatro arquivos juntos, com `--maxWorkers=4`, o "o valor unitário só é campo para o gerente" do
Console caiu uma vez e passou nas duas rodadas sozinho -- a variação da lista que este débito já descreve.

**Relido em 31/10/2026 (a fatia 61, REL-CAIXA-API).** Com `AMPLIFICAR_EFEITOS_MS=50`, nos quatro arquivos de Console e do menu
juntos (`--maxWorkers=2`), o mesmo "o valor unitário só é campo para o gerente" do `Console.teste.tsx` caiu uma vez (215 casos, 1
vermelho: o `combobox` "Produto ou serviço" não achado). Sozinho, com a fatia, caiu 1 de 4 vezes; com o `Console.tsx` da HEAD
(trocado e devolvido pelo sha1 no mesmo roteiro), 1 de 3. A fatia não tocou a comanda: só a linha do Caixa na `TELA_DO_RELATORIO`.

**Relido em 31/10/2026 (a fatia 62, REL-CAIXA-TELA).** Com `AMPLIFICAR_EFEITOS_MS=50`, nos seis arquivos de efeito da fatia juntos
(`--maxWorkers=2`, 290 casos), caíram 2 do `Console.teste.tsx`: o mesmo "o valor unitário só é campo para o gerente" e o vizinho
"sem vendas/comandasNova/escrever, a comanda aberta não oferece lançar" (o texto do aviso não achado logo depois do gesto, a
forma deste débito). Sozinho, com a fatia, caiu 1 de 2. Alternando o `Console.tsx` da fatia e o da HEAD no mesmo roteiro
(`medir-p62-d471.py`, trocado e devolvido pelo sha1), oito voltas: com a fatia 0 de 8, com a HEAD 2 de 8 (o "valor unitário"); e
numa cópia do `git archive HEAD frontend`, 0 de 7. A lista varia com a carga da máquina, como o débito diz; a fatia não tocou a
comanda. **Na correção da 62:** o roteiro troca SÓ o `Console.tsx` -- o resto da árvore segue com a fatia --, então o braço "HEAD"
dele não é a HEAD, e a saída das oito voltas não foi gravada. Rodado de novo com a saída gravada (`p62c-d471.txt` no rascunho da
sessão), quatro voltas: 1 de 4 com o `Console.tsx` da fatia e 1 de 4 com o da HEAD (na mesma volta, a terceira, quando a máquina
pesou). Somando, a fatia caiu fora da alternância (no lote e 1 de 2 sozinha) e dentro dela; a HEAD inteira não caiu nas 7 rodadas
que teve. Não há prova, nesta fatia, de que o vermelho cai com a HEAD inteira: o que diz que é este débito é a comanda fora da
fatia e o "valor unitário" caindo antes da tela do Caixa existir (a releitura de antes da 61, acima).

**Relido em 06/11/2026 (a fatia 90, EXPORTAR-PACOTES, e a verificação dela).** O `PacotesLigado.teste.tsx` sozinho, com
`AMPLIFICAR_EFEITOS_MS=50`, dá 2 vermelhos em 69, nas duas rodadas, e os mesmos 2 em 63 numa cópia do `git archive HEAD
frontend` (o `node_modules` ligado, nada mais): o "desativar com o formulário do item aberto não leva o foco ao acrescentar",
que este débito já lista, e o **"o aviso da gravação não passa ao próximo pacote aberto"** (`PacotesLigado.teste.tsx:1125`),
que nenhuma releitura tinha escrito. A forma deste: depois do "Pacote salvo." e do "voltar aos pacotes", o clique em "abrir o
pacote Pacote Dez Barbas" corre contra os efeitos da lista que volta, e o `findByRole('heading', { name: 'Pacote Dez Barbas' })`
não acha o título -- a tela segue na lista. Nenhum dos dois toca o botão da planilha da fatia 90, e os seis casos novos dela
passam com o amplificador. **O conserto** é o deste débito: esperar o sinal dos efeitos da lista (o botão "abrir o pacote ..."
pronto depois da releitura) antes do clique, e provar com o amplificador.

**Relido em 06/11/2026 (a correção da fatia 94, ENTRADA-NO-NOVO).** O `PesquisasLigado.teste.tsx` sozinho, com
`AMPLIFICAR_EFEITOS_MS=50`, dá 2 vermelhos em 51 que nenhuma releitura tinha escrito: **"pôr na adesão diz o que a etapa 5
passa a perguntar e qual sai; a volta relê a lista"** e **"o 409 da incompleta diz o que falta, e relê"**, os dois sem achar o
botão "pôr na adesão" logo depois de um `findBy` -- a forma deste débito. Sem o amplificador, passam. A correção da 94 tocou o
dublê da API (`frontend/src/testes/dubleDaApi.ts`, o redirecionamento como o navegador o entrega); com o dublê do `HEAD` no lugar
(trocado e devolvido pelo sha1), os mesmos dois caem do mesmo jeito: não são da fatia, e a tela não muda desde a fatia 36.
**O conserto** é o deste débito: esperar o botão (ou o pedido que o efeito faz) antes do clique, e provar com o amplificador.

**Relido em 06/11/2026 (a fatia 98, ENDERECO-POR-TELA).** O `Console.teste.tsx` sozinho, com `AMPLIFICAR_EFEITOS_MS=50`, dá 6
vermelhos em 168 em todas as rodadas (quatro da comanda dentro do Console -- o "valor unitário" duas vezes, o "lançar" e o
"cancelar comanda" -- e os dois do caixa, a "recusa da finalização"), e os MESMOS 6, pelo nome, numa cópia do `git archive
HEAD frontend` (o `node_modules` ligado, nada mais), três rodadas de cada lado. A fatia tocou o `Console.tsx` (o aviso do
endereço e o efeito que avisa a troca de tela), e não a comanda; os casos novos dela passam com o amplificador. A lista cresceu
de 1-2 para 6 sem mudança da comanda: é a máquina, como o débito diz.

**CORRIGIDO em 08/11/2026 (a fatia 124, TELAS-NOVAS-ACABAMENTO-E-TESTES).** Medido antes, com `AMPLIFICAR_EFEITOS_MS=50` e
`--maxWorkers=2`, nos oito arquivos da triagem (Catálogo, Clientes, Comandas, Convênios, Devedores, Pacotes, Pesquisas e
Console): 23 vermelhos em 762, os mesmos nas duas rodadas -- 2 do Catálogo, 6 do Clientes, 1 do Comandas (as pílulas que andam
pelas setas), 6 do Console (os quatro da comanda e os dois do caixa), 1 do Convênios, 3 do Devedores, 2 do Pacotes e 2 do
Pesquisas. O conserto é só dos testes, caso a caso, esperando o sinal do que o caso precisa, e não o que vem antes:

- **o foco é de um efeito:** `waitFor` no foco, no lugar do `expect` logo depois do `findBy` (o título depois da página que falha
  no Catálogo; o editar, o "Cliente novo", o nome e o título do cliente criado no Clientes; o voltar, o aviso e o receber no
  Devedores); e os ajudantes `editarAna` e `cadastrarNovo` (Clientes) e `abrirReceber` (Devedores) esperam o foco que o
  formulário e o painel põem antes de devolver -- o gesto seguinte corria contra ele;
- **o título vem antes do conteúdo:** o "pôr na adesão" do Pesquisas por `findByRole` (o título já tem o foco com a pesquisa
  carregando); o `abrirAComanda` do Console espera o "abrindo a comanda…" sair, como o `abrir` do `ComandasLigada.teste.tsx`; o
  `finalizarSemCaixa` espera o botão "finalizar comanda" desenhado, e não só os sete pedidos feitos;
- **a releitura da lista desliga a linha:** no "o aviso da gravação não passa ao próximo ... aberto" do Catálogo, do Convênios e
  do Pacotes, o clique em "abrir" caía na linha com `aria-disabled` (a releitura no ar) e a tela ficava na lista -- espera-se o
  pedido da releitura e a linha que volta a abrir;
- **um efeito tira o foco posto à mão:** nas pílulas do Comandas, o foco da comanda aberta chegava ao título depois do
  `tudo.focus()` do caso e o tirava da pílula (visto com o `document.activeElement`): o caso espera o título focado antes; no
  Pacotes, o foco do formulário de acrescentar chegava ao serviço depois do clique em "desativar".

Nas rodadas de prova, a 10 ms, caiu 1 vez em 3 o "abrir outra comanda tira a frase da encerrada" (Comandas, que este débito já
listava em 11/10): o mesmo "abrindo" antes do voltar, consertado igual. E a suíte inteira com o amplificador achou mais 4, fora
da lista e iguais numa cópia do `git archive HEAD frontend`: `AclLigado` (2, a grade do grupo depois do foco no título),
`UnidadesLigado.escrita` (1, o botão do cartão que volta com a resposta) e `WhatsAppLigado` (1, o aviso que vem com a resposta,
e não com o pedido) -- consertados do mesmo jeito. A fatia achou um quinto, dela: o D725 fez o cabeçalho dizer "Todas as
unidades" já carregando, e seis arquivos de relatório esperavam esse texto como sinal da resposta da rede
(`ProdutosVendidosLigado` caía a 50 ms; nos outros a espera ficava vazia sem cair) -- passam a esperar também o
`.painel-carregando` sair.

**Medido depois:** os oito arquivos, 762 de 762 em 4 rodadas a 50 ms e 4 a 10 ms (e 3 e 3 antes, com o vermelho de 1 em 3 já
dito); a suíte inteira, 4.744 de 4.744 a 50 ms e a 10 ms com `--maxWorkers=2`, e sem o amplificador com `--maxWorkers=4`.
Mutantes, cada espera arrancada sozinha e devolvida pelo sha1, a 50 ms: a do `abrirAComanda` (4 do Console vermelhos), a dos
ajudantes do Clientes, a da linha que volta a abrir no Pacotes e no Catálogo, o `findBy` do Pesquisas, a do título nas pílulas
e a do `.painel-carregando` no Produtos -- vermelhos; a do `abrirReceber` do Devedores ficou verde (as esperas de dentro dos
casos já a cobrem: fica como sinal para o gesto seguinte, e não carrega o verde sozinha). Nenhuma tela mudou por este débito.
CORRIGIDO.

**A verificação em três lentes (08/11/2026) pediu a prova caso a caso** das esperas que os mutantes de cima não arrancaram.
A correção as arrancou uma de cada vez (os mutantes X, devolvidos pelo sha1), cada arquivo rodado até 6 vezes a 50 ms e 6 a
10 ms com `--maxWorkers=2`, parando no primeiro vermelho. **Seguram o verde** (vermelhas na 1ª rodada a 50 ms): o `findByRole`
do "finalizar comanda" no `finalizarSemCaixa` do Console (X1, os 2 casos do caixa); a espera da linha que volta a abrir no
Convênios (X2b; o par inteiro, X2, também); no Devedores, as esperas do foco no aviso do recebimento (X3b), no "receber" da
recusa sem caixa (X3c) e no aviso do "já estava quitado" (X3d); as duas do Acl (X5, X6), a do Unidades.escrita (X7) e a do
WhatsApp (X8). **Não seguram** (verdes em 12 rodadas, 6 a 10 ms e 6 a 50 ms): a espera do pedido da releitura no Convênios
(X2a; a da linha basta, e a do pedido fica para a da linha não passar antes de a releitura começar), a do foco no "voltar" do
Devedores (X3a; e junto com a do `abrirReceber`, o X3e, também verde) e a do "abrindo" no caso da comanda encerrada do Comandas
(X4; a queda de antes foi 1 em 3 a 10 ms e não se repetiu em 12). Essas quatro ficam como sinal, e o comentário de cada uma no
teste diz o que o mutante mediu -- nenhuma diz mais que isso. CORRIGIDO segue: os casos que caíam têm, cada um, uma espera
falsificada que os segura.

### D472 — O "tentar de novo" de dentro dos cartões aparece também no 403, onde repetir dá a mesma recusa — REGISTRADO em 11/10/2026 — CORRIGIDO em 08/11/2026

**Achado ao consertar o D322** (o item D322-CONSERTOS, 11/10/2026), e não procurando por ele. O D322 listava o "tentar de
novo" no 403 do caixa e da lista de clientes; o conserto o tirou dali e, pelo grep, dos dez irmãos com a mesma peça no
erro da tela inteira (o padrão do Profissional Parceiro e do Histórico do parceiro, que já não o ofereciam). Os de
DENTRO de um cartão ou de um formulário ficaram, porque não estavam nas listas e cada um pede o seu caso:

| tela | onde | o erro |
|---|---|---|
| `AgendaLigada.tsx` | a recarga que falha com a grade na tela (o aviso "A agenda não foi atualizada") e o dia novo que não veio | `desatualizada`, `pedido.erro` |
| `Catalogo.tsx` | o que o formulário precisa e não veio, na edição e no cadastro novo | `edicao.erro`, `novo.erro` |
| `Clientes.tsx` | os catálogos do formulário, na edição e no cadastro novo | `edicao.erro`, `novo.erro` |
| `Convenios.tsx` | as unidades do "onde vale" | `unidades.erro` |
| `Cupons.tsx` | as unidades e os produtos da campanha; a lista dos cupons emitidos | `unidades.erro`, `produtos.erro`, `visao.lista.erro` |
| `FidelidadeDoCliente.tsx` | o crédito e os pontos; os pacotes; o histórico dos pontos | `saldos.erro`, `pacotes.erro`, `lista.erro` |
| `Painel.tsx` | os clientes do mês | `carga.erro` |
| `Usuarios.tsx` | os privilégios do usuário aberto | `privilegios.erro` |

São quinze botões. **O conserto é o mesmo**, e não pede decisão: `{erro.semPermissao ? null : <botão>}`, com um caso
por botão que dubla o 403 naquele pedido. A lista de cupons emitidos é a mais próxima do que o D322 consertou (é a
segunda lista da tela de cupons). **Não medido:** quantos desses pedidos podem mesmo responder 403 a quem já abriu a
tela -- em vários, o privilégio que abre a tela é o mesmo do pedido.

**CORRIGIDO em 08/11/2026 (a fatia 124, TELAS-NOVAS-ACABAMENTO-E-TESTES).** Conferido antes pelo grep: os quinze seguiam. O
conserto é o do corpo, `{erro.semPermissao ? null : <botão>}`, em cada um: a recarga que falha e o dia que não veio
(`AgendaLigada.tsx`); as áreas do formulário na edição e no cadastro novo do Catálogo e do Clientes (só o "tentar de novo" sai, o
"desistir" fica); as unidades do "onde vale", que desde a fatia do D322 moram na peça comum `TodasOuAsMarcadas.tsx` -- o conserto
vale também para as Mensagens e para a VIP Class, que a usam; as unidades e os produtos da campanha e a lista dos cupons emitidos
(`Cupons.tsx`); o crédito e os pontos, os pacotes e o histórico (`FidelidadeDoCliente.tsx`); os clientes do mês (`Painel.tsx`);
os privilégios do usuário aberto (`Usuarios.tsx`). Um caso por botão, cada um dublando o 403 naquele pedido, nos
`*.teste.tsx` das telas (Agenda 2, Catálogo 2, Clientes 2, Convênios 1, Cupons 3, Fidelidade 3, Painel 1, Usuários 1). **Dois
casos que já existiam mudaram:** os "os catálogos que falham dizem o motivo ... e tentar de novo pede outra vez" do Clientes
(edição e cadastro novo) dublavam o 403 e clicavam no "tentar de novo"; passam a dublar o 500, e o 403 ganhou o caso dele. É a
resposta ao "não medido" do corpo: o caso que existia tratava o 403 como coisa que se tenta de novo. Mutantes A1 a A15, a guarda
de cada botão trocada por `false`, um de cada vez e devolvidos pelo sha1: os quinze vermelhos, cada um no seu caso. Não visto num
navegador. CORRIGIDO.

**A verificação em três lentes (08/11/2026) achou os irmãos que a tabela de 11/10 não tinha**: o título é geral, e a fatia
guardou só os quinze listados. Derivado do disco (todo texto de botão "tentar ... de novo" em `src/telas/*.tsx`, sem os
comentários), havia mais quinze sem guarda, de telas que nasceram depois do débito: na `CadastroDaNotificacao.tsx`, as opções
da campanha nova, as da unidade escolhida em "Quem recebe" e as da edição (o "desistir" fica); na `Notificacoes.tsx`, os
horários da hora nova; no `Nps.tsx`, os 12 meses e a lista dos clientes; a lista da `Carteira.tsx` e a dos `Descontos.tsx`; os
clientes novos do `ProgramaDeIndicacao.tsx` e do `Atendimentos.tsx`; os clientes e a agenda de cada bloco do
`HistoricoDoParceiro.tsx`; e os clientes, a agenda e o melhor da rede do `ProfissionalParceiro.tsx`. O conserto é o mesmo. Um
caso por botão, cada um dublando o 403 naquele pedido (e, nos de componente, o 500 do mesmo lugar com o botão, para o caso olhar
o lugar certo): 15 casos, nos `*.teste.tsx` de cada tela. Mutantes B1 a B15, a guarda de cada um trocada por `false`: os quinze
vermelhos, cada um no seu caso. **Para a próxima tela não esquecer**, o caso novo `tentarDeNovoNo403.teste.ts` deriva os botões
do disco e pede a guarda acima de cada um (`semPermissao`, ou o predicado que já exclui o 403: o `seTentaDeNovo` do Usuários e o
`tentar` do Histórico); a única exceção, com o porquê, é a redefinição de senha (o estado sem resposta do servidor, antes do
login). Ele lê sem os comentários, e tem um piso de 60 botões contra a expressão que deixasse de achar. Mutantes G1 a G5 (uma
guarda do NPS trocada por `false` e por um comentário que diz `semPermissao`; o `tentar` do Histórico, a recarga da Agenda e o
`seTentaDeNovo` do Usuários trocados por verdadeiro): os cinco vermelhos no caso derivado, cada um apontando o arquivo e a linha.
Segue de pé o "não medido" do corpo: quantos desses pedidos podem mesmo responder 403 a quem já abriu a tela. Não visto num
navegador. CORRIGIDO.

### D473 — As colunas do cupom do dia moram fora do Doctrine, porque a cópia local não as tem — REGISTRADO em 11/10/2026 — ATUALIZADO em 12/10/2026: a cópia local as tem, e a pergunta fica pela base do teste do BVIP — ATUALIZADO em 12/10/2026: e a do pacote recusado no item (D453)

**Achado ao consertar o D271.** `cupons_clientes.desconto` e `cupons_clientes.produto_oferecido` estão no repositório (o
`mysql.sql`, o `schema.sql`, os bancos da suíte) e chegam à produção pelo passo 0 do corte; a cópia local só as ganha com
a resposta da pergunta 83. Mapeadas na entidade `CupomCliente`, toda leitura de cupom falharia na cópia. Então ficaram
fora do mapeamento: o código as lê e as grava pela conexão (`CupomClienteRepository`, `CupomRepository::resumoDosEmitidos`)
e pergunta antes se elas existem (`ISBase\Doctrine\ColunaNoBanco`). É o molde para a próxima coluna nova numa tabela que o
ERP inteiro lê, enquanto as cópias dependerem de autorização -- e ele tem custo:

- **o `schema-tool` passa a propor apagá-las** em todo banco que as tenha (a suíte, e a produção depois do corte): coluna
  no banco sem mapeamento é, para ele, coluna a remover. O `conferir-schema-tool.sh` mede a `franquiabv_dev`, que não as
  tem, e por isso segue em 4; quando a cópia as ganhar sem que sejam mapeadas, ele sobe a 6, e o roteiro manda descobrir
  por que antes de mexer na linha de base;
- **uma consulta ao `information_schema` por pedido** que toca o cupom (a resposta fica guardada no processo);
- **duas leituras do mesmo cupom**: a da DQL e a das colunas, juntadas no PHP;
- **os guardas só se provam fingindo a ausência** (a verificação, 11/10/2026). Os três bancos que rodam alguma coisa (a
  suíte nos dois bancos e o do contrato) têm as colunas, e arrancar um dos três guardas -- o de `guardarODoDia`, o de
  `oQueGuardaram` e o de `resumoDosEmitidos` -- deixava tudo verde, enquanto na cópia a venda daria "Unknown column" (no
  PostgreSQL, a transação inteira abortada). O `CupomSemAsColunasTest` responde "não tem" à pergunta de propósito -- a
  resposta guardada da `ColunaNoBanco` é trocada pela reflexão, sem gancho no código de produção -- e enche as colunas
  com valores diferentes dos da campanha: com os guardas, nada as grava, nada as lê e a conta não as conta; sem um deles,
  o caso dele fica vermelho. A ausência de verdade não cabe na suíte (no PostgreSQL o `bvip_app` não é dono da tabela, e
  o `ALTER` é recusado; no MySQL, ele fecha a transação do caso), e o que a pergunta responde num banco sem a coluna é
  provado com a coluna e a tabela que não existem em banco nenhum. O recurso da campanha diz `guardaODoDia`, e a tela
  nova escreve o que vale em cada banco.

**O conserto**, técnico e sem decisão nova, depois que a 83 vier e a migração rodar nas duas cópias: mapear as duas
colunas em `CupomCliente` (o produto como relação), levar as leituras para a DQL (o do cupom, ou o da campanha), e tirar
a `ColunaNoBanco`, o pulo dos casos (`tests/Caracterizacao/ColunasDoCupomDoDia.php`), o `CupomSemAsColunasTest` e o
`guardaODoDia` do recurso. Até lá, nada a fazer.

#### A cópia local tem as colunas desde 12/10/2026, e a pergunta fica (item ESQUEMA-NAS-COPIAS)

A resposta 83 do dono (a opção (a)) levou as duas colunas aos dois bancos da cópia local (o **D271**). O conserto escrito
acima -- mapear as colunas e tirar a `ColunaNoBanco` "depois que a 83 vier e a migração rodar nas duas cópias" -- **ainda
não vale**: a cópia local não era o único banco sem elas. **A base do teste do BVIP** veio desta cópia antes das colunas,
e a mesma resposta diz que ela as recebe na próxima cópia exportada, pela mão do dono. Mapeadas hoje, toda leitura de
cupom no servidor de teste daria "Unknown column" (no PostgreSQL, a venda inteira). Então a pergunta ao
`information_schema`, os três guardas, o `CupomSemAsColunasTest` e o pulo dos casos (`ColunasDoCupomDoDia`) ficam: eles
protegem a base do teste do BVIP até a próxima exportação, e qualquer servidor subido com `ACEITAR_ESQUEMA=sim` sem a
migração. **O conserto acima passa a esperar essa exportação**, e não mais a 83; os comentários da `ColunaNoBanco`, do
`CupomClienteRepository`, do `CupomJson` e dos dois casos dizem isso desde este dia, e os do front
(`Cupons.tsx`, `api/cupons.ts` e `campanhaDoCupom.ts`) desde a verificação do mesmo dia. E desde a
verificação os dois portões que conferem o mapeamento contra o banco exigem as duas colunas, que antes não viam
(o **D475**).

**O custo que este registro previa, medido:** o `conferir-schema-tool.sh` passou a contar as duas entre as colunas que o
`--force` apagaria na cópia -- e contou uma: ele lia só o primeiro `DROP` de cada `ALTER`, e o `schema-tool` junta as duas
num `ALTER` só (o **D474**, corrigido junto). Com a leitura de toda cláusula, 6 contra a linha de base de 4; a linha de
base foi a 6, com o motivo no roteiro, e volta a 4 quando as colunas forem mapeadas. Dos bancos que rodam alguma coisa,
agora quatro têm as colunas -- a suíte nos dois bancos, o do contrato e a cópia, nos dois --, e nenhum prova os guardas
sem fingir a ausência: o `CupomSemAsColunasTest` segue sendo o caso que os prova. Na cópia, a pergunta responde que sim
(o `guardaODoDia` dá `true` contra a `franquiabv_dev` do MySQL, medido em processo; no PostgreSQL, a consulta dela conta
2).

#### A terceira coluna no mesmo molde, em 12/10/2026 (o item PDV-QUITAR-E-PACOTE)

`vendas_produtos.pacote_recusado`, o "Usar pacote contratado: Não" do PDV antigo gravado no item (o **D453**, a resposta
80 do dono), mora fora do Doctrine pelo mesmo motivo: a entidade `VendaProduto` é lida por quase todo o ERP, e mapeada
ela pediria a coluna em toda leitura de item de venda -- na base do teste do BVIP, que só a ganha na próxima cópia
exportada, o PDV inteiro cairia. A cópia local a tem desde o dia em que ela nasceu (a regra da resposta 83). Quem a usa
passa por `VendaProdutoRepository` (`guardaARecusaDoPacote`, `gravarARecusaDoPacote` e `recusaramOPacote`), e o
`D453SessaoPassaAoQueFicouPeloPrecoTest` prova os dois guardas fingindo a ausência, no molde do `CupomSemAsColunasTest`.
O custo é o escrito acima: o `conferir-schema-tool.sh` foi de 6 a 7 (volta a 6 quando ela for mapeada), e uma consulta
ao `information_schema` por processo e banco. Os dois portões de esquema passaram a exigi-la sem mudança neles: a
lista sai das chamadas do código (o **D475**), e o `ColunasForaDoMapeamentoTest` ganhou a terceira linha. **O conserto**
é o mesmo, para as três, depois da exportação: mapeá-la, ler os recusados pela DQL e tirar a pergunta e os casos que
fingem a ausência. Na verificação do item, o título ganhou a marca da terceira coluna, o cabeçalho de
`ISBase\Doctrine\ColunaNoBanco` passou a dizer as três (e quem pergunta por cada uma), e o `conferir-schema-tool.sh`
passou a dizer quanto cada uma desce da linha de base -- com as três mapeadas, a contagem volta a 4.


### D474 — O portão do `schema-tool` contava uma coluna apagada por `ALTER`, e o `schema-tool` junta as da tabela — REGISTRADO em 12/10/2026 — CORRIGIDO em 12/10/2026

**Achado ao levar as colunas do cupom do dia à cópia local** (o **D271**, a resposta 83 do dono). O D473 previa que o
`scripts/conferir-schema-tool.sh` subiria de 4 para 6 quando a cópia ganhasse as duas colunas sem mapeamento. Ele subiu a
**5**, e listou só `cupons_clientes.produto_oferecido`. O `--dump-sql` propõe as duas quedas num comando só -- `ALTER
TABLE cupons_clientes DROP produto_oferecido, DROP desconto, CHANGE cupom ...` --, e o roteiro procurava
`ALTER TABLE <tabela> DROP <coluna>` com o `grep -o`: o segundo `DROP` não tem o `ALTER TABLE` colado, e não contava. Até
aqui as quatro quedas eram uma por tabela (`unidades DROP iframe_bi, CHANGE ...` e as três de sempre), e o defeito não
aparecia; com ele, uma tabela inteira desmapeada contaria como uma coluna. O portão existe para dizer o que um `--force`
destruiria, e contava menos do que ele destruiria.

**O conserto.** O roteiro lê cada linha `ALTER TABLE <tabela> ...`, parte as cláusulas pela vírgula e conta toda cláusula
que começa por `DROP` seguido de nome minúsculo -- o critério de antes (`DROP FOREIGN KEY`, `DROP INDEX` e `DROP PRIMARY
KEY` vêm em maiúsculas e não perdem dado). A vírgula de dentro de um tipo (`NUMERIC(10, 2)`) parte uma cláusula `CHANGE`,
e nenhum dos pedaços começa por `DROP`. A linha de base foi de 4 a 6, com o motivo escrito no roteiro: as duas do cupom do
dia, de propósito fora do mapeamento até a base do teste do BVIP as ter (o **D473**).

**Provado**, contra a cópia com as colunas: o roteiro sai 0 com 6 ("Igual a linha de base"), as seis com nome --
`cupons_clientes.desconto`, `cupons_clientes.produto_oferecido`, `unidades.iframe_bi`, `usuarios.senha_php83`,
`usuarios_acessos.local`, `vendas.desconto_motivo`; uma segunda régua, independente (uma expressão regular em Python sobre
o mesmo `--dump-sql`, que procura `DROP` no começo do comando ou depois de vírgula, menos `FOREIGN`, `INDEX`, `PRIMARY`,
`CONSTRAINT` e `CHECK`), conta as mesmas seis. **Mutantes**, cada um numa cópia do roteiro fora do repositório, rodada da
raiz: a leitura de antes (o `grep -o` do `ALTER TABLE ... DROP`) dá 5 e sai **1** ("SAO MENOS QUE A LINHA DE BASE"); a
linha de base de volta a 4 dá 6 e sai **1** ("APARECEU QUEDA DE COLUNA NOVA").

**O que não foi medido:** o roteiro contra os bancos da suíte, que também têm as colunas sem mapeamento -- ele lê o banco
do `local.php`, e trocá-lo à mão é o que os executores da suíte fazem; e o caso da coluna com letra maiúscula no nome, que
o critério, o de antes e o de agora, não conta (não há nenhuma no `mysql.sql`).


### D475 — Os portões de esquema não exigiam o que o código lê pela `ColunaNoBanco` — REGISTRADO em 12/10/2026 — CORRIGIDO em 12/10/2026

**Achado na verificação do item ESQUEMA-NAS-COPIAS** (o **D271**, a resposta 83 do dono). As duas colunas do cupom do
dia ficam fora do mapeamento de propósito (o **D473**), e o código pergunta ao banco se elas existem; sem elas, o cupom
segue a campanha, **calado**. Os dois portões que conferem o mapeamento contra o banco --
`scripts/verificar-schema-postgres.php` (o PostgreSQL; no CI e à mão contra a cópia) e
`scripts/conferir-tipos-do-mapeamento.php` (o banco do `local.php`; no CI e no `conferir-tudo.sh`) -- leem o mapeamento,
e não as viam. **Medido:** num banco descartável clonado da `franquiabv_pgtestes`, sem `cupons_clientes.desconto`, o
`verificar-schema-postgres.php` de antes saía **0**. No MySQL, só o `conferir-schema-tool.sh` acusava (a conta cai a 4,
"SAO MENOS"), e ele roda só na mão do operador. A cópia refeita sem as colunas, ou que as perdesse, ficava com o cupom
seguindo a campanha e os portões verdes.

**O conserto.** O `scripts/tabelas-fora-da-copia.php`, a leitura que os dois portões já dividiam, ganhou
`colunasLidasForaDoMapeamento` e `conferirColunasForaDoMapeamento`. A lista **sai do código**, e não é digitada: toda
chamada a `ColunaNoBanco::existe(<conexão>, '<tabela>', '<coluna>')` em `module/`, lida sem os comentários (pelo
`token_get_all`; o docblock da própria `ColunaNoBanco` cita a chamada). A tolerância é a das tabelas: só a coluna que uma
migração com "NAO rode na copia local" no cabeçalho acrescenta (`ALTER TABLE ... ADD COLUMN`, no código dela) falta de
propósito, e é dita. Os dois portões saem com **1** quando uma falta, com a coluna e o arquivo que a lê, e com **2**
quando não conseguem ler -- uma chamada sem tabela e coluna literais, ou a `ColunaNoBanco` no repositório sem chamada
lida. A leitura das migrações foi para `migracoesForaDaCopia`, e a `tabelasForaDaCopia` devolve o mesmo que antes
(conferido contra a de antes num repositório de mentira, com a frase inteira, quebrada e fora do lugar).

**Provado:** contra a cópia, os dois saem 0 e dizem "lidas fora do mapeamento: 2 (cupons_clientes.desconto,
cupons_clientes.produto_oferecido)". **Mutantes**, um de cada vez: um arquivo temporário em `module/` que pergunta por
uma coluna que não existe -- os dois saem **1**; a mesma chamada com a tabela numa variável -- os dois saem **2**; a
chamada só em comentário -- os dois saem 0, com as duas de sempre; e no banco descartável sem `cupons_clientes.desconto`
(apagado no fim) o `verificar-schema-postgres.php` novo sai **1**, e o de antes, **0**. O caso novo
`tests/Caracterizacao/ColunasForaDoMapeamentoTest.php` (9 casos, nos dois bancos) prova a leitura: o código de hoje lê as
duas e o banco da suíte as tem; sem a coluna, a conferência acusa; o comentário não conta; a chamada sem literais e a
classe sem chamada não se medem; e a tolerância vale pela frase no cabeçalho, inteira ou quebrada, e não fora dele nem
pela migração de outra coluna. Com o `tabelas-fora-da-copia.php` de antes, os 9 caem; e com cada um de quatro mutantes
do novo -- lendo o comentário, sem a tolerância, sem o "classe sem chamada", sem o "chamada sem literais" --, cai o caso
daquela regra.

**O que ele não cobre:** a ligação nos dois portões está provada pelos mutantes, e não por um caso da suíte; a coluna
lida fora do mapeamento por outro caminho que não a `ColunaNoBanco` continua invisível a eles; e cada um mede só o banco
que lhe dão -- a cópia, à mão e no `conferir-tudo.sh`; os bancos do CI, lá.

### D476 — O "Meus dados" grava o nome do POST cru, e o filtro que tira as marcas não vale ali — REGISTRADO em 12/10/2026 — CORRIGIDO em 07/11/2026: o "Meus dados" grava o `getData()` (a fatia 117)

**Achado em 12/10/2026, na verificação do SESSAO-E-ROTAS (o D76), lido e não executado.**
`UsuariosController::meusDadosPerfilAction` -- a tela em que cada pessoa logada muda o próprio nome e telefone -- confere
o formulário (`$form->isValid()`, com o `MeusDadosPerfilFilter`, que passa o nome pelo `StripTags`) e grava o POST cru:
`updateMeusDadosPerfil($request->getPost()->toArray(), $usuario)`, e não o `$form->getData()`. O `StripTags` filtra uma
cópia que ninguém usa, e o nome vai ao banco com as marcas que a pessoa escreveu. O cadastro e a edição de usuários pelo
ERP gravam o filtrado (`UsuarioService::gravarDados`, pelo `UsuarioDadosFilter`).

**O que isso alcançava, e o que fechou:** qualquer uma das pessoas com sessão alcança o "Meus dados" (é o próprio
usuário da sessão, `find($this->usuarioId)`), e o nome dela saía cru na lista de acessos da unidade
(`UnidadesController::localizarAcessosAction`, HTML montado no controller e posto na página pelo `.html()`), que o
administrador abre. Essa saída passou a escapar o nome em 12/10/2026 (o D76, a verificação, com caso e mutante). As
outras telas que imprimem o nome da pessoa são a família do D203 (o que falta nele é a forma do getter).

**O conserto, sem decisão:** gravar o `$form->getData()` -- o nome e o telefone filtrados, como o cadastro --, com um caso
em processo (o nome com `<b>` sai do "Meus dados" sem a marca). Não feito aqui: está fora das respostas do item, e
muda a escrita de uma tela que o item não tocava.

**Corrigido em 07/11/2026 (a fatia 117 da fila, USUARIOS-FICHA-ANTIGA).** O `meusDadosPerfilAction` grava o
`$form->getData()`, e não mais o POST cru: o nome sai sem as marcas (`StripTags`) e sem as pontas, e o telefone, que ganhou no
`MeusDadosPerfilFilter` uma entrada opcional com `StripTags` e `StringTrim`, também -- o molde do cadastro. No `franquiabv_dev`
(só contagem), nenhum nome guarda `<`. Caso: `UsuarioDadosPelaApiTest::testOMeusDadosGravaOQueOFormularioFiltrou` (o nome
`<b>Colaborador</b> Teste` sai "Colaborador Teste", e o telefone sem a marca), nos dois bancos. Mutante: o POST cru de volta,
vermelho nos dois.


### D477 — O desconto digitado maior que o item depois do convênio, ou no item da sessão do pacote, segue ignorado calado nas duas portas — REGISTRADO em 12/10/2026; a pergunta foi ao lote — CORRIGIDO em 13/10/2026 (a resposta 165) — PARCIAL em 13/10/2026 (o motivo foi ao lote) — CORRIGIDO em 14/10/2026 (a resposta 177) — CORRIGIDO em 16/10/2026 (a resposta 187: o motivo do cupom no item do convênio)

**Achado ao consertar o D212 no PDV antigo** (12/10/2026, o item PDV-E-CADASTRO), lido no código. A regra das duas portas
(`RegraDoItemDaComanda::camposDoDesconto`, que a API e, desde a resposta 91, o PDV antigo chamam) compara o desconto em
valor com o item pelo preço de tabela -- valor unitário vezes quantidade. O serviço tira o desconto digitado do que
SOBROU do item (`VendaProdutoService::calcularLinha`: primeiro a sessão do pacote ou o convênio, depois o desconto do
PDV, e só se ele couber no que sobrou):

- com o convênio de 20%, o corte de 89,90 fica em 71,92, e um desconto de 80,00 passa pela regra (não é maior que 89,90)
  e é ignorado calado -- o item entra a 71,92, e a tela diz "Adicionado" (o ignorar está preso, pelo serviço e sem o
  convênio, em `ConvenioDescontoTest::testDescontoMaiorQueOTotalEIgnoradoSilenciosamente`);
- no item que vira sessão do pacote (valor zero), o desconto em valor é ignorado calado (o em porcentagem é a
  porcentagem de zero).

Vale nas duas portas, e era assim antes da 91 (a API desde 23/09/2026). **Na cópia não dá para contar:** o desconto
ignorado não deixa rastro -- o item grava o valor sem ele. Recusar, cortar no valor do item ou deixar muda o que o balcão
vê ou o que o cliente paga: foi ao lote (PDV-E-CADASTRO, o D477), com a recomendação de recusar comparando com o que o
item vale depois do convênio e da sessão, com o valor na frase.

**Decidido pelo dono em 13/10/2026 (a resposta 165, opção "b", e NÃO a recomendada "a"):** *"Aplicar o desconto até o valor
do item -- o desconto digitado maior do que o que sobrou do item depois do convênio, ou no item que virou sessão do
pacote, zera o item, e o motivo fica. Nas duas portas (o PDV antigo e a API da tela nova), pelo serviço que aplica o
desconto."* Pelo pedido do dono: *"aguarde terminar e continue de forma autonoma a resolver todos os debitos que  tiver
resposta"*.

#### O conserto (13/10/2026, o item RESPOSTAS-162-A-167)

- **O serviço** (`VendaProdutoService::calcularLinha`, o lançamento e o ajuste): o desconto do PDV é cortado no que sobrou
  do item (`min` do desconto e do valor da linha depois do convênio ou da sessão), e a guarda que o descartava inteiro
  (`if ($descontoPdv <= $valorTotal)`) saiu. Com o convênio de 20%, o corte de 89,90 fica em 71,92, e os 80,00 do PDV o
  zeram: o desconto da linha fica em 89,90 (os 17,98 do convênio e os 71,92 que sobraram), e a comissão, que é sobre o
  valor da linha, vai a zero junto, como em todo item de cortesia inteira (lido no `calcularComissao`; os casos não a
  medem, porque o colaborador das fixtures não tem comissão). Até esta data o item entrava a 71,92, e a tela
  dizia "Adicionado".
- **As portas** não mudaram: o PDV antigo (`VendasProdutosController::adicionarAction`) e a API
  (`Adm\ComandasController::adicionarItemAction` e `alterarItemAction`) chegam ao serviço, e a regra das duas
  (`RegraDoItemDaComanda`) continua recusando o maior que o preço de tabela -- o que passa por ela e não cabe no que
  sobrou é o que zera.
- **O motivo que fica é o que o item já gravava, e não o digitado:** no item do convênio, o nome do convênio, como em todo
  desconto do PDV nele (o D214; a resposta 90 do dono, "b", de 12/10/2026, manteve o motivo digitado sumindo); no item da
  sessão, o nome do pacote. Ler assim o "e o motivo fica" da 165 foi uma escolha, e não do dono: a verificação a mandou ao
  lote (abaixo).
- **O item que vira sessão do pacote** já vale zero: o desconto digitado não tem o que zerar, e o item fica como ficava --
  zero, sem desconto a mais, com o nome do pacote no motivo. O desconto em porcentagem nunca passa do que sobrou (até
  100% dele), e não muda.

**Na cópia não dá para contar** quantas vezes o desconto foi ignorado: ele não deixava rastro.

**Provado** nos dois bancos, em série: `D477DescontoAteOValorDoItemTest` (novo, 8 casos, despachados em processo) -- pelo
PDV antigo, pela API e pelo ajuste da API, o corte com o convênio e os 80,00 zera (o item e a venda a 0,00, o desconto a
89,90, o motivo do convênio); o desconto que cabe soma ao do convênio (61,92, como sempre); o maior que o preço de tabela
continua recusado nas duas portas, sem gravar nada; e o item da sessão fica zero, sem desconto a mais, nas duas portas. E
o `ConvenioDescontoTest`: o caso que prendia o ignorar (`testDescontoMaiorQueOTotalEIgnoradoSilenciosamente`) virou o que
zera (`testDescontoMaiorQueOTotalZeraOItem`, pelo serviço), e um novo, com o convênio. Os vizinhos do item (o D212, o
D435, o D450, o D452, o D453, o ajuste, a comanda, os cupons) seguem verdes nos dois bancos.

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-r30.py`, no rascunho do item) | vermelho, no MySQL e no PostgreSQL |
|---|---|
| a guarda de antes (o desconto que não cabe é ignorado) | 5: os dois do `ConvenioDescontoTest`; o PDV antigo, a API e o ajuste com o convênio |
| o corte no preço de tabela, e não no que sobrou | 6: o do convênio no `ConvenioDescontoTest`; o PDV antigo, a API e o ajuste com o convênio; e o item da sessão nas duas portas (ele iria a -10,00) |

**Não provado:** nada foi aberto num navegador. A prévia da tela nova (`LancarItem.tsx`, `DescontoDaLinha.tsx`) conta o
desconto sobre o preço de tabela, sem o convênio -- como antes --, e o item aparece como o servidor o devolve.

#### PARCIAL em 13/10/2026 -- a verificação do item RESPOSTAS-162-A-167: qual motivo fica no item zerado

A opção que o dono escolheu na 165 diz que o item zera *"e o motivo fica"*. Nos dois casos em que ela muda alguma coisa,
o motivo digitado não fica: no item do convênio, o ramo do convênio o troca pelo nome dele antes do desconto do PDV
(`calcularLinha`), e no da sessão `comoSessao` o troca pelo nome do pacote. O conserto leu "o motivo" como "o que o item
já gravava" sem perguntar. A leitura tem apoio numa resposta -- a **90 (b)**, de 12/10/2026, conferida na coleção
`respostas` da página do lote: *"o motivo digitado continua sumindo"* no cliente de convênio (o D214, o D363 e o D277;
este registro ainda não a anotou neles) --, mas a 165 veio depois e diz o contrário, e a 90 não mostrava o que a 165
abriu:

- **a cortesia inteira vira convênio de 100%:** o corte de 89,90 com o convênio de 20% e 80,00 do balcão fica a 0,00, com
  89,90 de desconto e o motivo "Convênio X" -- na comanda e no relatório de descontos;
- **e a tela nova a desfaz sem aviso:** ela reconhece a linha do convênio pelo prefixo do motivo
  (`ajusteDaLinha.ts::descontoDoConvenio`), e o "+" e o "−" mandam só a quantidade (`ajusteDeQuantidade`, preso em
  `ComandasLigada.teste.tsx`); o ajuste recalcula só o convênio, e o item zerado volta a 143,84 com duas unidades. O
  mecanismo é o do D214 e já existia -- o desconto do balcão que cabe também some no "+" (17,98 + 10,00: os 10,00 somem)
  --; a 165 o leva até a cortesia inteira.

**Caracterizado, sem decidir:** `D477DescontoAteOValorDoItemTest::testOAjusteSoDaQuantidadeNoItemZeradoPeloConvenioVoltaAoPrecoComOConvenio`
(o nono caso), verde no MySQL e no PostgreSQL: o item zerado pela API, ajustado só na quantidade, volta a 143,84, com
35,96 de desconto e o motivo do convênio. Os casos do motivo de antes (o do convênio e o do pacote) seguem presos como
estão. **Foi ao lote** (RESPOSTAS-162-A-167, a verificação, o D477): (a) como está; (b) a tela nova recusa o "+" e o "−"
na linha do convênio a 0,00, sem mudar o que se grava; (c) o motivo leva os dois -- "Convênio X; <o digitado>", cortado
em 100 -- só quando o balcão dá desconto no item do convênio, e a tela nova recusa o "+" e o "−" nessa linha. A
recomendação é a (c). **Resta**, por isso, só o motivo do item zerado e o "+" da tela nova; o desconto que zera o item
está feito, e nada mais mudou no código (os comentários do serviço, da API da comanda e dos testes passaram a dizer o
que ele faz).

#### CORRIGIDO em 14/10/2026 -- a resposta 177 (o item CADASTRO-E-CONVENIO)

**Decidido pelo dono em 14/10/2026 (a resposta 177, opção "c", a recomendada):** *"O motivo leva os dois, só quando o
balcão dá desconto no item do convênio -- 'Convênio X; <o motivo digitado>', e a tela nova recusa o '+' e o '−' nessa
linha."* A opção do lote dizia também *"cortado em 100 caracteres"* e a frase da recusa, a da (b). Pelo pedido do dono:
*"continue de forma autonoma a resolver todos os debitos que  tiver resposta"*.

- **O serviço** (`VendaProdutoService::calcularLinha`, o lançamento e o ajuste, por onde passam as duas portas -- o PDV
  antigo e a API da tela nova): no item em que o convênio deu desconto, o desconto do balcão grava o motivo
  `RegraDoItemDaComanda::motivoDoConvenioComOBalcao` -- "Convênio X; <o motivo digitado, aparado>", cortado em 100 (a
  coluna). Vale **sempre que o balcão dá desconto no item do convênio**, no que zera (a 165) e no que cabe (17,98 +
  10,00, o motivo "Convênio Convenio Teste; Cliente da casa"), como a opção diz ("só quando o balcão dá desconto no item
  do convênio") e a recomendação explicava ("em toda linha do convênio com desconto do balcão, e não só na zerada").
  Sem desconto do balcão, o motivo segue o do convênio (a 90). Os valores, a comissão e o total não mudaram.
- **Só com desconto de fato:** no convênio que já zerou o item sozinho (o de 100%; há um dos 638 da cópia), o desconto do
  balcão não tira nada, e o motivo fica o do convênio -- o digitado não explicaria desconto nenhum.
- **O motivo do cupom não entra** (a marca "CUPOM VIP:"): com ela no motivo, o fechamento passaria a gastar o cupom do
  cliente de convênio e a linha deixaria de se ajustar -- o **D277** (o convênio apaga a marca, e o cupom não queima), que
  a resposta 90 deixou como estava e a 177 não citou. **Foi ao lote** (CADASTRO-E-CONVENIO); até lá, a linha do cupom
  fica com o motivo do convênio, como antes.
- **A comanda marca a linha** (`ComandaJson`, o booleano `descontoDoBalcaoNoConvenio`, pelo motivo, com
  `RegraDoItemDaComanda::temODescontoDoBalcaoNoConvenio`: o prefixo do convênio e o "; "), no molde do `cupom` do D276 --
  a tela não compara o texto. **A tela nova recusa o "+" e o "−" nela** (`ajusteDaLinha.ts::ajusteDeQuantidade`), sem
  pedido ao servidor, com a frase da (b): *"Corte tem o desconto do convênio e um desconto do balcão. Para mudar a
  quantidade, tire o item e lance de novo."* O "−" com uma unidade continua sendo "tirar", que é o que a frase manda.
  Até esta data o "+" mandava só a quantidade, e o servidor, recalculando só o convênio, devolvia o item zerado a 143,84
  com duas unidades.
- **O relatório de descontos** filtra pelo texto do motivo, e acha a linha pelo convênio e pelo motivo digitado.

**O que fica de fora, e por quê:**

- **o pedido montado à mão:** o `PUT` só com a quantidade, nessa linha, continua recalculando só o convênio (o caso
  `testOAjusteSoDaQuantidadeNoItemZeradoPeloConvenioVoltaAoPrecoComOConvenio` o descreve). A resposta pede a recusa na
  tela, e nenhuma tela manda esse pedido: a linha do convênio não tem o "desconto" (o D214), e o PDV antigo não ajusta;
- **as linhas gravadas antes de 14/10/2026** com desconto do balcão no item do convênio têm só o nome do convênio no
  motivo; desde a verificação (abaixo) vêm marcadas pelo valor, e o "+" delas recusa como o das novas;
- **o convênio de nome longo:** com o corte em 100, a coluna inteira, o nome de 89 caracteres ou mais não deixa caber o
  digitado, e o de 90 ou mais nem o "; " -- o motivo fica o do convênio, e a linha vem marcada pelo valor (a
  verificação). Na cópia (medido em 14/10/2026, só contagens): dos 638 convênios, um ativo tem 100 caracteres (de antes do
  teto de 91 do D253), sem cliente ativo nele; um ativo tem 77, com 16 clientes ativos, e deixa 12 caracteres para o
  digitado; um ativo tem 70 (19 caracteres). Cortar o nome do convênio, e não o fim, para caber o digitado é desenho, e não
  foi feito: a opção da 177 escreve o nome primeiro e o corte na coluna, e o que se perdia de fato -- o desconto do
  balcão no "+" -- fechou pelo valor;
- **o que a marca lê a mais:** o convênio cujo nome tenha "; " (nenhum dos 638 da cópia tem ";"), o convênio cuja
  porcentagem baixou depois do lançamento, e o motivo digitado que comece por "Convênio " num item em que o convênio não
  vale e tenha "; " ou passe da parte do convênio -- em todos, a tela recusa o "+" a mais, e nada se perde.

**Provado** nos dois bancos, em série: `D477DescontoAteOValorDoItemTest` (11 casos; eram 9) -- pelo PDV antigo, o item
zerado e o desconto que cabe gravam os dois motivos; o convênio de 100% e o motivo com a marca do cupom ficam com o do
convênio; pela API, o lançamento que zera vem com a linha marcada, e o ajuste que dá o desconto também; o lançamento sem
desconto do balcão fica com o do convênio, sem a marca; e o ajuste só da quantidade, montado à mão, volta ao do convênio,
sem a marca. `RegraDoItemDaComandaTest` (2 casos novos): o motivo com os dois, aparado, o do convênio sem motivo digitado,
o corte em 100 (o nome de 85 deixa "; Cort"; o de 100, nem o separador), e a marca só com o prefixo e o "; ". O
`ConvenioDescontoTest` não mudou de número (o caso do zerado chama o serviço sem motivo digitado). Os vizinhos (o
ajuste, a comanda, os cupons na comanda nova, o D212, o D435, o D450, o D452, o D453, o D316) seguem verdes. **Na tela
nova**, `ComandasLigada.teste.tsx`, um caso novo: na linha marcada, o "+" e o "−" recusam com a frase, sem pedido; o caso
da linha só do convênio ("+" manda só a quantidade) segue verde.

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-r47.py`, no rascunho do item) | vermelho, no MySQL e no PostgreSQL |
|---|---|
| sem os dois motivos (o do convênio, como antes) | 5: o PDV antigo, no zerado e no que cabe; a API, o ajuste e o ajuste só da quantidade (o cenário) |
| os dois motivos sem o "desconto de fato" | 1: o convênio que já zerou o item |
| os dois motivos também com a marca do cupom | 1: o motivo do cupom no item do convênio |
| a comanda sem a marca da linha | 2: a API no zerado e o ajuste que dá o desconto |
| a marca sem o prefixo do convênio | 1: a regra (o motivo digitado sem o prefixo, com "; ") |
| o separador mesmo sem motivo digitado | 2: a regra (o motivo em branco e o nulo) e o `ConvenioDescontoTest` do zerado |
| a tela sem a recusa (o "+" manda só a quantidade) | 1, no vitest: o caso novo da `ComandasLigada.teste.tsx` |
| a recusa depois do caso do convênio | 1, no vitest: o mesmo caso |

**Não provado:** nada foi aberto num navegador; a prévia do lançamento na tela nova (`LancarItem.tsx`) segue contando o
desconto sobre o preço de tabela, sem o convênio, como antes.

#### A verificação (14/10/2026, o mesmo item)

A verificação achou, lido no código, três linhas em que a marca pelo texto (o prefixo e o "; ") não chegava, e o "+" da
tela nova seguia tirando o desconto do balcão calado -- o defeito que a 177 mandou fechar "nessa linha":

- **o cupom no cliente de convênio:** a comanda nova põe o cupom sozinho no corte (`cuponsDisponiveis` não tira o
  convênio), e o serviço troca o motivo dele pelo do convênio (o D277). Sem a marca do cupom e sem o "; ", o "+" mandava
  `{quantidade}`, o `PUT` não dava o 409 do cupom (ele lê a marca) e os 7,19 do cupom de 10% no corte de 89,90 sumiam, com
  só "Corte agora com 2 unidades". A pergunta do lote sobre o cupom não contava isso ao dono: passou a contar;
- **o convênio de nome com 90 caracteres ou mais** (o corte come o "; ");
- **as linhas gravadas antes da resposta 177.**

**O conserto:** a marca (`RegraDoItemDaComanda::temODescontoDoBalcaoNoConvenio`) lê também o valor -- com o prefixo do
convênio no motivo, o desconto da linha maior do que a parte do convênio (a porcentagem do convênio atual do cliente sobre
o preço da linha, a conta do `calcularLinha` e a da medição do D214, com a tolerância de meio centavo da regra) tem um do
balcão. A comanda (`ComandaJson`) passa o desconto, o preço da linha e a porcentagem; o que se grava não mudou, e a tela
também não (ela lê a marca). O convênio de 100% não passa da parte dele, e a linha não é marcada. O cupom no cliente de
convênio continua sem queimar e com o motivo do convênio (o D277, no lote): só o "+" e o "−" dessa linha passam a
recusar, com a frase da 177, em vez de tirar o cupom calado.

**E o motivo digitado quando o convênio não vale na venda** não tinha caso: o nome do convênio que vai ao motivo é o do
ramo em que o convênio deu desconto, e o mutante que o tirava de dentro do `if ($convenioAtivo)` sobrevivia. Agora o
`ConvenioDescontoTest` prende o motivo digitado, sozinho, nos quatro jeitos de o convênio não valer -- inativo, ligado só a
outra unidade, desligado no dia, e o item que é produto. Os comentários da tela nova (`ajusteDaLinha.ts`,
`DetalheDaComanda.tsx`) que ainda diziam "o motivo que fica é o do convênio" foram corrigidos (só texto).

**Provado** nos dois bancos, em série: `D477DescontoAteOValorDoItemTest` (15 casos; eram 11) -- o cupom no cliente de
convênio, pela API, vem marcado, sem `cupom`; o nome de 90 caracteres, sem o "; ", vem marcado; a linha gravada antes da
177 vem marcada no `GET`; e o convênio de 100%, pela API, não. `RegraDoItemDaComandaTest` (1 caso novo, a marca pelo
valor: um centavo do balcão já marca; só o convênio, o centavo quebrado de 15%, o de 100%, sem a porcentagem e sem o
prefixo, não). `ConvenioDescontoTest` (1 caso novo, com os quatro conjuntos). O front inteiro, o build e o lint (a tela
só mudou nos comentários).

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-r47v.py`, no rascunho do item) | vermelho, no MySQL e no PostgreSQL |
|---|---|
| o nome do convênio no motivo com qualquer convênio do cliente | 4: os quatro conjuntos do convênio que não vale |
| o nome do convênio antes do `convenioAtivo` | 2: ligado só a outra unidade e desligado no dia |
| a marca só pelo motivo, sem o valor | 4: o cupom, o nome longo, a linha de antes da 177 e a regra |
| a comanda sem a porcentagem do convênio | 3: o cupom, o nome longo e a linha de antes da 177 |
| o valor sem descontar a parte do convênio | 4: a regra, a linha só do convênio (duas) e o convênio de 100% |
| a parte do convênio sobre o preço unitário, e não o da linha | 1: o ajuste só da quantidade (duas unidades, só o convênio) |

#### CORRIGIDO em 16/10/2026 -- a resposta 187: o motivo do cupom no item do convênio (a fatia 5d)

O que a correção da 177 deixou de fora e mandou ao lote -- **o motivo do cupom não entra** (a marca "CUPOM VIP:" no item do
convênio) -- foi decidido pelo dono na **187 (c)**: no serviço do cliente de convênio o cupom não soma ao convênio, e o item
leva só o maior dos dois. O cupom que vence grava "Convênio X; CUPOM VIP: <a campanha> | #N" (`RegraDoItemDaComanda::
motivoDoConvenioComOCupom`), e a linha é a do cupom, e não a do balcão: `temODescontoDoBalcaoNoConvenio` responde não com a
marca, e o "+" da tela nova não aparece (a linha do cupom não se ajusta; o `PUT` dá 409). O que perde fica de fora, e a
linha é a do convênio, sem desconto do balcão nem marca pelo valor. Até esta data o cupom somava ao convênio, e a linha
vinha marcada pelo valor (`testPelaApiOCupomNoClienteDoConvenioVemMarcadoPeloValor`, que passou a
`testPelaApiOCupomQuePerdeParaOConvenioNaoEntraNemMarcaALinha`; o do PDV antigo, a
`testOMotivoDoCupomNoItemDoConvenioNaoEODoBalcao`). A marca pelo valor segue para as linhas gravadas antes. O resto do
cupom no convênio -- se o que perde deve se gastar -- está no **D277**, no lote.


### D478 — A mensagem de WhatsApp que falhou fica na fila sem a causa nem a hora, e nada a limpa — REGISTRADO em 12/10/2026; a pergunta foi ao lote — PARCIAL em 14/10/2026: sai em 7 dias onde a de 24 h não roda; o resto foi ao lote

**Achado ao consertar o D302** (12/10/2026, o item WHATSAPP-F0-E-CENTRAL). A resposta 115 do dono, a (b), pediu que a falha
fique registrada, e ela fica sem esquema novo: a linha de `push_app` que não saiu continua lá, com a rotina trocada por
`falhou:<a rotina>` e sem a credencial (`PushApp::marcarFalha`). O que essa forma não guarda:

- **o motivo** -- a instância desconectada, o telefone vazio, o provedor fora --: não há coluna, e o texto do provedor não
  deve ser guardado cru (o W4 do desenho do WhatsApp), e a rotina da fila não escreve no registro das rotinas (o D68);
- **a hora da falha**: a `data` é a do pedido. Numa fila que roda a cada minuto, dá a hora com um ou dois minutos de
  diferença, mas não a de uma linha que esperou;
- **o fim**: nada apaga as linhas `falhou:`. Elas crescem com as falhas -- as 4 unidades fechadas com o WhatsApp ligado (o
  D351), se a instância delas não conecta, dão perto de 50 parabéns por dia (19.270 clientes com data de nascimento, Q10 do
  desenho), e todo telefone inválido conta. A rotina da fila lê só a rotina exata (`selecionarCronJob`), e as linhas
  `falhou:` não entram nela.

**O que fazer depende de para que a falha serve:** a P3 do desenho do WhatsApp, (C), quer "o último estado conhecido,
gravado pelo envio que falha" para a tela do administrador; e a F3 (a tabela de eventos que a 136a autorizou) pode
guardar a falha com o motivo tratado. Quanto tempo a linha fica foi ao lote (WHATSAPP-F0-E-CENTRAL), com a recomendação
de apagar depois de 30 dias, junto da F3.

#### PARCIAL em 14/10/2026 -- a resposta 168 (o item WHATSAPP-E-APLICATIVO)

**Decidido pelo dono em 14/10/2026** (a pergunta 168 da página do lote, a opção (c), e não a recomendada (a)): *"Apagar
depois de 7 dias."*

**O conserto:**
- **A rotina da fila apaga a falhada de mais de 7 dias** (`CronJobService::marketingPush`, por
  `PushAppRepository::apagarFalhasAntigas`; o prazo na constante `PushApp::DIAS_DA_FALHA_NA_FILA`). **Nela, e não numa
  rotina nova:** é a única que marca a falha (`PushApp::marcarFalha`), roda a cada minuto na fila `geral` e a cada
  chamada das outras duas (o parabéns e o "sentimos sua falta", pelo `marketingPushOutros`), e já tem a trava da fila --
  nenhuma rota, linha de crontab nem passo do corte novos. Cada fila apaga as falhadas dela (`falhou:<a rotina>`, pela
  mesma conta que as marca, `PushApp::rotinaDaFalha`), antes de ler as que vai mandar.
- **Só a falhada.** A pendente (a da rotina exata, que a fila ainda vai mandar) não entra **nesta limpeza**, por velha que
  seja: na cópia há 5.141 linhas na fila, todas pendentes, de 16/08 a 01/10/2026 -- o cron não roda ali --, e nenhuma
  falhada (contagem de 14/10/2026). A enviada não está na fila: sai no envio, desde sempre. **Não é a única limpeza da
  fila:** a de 24 h da produção apaga as duas (a verificação, abaixo).
- **Os 7 dias contam da `data` da linha**, a do pedido, e não da falha: não há coluna da hora da falha, e `marcarFalha`
  não a regrava. Na fila `geral`, de cada minuto, as duas quase coincidem; nas outras duas, não (a verificação, abaixo).
- **"Não receberam" não muda, e não precisou de coluna.** A resposta pedia que se conferisse de onde a conta sai antes de
  apagar: a campanha manda o WhatsApp direto (`CronJobService::envioDeCampanha`) e conta na linha dela
  (`notificacoes.total_clientes` e `total_envios`, `RegraDaCampanha::naoReceberam`); nenhuma linha da fila é da campanha.

**O que resta (por isso PARCIAL):** a causa e a hora da falha continuam sem registro -- a linha marcada não guarda o
motivo, e a `data` é a do pedido. São da F3 (a tabela de eventos que a 136a autorizou), como este registro já dizia; a 168
decidiu só o fim. E, achadas na verificação (abaixo), duas perguntas novas ao lote: se a limpeza de 24 h da fila poupa a
falhada, e de quando os 7 dias contam.

**Provado** em `tests/Caracterizacao/WhatsAppQueFalhaTest.php`, com o WhatsApp de mentira (`usarFabricaDeWhatsApp`) e a
fila do caso com a rotina só dela -- nada sai da máquina, e nenhuma rotina de envio de verdade rodou. Dois casos novos:
`testAFalhadaSaiDaFilaDepoisDeSeteDias` (a falhada de 7 dias e uma hora sai; a de 7 dias menos uma hora fica; a pendente
de 7 dias e uma hora não é apagada -- é mandada, e sai pelo envio --; a pendente velha de outra fila fica; e, chamado
por nome, o `apagarFalhasAntigas` com o relógio duas horas adiante tira só a "nova") e
`testONaoReceberamDaCampanhaNaoDependeDaFila` (a campanha não deixa linha na fila, e a falhada velha do mesmo telefone sai
sem mudar a coluna, "2", "1", "1"). Verdes no MySQL e no PostgreSQL, em série. Sete mutantes, cada um sozinho, vermelhos
nos dois bancos: sem a chamada da limpeza; o prazo a 8 e a 6 dias; a limpeza pela rotina sem o `falhou:`; pelo que não é
a falhada; por toda linha velha; e pelo relógio de agora no lugar do de quem chama (a tabela no roadmap de 14/10/2026).
O par novo (`PushAppRepository::apagarFalhasAntigas`) entrou na linha de base do `conferir-cobertura-por-par.php` (de 323
para 324 pares, o ISApi de 109 para 110), mencionado em teste; o "sem menção" ficou em 137.

#### A verificação de 14/10/2026 -- a limpeza de 24 h e a data do pedido (duas perguntas ao lote)

**A 168 foi respondida sobre uma premissa que não vale na produção.** A pergunta dizia *"Hoje nada apaga essas linhas"*, e
na produção apaga: o evento `limpar_push_app_24h` do MySQL (o **D26**; `mysql.sql:2355`; `ENABLED` e executando de hora
em hora quando lido, em 19/08/2026) apaga **toda** linha da `push_app` de mais de 24 h -- a pendente e a falhada. E a
resposta 127 do dono (12/10/2026, a (a)) leva essa limpeza para uma rotina do crontab no corte, *"com os prazos de hoje --
24 h para a fila"* (ainda não feita). Enquanto uma das duas rodar, a falhada sai antes de um dia, e o
`apagarFalhasAntigas` nunca acha o que apagar: os 7 dias só valem onde a de 24 h não roda -- as cópias locais, onde o
evento existe, `ENABLED`, e não apaga: o definer `andre@%` não existe (0 usuários, lido em 14/10/2026), e a cópia guarda
pendentes de agosto --, e o PostgreSQL depois do corte, até a rotina da 127. **Se a de 24 h poupa a falhada é escolha do
dono, entre duas respostas dele:** foi ao lote (WHATSAPP-E-APLICATIVO, a verificação), com a recomendação de poupar -- a
168 é a mais nova e a única que fala da falhada. Nada mudou no código: a limpeza dos 7 dias serve às duas saídas (se a de
24 h valer para tudo, ela fica sem nada a apagar). Os comentários de `PushApp::DIAS_DA_FALHA_NA_FILA` e de
`apagarFalhasAntigas`, que diziam a pendente fora da limpeza "nunca, por velha que seja", passaram a dizer que é só desta.

**De quando os 7 dias contam.** A `data` é a do pedido. Na fila `geral` (a cada minuto, das 8h às 22h) a linha é mandada,
e marcada, no minuto seguinte. As duas filas do `marketingPushOutros` -- o parabéns e o "sentimos sua falta" -- saem uma
vez por dia, às 10:00, até 100 linhas por fila (`selecionarCronJob`; a sobra é o **D489**): a linha que espera mais, ou
qualquer linha de uma fila parada, pode falhar dias depois do pedido e, contada do pedido, sai já na rodada seguinte --
um dia de registro (um minuto, na `geral`), e não sete. Contar da falha sem coluna nova é regravar a `data` na marcação, e
perder a hora do pedido; guardar as duas é uma coluna, que nenhuma resposta pediu. Foi ao lote, sem decidir, com a
recomendação de ficar como está: com a de 24 h na pendente, a diferença é de menos de um dia em sete, e a hora da falha,
com o motivo, é da F3.

**O caso que descreve o de hoje:** `testAPendenteVelhaQueFalhaSaiNaRodadaSeguinte`, no `WhatsAppQueFalhaTest` (a pendente
de 10 dias é mandada, falha, fica marcada com a `data` do pedido, e sai na rodada seguinte). O
`testAFalhadaSaiDaFilaDepoisDeSeteDias` marca as linhas e só depois as data para trás, e não via a diferença. Um mutante,
a marcação regravando a `data` com a hora da falha: vermelho só no caso novo, no MySQL e no PostgreSQL. O caso muda com a
resposta.

### D479 — A coluna "Não receberam" diz "0" nas campanhas que a produção mandar pelo código antigo até o deploy — REGISTRADO em 12/10/2026; a pergunta foi ao lote — DECIDIDO em 14/10/2026: como está

**Achado na verificação do item WHATSAPP-F0-E-CENTRAL (12/10/2026)**, ao consertar a coluna nova do D302. A lista antiga
das campanhas (Marketing › Notificações) mostra "Não receberam" pela diferença entre os contatos e os envios, e até o
conserto o WhatsApp que falhava era contado como envio: nessas campanhas a diferença diz "0" justamente onde as falhas
se esconderam. A regra da campanha (`RegraDaCampanha::naoReceberam`) só mostra a diferença na campanha de WhatsApp
enviada com a data a partir de 12/10/2026 (`NAO_RECEBERAM_DESDE`), o dia do conserto; nas de antes, o travessão.

**O que a data não resolve:** a produção roda o código antigo até o deploy deste. As campanhas que ela mandar entre
12/10/2026 e o deploy têm a data depois da fronteira e foram contadas do jeito antigo: depois do deploy, a coluna diz
"0" (ou só quem desligou o marketing) nelas. A cópia do teste herda o mesmo das cópias que vierem da produção. Nada no
banco separa a campanha contada pelo código novo da contada pelo antigo, e separá-la é esquema novo ou um passo do
deploy -- do dono. Pela leitura do código e um SELECT agregado na `franquiabv_dev` (as 526 campanhas de WhatsApp
enviadas, a última de 15/08/2026, todas com os contatos iguais aos envios); nada rodou na produção.

**As saídas** foram ao lote (WHATSAPP-F0-E-CENTRAL, a verificação): (a) uma coluna nova, `notificacoes.total_falhas`,
contada pelo envio -- exata, e a campanha de antes do deploy fica com o travessão por ser nula (esquema que só
acrescenta; a recomendação); (b) a fronteira passa a ser a data do deploy, num passo do corte; (c) como está.

#### DECIDIDO em 14/10/2026 -- a resposta 170 (o item WHATSAPP-E-APLICATIVO)

**Decidido pelo dono em 14/10/2026** (a pergunta 170 da página do lote, a opção (c), e não a recomendada (a)): *"Como está
-- as campanhas do intervalo mostram '0'."* Registrado, **sem código, de propósito:** a fronteira continua
`RegraDaCampanha::NAO_RECEBERAM_DESDE` (12/10/2026), nenhuma coluna nova, nenhum passo do corte. As campanhas de WhatsApp
que a produção mandar pelo código antigo entre 12/10/2026 e o deploy mostram, depois dele, "0" em "Não receberam" (ou só
quem desligou o marketing): **nelas, o "0" não quer dizer que todos receberam.** A cópia do teste herda o mesmo das cópias
que vierem da produção. Nada mudou na tela nem na regra.

### D480 — A sangria de R$ 0 com o financeiro marcado ainda grava R$ 0 na despesa — REGISTRADO em 13/10/2026; a pergunta foi ao lote — CORRIGIDO em 14/10/2026

**Achado no item CONTAS-A-PAGAR (13/10/2026)**, ao levar a resposta 139 do dono ("o pagamento de R$ 0 é recusado") à
tela antiga de Contas a Pagar (o **D208**). A retirada do caixa com "lançar no financeiro" cria a despesa, a parcela
quitada e o pagamento direto, em `CaixaService::registrarRetirada`, sem passar pelo `SaidaService`; e as duas portas do
caixa aceitam a retirada de R$ 0,00 (o resto do D208, 03/10/2026: "o zero não mudou"). Uma retirada de R$ 0 com a caixa
marcada grava, por esse caminho, o pagamento de R$ 0 que a regra nova recusa em Contas a Pagar. Lido no código; na cópia
não há nenhuma retirada de R$ 0 -- é o caminho, e não um caso.

**As saídas** foram ao lote (CONTAS-A-PAGAR, 13/10/2026): (a) recusar a retirada de R$ 0 nas duas portas do caixa, com
ou sem "financeiro" (a recomendação: a frase "- Valor" da tela do caixa já existe); (b) aceitá-la sem lançar no
financeiro; (c) como está.

#### CORRIGIDO em 14/10/2026 -- a resposta 173 (o item CAIXA-E-SANGRIA)

**Decidido pelo dono em 14/10/2026** (a pergunta 173 da página do lote, a opção (a), a recomendada): "Recusar a retirada
de R$ 0 nas duas portas do caixa (a tela antiga e a nova/API), com ou sem 'lançar no financeiro'."

**O conserto:**
- **O serviço recusa** (`CaixaService::registrarRetirada`, por onde as duas portas passam): a conta em centavos que já
  recusava o negativo passou de `< 0` a `<= 0` -- R$ 0,00, e também o 0,004 que seria gravado R$ 0,00. Nada é gravado:
  nem a retirada, nem a despesa com o pagamento de R$ 0.
- **A tela antiga do caixa diz o campo** (`CaixasController::validarFormRetirada`): "0,00" sem a máscara é "0.00", que
  não é vazio, e passava o `empty`; a conferência passou a ser em centavos, e a frase é a do negativo ("- Valor"). De
  quebra, o valor que não é número ("abc") também recebe "- Valor" -- antes recebia "Valor indiponível.", e não ia ao
  serviço: passava a conferência do negativo (`(float) "abc"` é 0) e caía na do saldo, porque no PHP 8 o número contra
  o texto não numérico compara como texto ("5000" < "abc" é verdadeiro; conferido no contêiner). Corrigido na
  verificação do item (14/10/2026), que achou o "antes ia ao serviço" escrito aqui; agora com caso.
- **A API do caixa responde 422 com o campo** (`Adm\CaixasController::retiradaAction`): `valor` "Obrigatório, maior que
  zero." -- para "0", "0.0" e "0.00", com ou sem `financeiro`. A tela nova do caixa já recusava o zero do lado dela
  (`lerValor(valor, true)` em `frontend/src/telas/Caixa.tsx`: "O valor tem de ser maior que zero."), e mostra o campo que
  o servidor devolve: o front não mudou.

**Provado** em `tests/Caracterizacao/D208RetiradaNoFinanceiroTest.php` (oito casos novos, com os dados): o serviço, pelas
duas entradas (`retirada` e `retiradaComSaldoRecalculado`), recusa R$ 0 com e sem "financeiro" e 0,004, sem gravar nada
(na da API o saldo do caixa é recalculado antes, como em toda escrita dela -- o cache da coluna --, e por isso ali o caixa
não é comparado); a tela antiga devolve "- Valor" nos três, e ao texto não numérico ("abc",
`testATelaVelhaDizOCampoAoTextoQueNaoENumero`, desde a verificação); a API, 422 com o campo nos seis; e um centavo passa
pela API, com a conta a pagar de R$ 0,01. Verdes no MySQL e no PostgreSQL, em série. Mutantes, cada um sozinho, vermelhos nos dois
bancos: o serviço de volta a `< 0` (o caso do serviço, nos três dados); a tela antiga com a conferência de antes (o caso
dela, nos três, e -- rodado de novo na verificação -- o do "abc"); e a API sem o campo do zero (o caso dela). Na cópia não há retirada de R$ 0 (medido em 14/10/2026, 0 de
todas): é o caminho, e não um caso.

### D481 — A etapa 1 da adesão troca o telefone sem o teto dos 10 cadastros — REGISTRADO em 13/10/2026; a pergunta foi ao lote — CORRIGIDO em 14/10/2026 (a resposta 176) — PARCIAL em 14/10/2026 (a etapa 3 foi ao lote)

**Achado ao consertar o D32 pela resposta 167** (13/10/2026, o item RESPOSTAS-162-A-167), derivando pelo `grep` (por
`setTelefoneSemMascara`), e não pela memória, as portas que gravam o telefone de um cliente. São seis: o cadastro e a
alteração do ERP e da tela nova (`ClienteService::insert` e `update`, que desde a 113 e a 167 passam pelo teto); o
cadastro do aplicativo (`AplicativoService::clienteNovo`), o convite dele (`indicacaoCadastrar`) e o amigo da etapa 6
(`FidelidadeService::indicarAmigo`), os três recusando qualquer telefone que já seja de cliente; e **a etapa 1 da
adesão** (`FidelidadeService::salvarDados`), a única que troca o telefone de um cliente gravado fora do ERP e da tela
nova. Ela grava o telefone digitado sem conferência nenhuma -- `FidelidadeController::validarFormularioCadastro` só
confere que ele não veio vazio --: nem o teto dos 10 cadastros, nem a frase do repetido que o ERP diz a quem não é
dependente (o D417).

**Caracterizado, sem decidir** (`D32TelefoneEmMuitosCadastrosTest::testAEtapa1DaAdesaoTrocaOTelefoneParaONumeroEmDezSemConferir`,
verde no MySQL e no PostgreSQL): o cliente que não é dependente troca, pela etapa 1, para o número que já está em 10
cadastros, e ele passa a 11. Quem abre a página é o próprio cliente, pelo link da adesão, e não o balcão: o número da loja
só chega por ali se o cliente o digitar. **Na cópia não dá para contar:** a anonimização refaz os telefones.

A 113 e a 167 falam do ERP e da tela nova; aplicar o teto na página da adesão é escolha do dono. Foi ao lote
(RESPOSTAS-162-A-167, o D481), com a recomendação de o teto valer também na etapa 1 quando o telefone muda, sem a recusa
do repetido (que prenderia o dependente que adere com o telefone de casa). Nada mudou no código.

**Decidido pelo dono em 14/10/2026 (a resposta 176, opção "b", a recomendada):** *"O teto dos 10 cadastros também na
etapa 1, quando o telefone muda -- Com a mesma frase; quem não muda o telefone continua."* Pelo pedido do dono:
*"continue de forma autonoma a resolver todos os debitos que  tiver resposta"*.

#### O conserto (14/10/2026, o item CADASTRO-E-CONVENIO)

- **A porta** (`FidelidadeController::salvarDadosAction`, a rota pública `/fidelidade/salvarDados`): na etapa 1, depois
  da validação dos campos e antes de gravar e de pontuar, `recusaDoTelefoneNaEtapa1` confere o número pela regra da
  alteração no ERP (a 167): só quando o telefone **muda** em relação ao gravado (`RegraDoCadastroDeCliente::mudaOTelefone`),
  e para o dependente também; a conta é a do ERP (`ClienteRepository::contarClientesPorTelefone`, os ativos com o número,
  fora o "Cliente Avulso" e o "SEM CADASTRO", o D18), e o número é o que o `salvarDados` grava ("+DDI número", pela mesma
  limpeza de `Cliente::setTelefoneSemMascara`). Passando do teto, a etapa responde só a frase do ERP e da tela nova --
  *"Este telefone já está em 10 cadastros. Informe o telefone do próprio cliente."* --, que a página mostra no aviso,
  como as outras recusas da etapa; nada é gravado (nem o nome e o nascimento que vieram junto) nem pontuado.
- **Sem a recusa do número de outro cliente** abaixo do teto (a opção "c", que o dono não escolheu): o dependente que
  adere digitando o telefone de casa continua passando, como antes.
- **O que a página conta a quem tem o link:** a frase diz que o número digitado já está em 10 cadastros -- o telefone de
  balcão, em regra. É a frase que o dono escolheu ("com a mesma frase"), e a pergunta já dizia que a página é pública.

**Provado** nos dois bancos, em série: `D481TelefoneNaEtapa1DaAdesaoTest` (novo, 5 casos, a etapa 1 despachada em
processo como o `fidelidade_cadastro.js` a manda) -- a troca para o número em 10 recusada com a frase, sem gravar nem
pontuar, para quem não é dependente e para o dependente; quem confirma o telefone que já tem continua no número em 10, o
dependente inclusive, e pontua; abaixo do teto a troca passa (mesmo para o número de outro cliente) e a seguinte, com o
número já em 10, é recusada; o inativo e o cadastro do sistema não contam. O caso que caracterizava a troca sem
conferência (`D32TelefoneEmMuitosCadastrosTest::testAEtapa1DaAdesaoTrocaOTelefoneParaONumeroEmDezSemConferir`) saiu: é a
recusa de lá. Os vizinhos (o D32, o D184, o D304, a adesão com pesquisa, o tudo-ou-nada) seguem verdes.

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-r47.py`, no rascunho do item) | vermelho, no MySQL e no PostgreSQL |
|---|---|
| sem a conferência na etapa 1 | 3: a troca para o número em 10, a do dependente e a troca seguinte do controle |
| o teto sem o "muda o telefone" | 1: quem confirma o telefone que já tem no número em 10 |
| a conta com os cadastros do sistema | 1: o inativo e o cadastro do sistema não contam |

**Não provado:** a página aberta num navegador; e a corrida de duas trocas ao mesmo tempo para o mesmo número (a conta e
a gravação não são atômicas, como no ERP). **Na cópia não dá para contar** quantas trocas a etapa 1 fez para um número de
balcão: a anonimização refaz os telefones.

#### A verificação (14/10/2026, o mesmo item) -- o PARCIAL do título

- **Os dois cadastros do sistema:** o caso "o inativo e o cadastro do sistema não contam" só punha o número no "SEM
  CADASTRO" (o id 2), e a lista sem o "Cliente Avulso" (`SENTINELAS = [2]`) passava verde -- o mutante da tabela acima
  tirava a lista inteira. Agora o caso põe o número nos dois, ativos (13 com o número: 9 ativos, 2 inativos e os dois do
  sistema), e cada um sozinho levaria a conta a 10.
- **O amigo inativo e a etapa 3 (foi ao lote):** o teto conta os ativos, como no ERP, e quem abre a página pelo link da
  indicação é o amigo, que nasce inativo (`FidelidadeService::indicarAmigo`) e só fica ativo ao concluir a etapa 3 -- o
  `salvarDados` ativa sem conferir o teto, como a reativação do ERP que a resposta 167 deixou como estava. Com 9 ativos no
  número, dois amigos inativos que trocam para ele na etapa 1 antes da etapa 3 passam os dois (a conta dá 9 nas duas
  vezes), e a etapa 3 leva o número a 11. Um de cada vez não passa. Contar o inativo na etapa 1, ou conferir o teto na
  etapa 3, é regra nova: **foi ao lote** (CADASTRO-E-CONVENIO, a verificação, o D481), com a recomendação de ficar como
  está. Caracterizado, sem decidir: `testOsAmigosInativosTrocamNaEtapa1EAEtapa3OsAtivaAcimaDoTeto`.

**Provado** nos dois bancos, em série: `D481TelefoneNaEtapa1DaAdesaoTest` (6 casos; eram 5).

| mutante (sozinho, restaurado e conferido pelo sha256; `medir-mutantes-r47v.py`, no rascunho do item) | vermelho, no MySQL e no PostgreSQL |
|---|---|
| a lista sem o "Cliente Avulso" (`[2]`) | 1: o inativo e os cadastros do sistema não contam |
| a lista sem o "SEM CADASTRO" (`[1]`) | 1: o mesmo caso |

### D482 — O aplicativo ainda abre a agenda e marca horário com o colaborador da unidade inativa, pelo id — REGISTRADO em 13/10/2026; a pergunta foi ao lote — CORRIGIDO em 14/10/2026 — PARCIAL em 15/10/2026: o V2 e o V3 reabertos marcam com ele; a pergunta foi ao lote — CORRIGIDO em 15/10/2026: o V2 e o V3 recusam (a 226 (b))

Achado ao fazer a resposta 132 do dono (o **D351**, o item UNIDADES-GRUPOS-ROYALTIES). O detalhe da unidade inativa e os
serviços dela já não abrem no aplicativo; o caminho pelo **colaborador** continua, por duas portas:

- **a agenda** (`/api/agendas/getAgenda/:id` e `/api/v2/agendas/getAgenda`, `AplicativoController::agendaSelecionarAction`),
  do contrato congelado do aplicativo (o **D5**): sem credencial nenhuma, lê o colaborador pelo id e não olha a unidade;
- **a marcação** pelo `POST /api/v4/agendamentos` (`V4\AgendamentosController::criarAction`), a do aplicativo autenticado
  -- o contrato novo, e não o congelado: confere só que o cliente é do telefone do token, lê o colaborador e o serviço pelo
  id (`em->find`), e `AplicativoService::validarAgendamento` olha a situação do colaborador (`getStatus`), e nunca a da
  unidade dele.

Na cópia são 32 colaboradores ativos, visíveis na agenda do aplicativo, em unidades inativas; nenhum agendamento nelas nos
90 dias antes da cópia. No uso normal o aplicativo não chega lá (a lista e o detalhe escondem a unidade); só um pedido
montado à mão, ou uma tela guardada no celular.

**Corrigido na revisão (13/10/2026):** a primeira versão deste registro e da pergunta apontava a marcação em
`agendaSalvarV2`/`V3`, e elas não precisam de nada. As duas exigem a sessão do ERP desde o **D91** (401 sem ela) e recortam
o colaborador pela unidade da sessão (`colaboradorDaUnidadeDaSessao`, o D237); desde o **D351**, a sessão de quem é lotado
em unidade inativa cai no pedido seguinte. A porta aberta da marcação é a v4. *(A premissa caiu em 14/10/2026, com as dez
rotas do aplicativo instalado reabertas pela resposta 189 (b): o "PARCIAL em 15/10/2026", abaixo.)*

A 132 não nomeava nenhuma das duas, e a agenda é do contrato congelado: a pergunta foi ao lote (UNIDADES-GRUPOS-ROYALTIES,
reescrita com as portas certas), com a recomendação de recusar o colaborador de unidade inativa como o colaborador que não
existe, nas duas.

#### CORRIGIDO em 14/10/2026 -- a resposta 179 (o item WHATSAPP-E-APLICATIVO)

**Decidido pelo dono em 14/10/2026** (a pergunta 179 da página do lote, a opção (b), a recomendada): *"As duas recusam o
colaborador de unidade inativa -- com a resposta do colaborador que não existe: a agenda com 'sucesso: false', como o
detalhe da unidade; a marcação nova com 'Informe o colaborador.', a recusa que ela já dá ao id que não acha ninguém."*

**O conserto:**
- **A regra** é uma só, `Usuario::daUnidadeInativa`: lotado numa unidade com o status desligado. Quem não tem unidade não
  é de unidade inativa. Sem exceção para o administrador: a pergunta é da agenda dele no aplicativo, e não da entrada no
  ERP (`entraPelaUnidade`).
- **A agenda** (`/api/agendas/getAgenda/:id` e `/api/v2/agendas/getAgenda`, as duas em
  `AplicativoController::agendaSelecionarAction`): o colaborador da unidade inativa responde `{"sucesso": false}`, como
  o que não existe e como o detalhe da unidade inativa. **O contrato congelado (o D5) muda só nesse caso, e a forma é a
  de sempre:** a do snapshot `agenda-selecionar-v2-sem-dados`, só `sucesso`. A do colaborador ativo
  (`agenda-selecionar`, `agenda-selecionar-v2`) não mudou. Nenhum snapshot cobria o caso, e nenhum foi regravado: as
  fixtures não têm unidade inativa, e a suíte de contrato não escreve no banco -- o caso da inativa é provado em
  processo (abaixo), e a suíte de contrato rodou verde.
- **A marcação** (`POST /api/v4/agendamentos`, `V4\AgendamentosController::criarAction`): o colaborador da unidade
  inativa vira o que não existe antes do `validarAgendamento`, e a resposta é o 422 "Informe o colaborador." -- status e
  corpo iguais aos do id que não acha ninguém --, sem gravar nada. No controller, e não no `validarAgendamento`: as rotas
  antigas que também o chamam exigem a sessão do ERP e recortam o colaborador pela unidade dela (o D91 e o D237), e não
  mudaram. *(Desde 14/10/2026, com as dez abertas, não exigem nem recortam: o "PARCIAL em 15/10/2026", abaixo.)*

**Provado** em `tests/Caracterizacao/D351UnidadeDesativadaTest.php`, em processo, três casos novos:
`testAAgendaDoColaboradorDaUnidadeInativaRespondeComoADoQueNaoExiste` (pelas duas rotas: ativa, a agenda do dia vem com os
horários; o id que não existe e o colaborador da inativa, exatamente `['sucesso' => false]`),
`testAMarcacaoDoAplicativoNovoRecusaOColaboradorDaUnidadeInativa` (pela fábrica de verdade, com o Bearer de uma sessão V4
inventada: a inativa dá o mesmo status e o mesmo corpo do id inexistente, 422, sem agendamento; ativa, 201 e um
agendamento) e `testARegraDoColaboradorDaUnidadeInativa`. O dia do caso é escolhido (o primeiro, de depois de amanhã em
diante, em que o colaborador atende e não tem horário), e não a data fixa do contrato. Verdes no MySQL e no PostgreSQL,
em série. Três mutantes, cada um sozinho, vermelhos nos dois bancos: a agenda sem a conferência, a marcação sem ela, e
a regra contando quem não tem unidade (a tabela no roadmap de 14/10/2026).

#### PARCIAL em 15/10/2026 -- o V2 e o V3 reabertos pela 189 (b) marcam com ele; a pergunta foi ao lote

Achado na verificação do item AS-DEZ-ROTAS-ABERTAS. **A premissa do conserto de 14/10 caiu no mesmo dia.** O V2 e o V3
(`/api/v2/agendas/agendar` e `/api/v3/agendas/agendar`) ficaram de fora porque *"exigem a sessão do ERP e recortam o
colaborador pela unidade dela"*; a resposta 189 (b) do dono reabriu as dez rotas do aplicativo instalado (o **D91**,
"ANOTADO em 14/10/2026"), e com a constante `AplicativoController::ROTAS_DO_APLICATIVO_INSTALADO` em `abertas` — como a
etapa 1 sobe — nenhuma das duas coisas vale. Um pedido montado à mão, sem credencial, com o id de um colaborador ativo de
uma unidade inativa e um serviço da unidade dele, passa: o `servicoDaUnidadeDoColaborador` não olha a situação da unidade,
e o `validarAgendamento` olha só a do colaborador. O horário é gravado na agenda da unidade fechada, como na produção de
hoje. **A agenda (`getAgenda`) e a v4 continuam recusando**: a parte do título que resta é a marcação pelo V2 e pelo V3.

Na cópia (`franquiabv_dev`, só contagens, sem as linhas de fixture), 99 colaboradores ativos em 24 unidades inativas,
todos com produto ativo da própria unidade (o registro de 13/10 contou 32 visíveis na agenda do aplicativo); nenhum
atendimento pelo aplicativo com eles criado nos 90 dias antes da cópia, e 2 em 2026 pela data do horário.

**Não decidi:** a 179 (b) pede *"As duas recusam o colaborador de unidade inativa"*, e a 189 (b), *"A exposição fica a de
hoje, nem maior nem menor"*. Recusar no V2 e no V3 fecharia mais que a produção; manter é aceitar o que a 179 queria
fechar. Foi ao lote (AS-DEZ-ROTAS-ABERTAS, 15/10/2026), e até a resposta fica como a produção. **Preso em**
`RotasAntigasDoAplicativoTest::testAbertasOMarcarSemSessaoMarcaComOColaboradorDaUnidadeInativa` (v2 e v3, nos dois
bancos): com as dez abertas e sem sessão, o colaborador ativo da unidade posta inativa marca. Os dois mutantes que põem o
`daUnidadeInativa` no V2 e no V3 deixam, cada um, o seu caso vermelho (a tabela da verificação no roadmap de 14/10/2026).
Se a resposta mandar recusar, é o mesmo conserto da v4 nas duas, e o caso inverte.

#### CORRIGIDO em 15/10/2026 -- a resposta 226 (b) (a fatia 4b, RESPOSTAS-DE-15-10)

**Decidido pelo dono em 15/10/2026** (a pergunta 226 da página do lote, a opção (b), a recomendada): *"As duas recusam já:
o colaborador da unidade fechada vira o que não existe, com a mesma resposta que elas dão hoje ao número que não acha
ninguém ("Informe o colaborador."), como a marcação nova -- a sua 179 (b). Fecha um pouco mais do que a produção, e só
para o pedido montado à mão."*

**O conserto:** `AplicativoController::semOColaboradorDaUnidadeInativa`, a regra do D482 (`Usuario::daUnidadeInativa`),
chamado pelo `agendaSalvarV2Action` e pelo `agendaSalvarV3Action` logo depois de achar o colaborador, **nos dois estados
do interruptor** `ROTAS_DO_APLICATIVO_INSTALADO`: a guarda não lê a sessão. Abertas, era a porta que restava; fechadas,
quem tem "Agendas unidades" (e o administrador) também marcava na agenda dele, porque o recorte da 106 deixa passar a outra
unidade sem olhar se ela está aberta. O colaborador da unidade inativa vira o nulo, e o `validarAgendamento` responde
"Informe o colaborador." -- o mesmo corpo do id que não existe, e nada é gravado. No controller, como na v4. Nada mudou
para o aplicativo instalado: a lista e o detalhe já escondem a unidade fechada.

**Provado** em `RotasAntigasDoAplicativoTest`, nos dois bancos, em série: `testAbertasOMarcarSemSessaoRecusaOColaboradorDaUnidadeInativa`
(v2 e v3; o caso que prendia o "marca" passou a provar a recusa, com o nome trocado), `testAbertasORecusadoDaUnidadeInativaRespondeComoOQueNaoExiste`
(a resposta igual à do id que não acha ninguém) e `testComAgendasUnidadesOMarcarRecusaOColaboradorDaUnidadeInativa` (o
estado fechado); os controles são os casos de sempre, com a unidade ativa. Vigiado no
`scripts/conferir-recorte-por-unidade.php` (o ajudante, e a chamada em cada uma das duas). **Mutantes**, cada um sozinho
(MySQL): o ajudante que nunca recusa (6 vermelhos); o V2 sem a chamada (3, e o portão do recorte sai com 1); o V3 sem a
chamada (3, e o portão sai com 1); o ajudante só com as dez abertas (2, os do estado fechado).

### D483 — A unidade fechada sem WhatsApp segue mandando o push do aplicativo — REGISTRADO em 13/10/2026; a pergunta foi ao lote — DECIDIDO em 14/10/2026: as 4 passam ao aplicativo, como as 24

Achado ao fazer a resposta 132 do dono (o **D351**). As rotinas do parabéns, da primeira visita e do "sentimos sua falta"
mandam pelo WhatsApp quando a unidade o tem ligado, e pelo aplicativo quando não. A 132 calou o WhatsApp das 4 fechadas
que o tinham, sem as desviar para o aplicativo; as **24 fechadas sem WhatsApp** continuam mandando pelo aplicativo, como
sempre -- as 24 têm as mensagens dele escritas, e 7.835 clientes ativos delas já usaram o aplicativo (contagens da cópia).
A mensagem leva o nome de uma barbearia fechada.

A (a) da 132 falava do WhatsApp, e não do push: a pergunta foi ao lote (UNIDADES-GRUPOS-ROYALTIES), com a recomendação de a
fechada não mandar por canal nenhum.

#### DECIDIDO em 14/10/2026 -- a resposta 178 (o item WHATSAPP-E-APLICATIVO)

**Decidido pelo dono em 14/10/2026** (a pergunta 178 da página do lote, a opção (c), e não a recomendada (b)): *"As 4
caladas passam ao aplicativo, como as outras 24 -- a fechada vira uma unidade com o WhatsApp desligado."* O título deste
débito descreve o estado que a resposta manteve -- as 24 fechadas sem WhatsApp continuam mandando pelo aplicativo -- e o
conserto é o das 4 caladas, que passam a fazer o mesmo. **Por isso a marca é DECIDIDO, e não CORRIGIDO** (a verificação
de 14/10/2026; a primeira versão deste registro dizia CORRIGIDO): o que o título diz continua verdade, por escolha do
dono, como o D458, o D459 e o D479.

**O conserto** (a regra da R30, e não o envio do push: o OneSignal e o FCM não mudaram):
- **`Unidade::mandaWhatsApp`** continua "o WhatsApp ligado E a unidade ativa", e ganhou a irmã para a linha do banco
  (`mandaWhatsAppPelaLinha`, a mesma conta), que as três rotinas leem. **O `whatsappCalado` saiu:** dizia "não manda, e
  não cai em outro canal", e o único que o lia era o NPS da venda.
- **O parabéns, a primeira visita e o "sentimos sua falta"** (`notificarAniversario`, `clientesNotificarPrimeiraVisita`,
  `clientesNotificarSentimosSuaFalta`): o `continue` da fechada saiu; o ramo do WhatsApp pergunta à
  `mandaWhatsAppPelaLinha`, e a fechada cai no ramo do aplicativo, como a sem WhatsApp -- para quem usa o aplicativo, com
  a mensagem do aplicativo escrita.
- **O NPS da venda** (`npsNotificacoes`), pela entidade: ele lia o `whatsappCalado` e cai no aplicativo sem o WhatsApp
  como os outros três; a 178 diz "para quem manda", e por isso entrou.
- **A campanha e a indicação ficam como estão**, que é o que o canal do aplicativo já faz para a unidade sem WhatsApp: a
  campanha de push sai (o push vem antes da conferência da unidade), a de WhatsApp da fechada não chama o provedor e conta
  como "não recebeu"; a indicação não tem canal no aplicativo, e a fechada, como a sem WhatsApp, não manda nada.
- **O WhatsApp da fechada continua calado** (a 132): nenhum caso o fez sair.

Na cópia, 4 unidades fechadas com o WhatsApp ligado (contagem de 14/10/2026); os 5.343 clientes ativos delas que usam o
aplicativo são a conta de 13/10/2026.

**Provado** em `tests/Caracterizacao/D351UnidadeDesativadaTest.php`: os quatro casos da R30 que diziam "fechada, não
sai -- nem pelo aplicativo" dizem agora "fechada, sai pelo aplicativo" (`testOParabensDaUnidadeFechadaSaiPeloAplicativo`,
`testAPrimeiraVisitaDaUnidadeFechadaSaiPeloAplicativo`, `testOSentimosSuaFaltaDaUnidadeFechadaSaiPeloAplicativo`,
`testONpsDaVendaDaUnidadeFechadaSaiPeloAplicativo`: ativa, a linha da fila é de WhatsApp; fechada, de aplicativo), e o
`testARegraDaUnidade` confere a regra pela entidade e pela linha. As rotinas só enfileiram (`push_app`); quem manda a fila
não é chamado. Verdes no MySQL e no PostgreSQL, em série. Cinco mutantes, cada um sozinho, vermelhos nos dois bancos: a
regra sem a situação da unidade, e cada uma das quatro rotinas de volta ao calado (a tabela no roadmap de 14/10/2026).

### D484 — A fatura de royalties cuja resposta do Asaas se perdeu é emitida de novo na chamada seguinte — REGISTRADO em 13/10/2026; a pergunta foi ao lote

Achado na revisão do item UNIDADES-GRUPOS-ROYALTIES (o **D44**, a resposta 98 do dono: *"repetir a chamada no dia 17 emite
só as que falharam no dia 16"*). No dia 16, `FranquiaService::gerarFatura` manda `POST /payments` ao Asaas
(`Asaas::gerarBoleto`, com 30 s de tempo no `curl`). Se o Asaas cria a cobrança e a resposta não chega -- o tempo passou, ou
o corpo não era JSON --, `json_decode` dá nulo: não há id nem erro, e nada se gravava. A fatura ficava `WAITING`, com
`asaas_id` nulo e sem `asaas_erro`, igual à que nunca foi tentada; a chamada repetida a acha em
`selecionarPorStatusVencimento('WAITING', ...)` e manda outro `POST /payments`, sem id: **dois boletos para a mesma
unidade**. O mesmo vale para o cadastro do cliente (`clienteNovo`), que repetido cria um segundo cliente no Asaas -- sem
cobrar nada. O defeito é anterior ao item: a emissão em qualquer chamada já existia, e o D44 só a pôs sob a trava.

**O que já mudou (13/10/2026):** a resposta perdida fica anotada em `asaas_erro` -- "sem resposta do Asaas ao gerar boleto
(a cobranca pode ter saido: confira no Asaas antes de emitir de novo)" --, e a emissão que dá certo depois apaga o aviso,
como apaga o de recusa. Caracterizado, sem decidir, em
`FaturasDeRoyaltiesTravaTest::testARespostaPerdidaFicaAnotadaEAChamadaRepetidaReemite` (o aviso, e a chamada repetida que
emite de novo). Nenhum caso fala com o Asaas: o cliente é dublado em processo. Nenhuma tela mostra `asaas_erro` hoje: o
aviso está no banco, para quem conferir.

**O que falta é escolha do dono**, porque decide quando uma unidade é cobrada: reemitir sem conferir (como hoje), deixar a
fatura sem resposta de fora da repetição até alguém conferir no painel do Asaas, ou mandar à cobrança a referência da
fatura e perguntar ao Asaas por ela antes de reemitir -- uma chamada nova ao Asaas, que só se prova contra o ambiente de
testes dele (a mesma condição da resposta 133). Foi ao lote (UNIDADES-GRUPOS-ROYALTIES).

### D485 — As consultas que só as telas e as rotinas que saíram usavam ficaram sem chamador, e três privilégios ficaram sem tela — REGISTRADO em 13/10/2026; a pergunta dos privilégios foi ao lote — PARCIAL em 25/10/2026: uma consulta saiu, e um quarto privilégio ficou sem tela — PARCIAL em 04/11/2026: o da Evasão é o quinto privilégio sem tela, e duas sobras dela ficaram sem chamador — PARCIAL em 08/11/2026: as consultas e os parâmetros sem chamador saíram; os três primeiros privilégios ficam pela 182 (a), e os da Fidelização e da Evasão esperam o dono — DECIDIDO em 08/11/2026: os cinco privilégios sem tela ficam na lista, sem efeito, pelas respostas 182 (a) e 376 (a)

Achado ao tirar o Radar de Temperatura, o Fluxo de Movimento, o Dash. Colaboradores antigo e as duas rotinas deles (a
resposta 137 do dono; o **D306**). As consultas que só eles chamavam e que moram nos repositórios deles saíram junto (as
nove de `DashboardColaboradorRepository` menos a régua antiga, e a de `DashboardMovimentoRepository`). As de fora ficaram,
sem chamador nenhum no código (`git grep`, 13/10/2026):

| O quê | Quem chamava |
|---|---|
| `VendaProdutoRepository::totalServicosVendidosPorMesAnoColaborador` e `::totalProdutosVendidosPorMesAnoColaborador` | a rotina das 23:30 |
| `VendaRepository::selecionarUnidadesComVendasAnoMes` e `::totalVendaPorAnoMesDiaUnidade` | a rotina do Fluxo |
| `AgendaRepository::totalAgendamentoPorMesAnoColaborador` | a rotina das 23:30 |
| `ClienteRepository::fidelizacao` | a rotina das 23:30 (e o `conferir-caminhos-postgres.php`, que a exercita) |
| `UsuarioRepository::selecionarColaboradoresUnidadeDashboard` | a tela antiga e o Radar (e o `ColaboradoresNoRelatorioTest`, que a compara com a lista comum) |
| `DataHora::totalDiaSemanaMes` | a rotina do Fluxo |
| o terceiro parâmetro (`$dataDashCola`) de `UsuarioRepository::selecionarUsuariosRelatorio` | a rotina das 23:30 |

**Por que ficaram:** o conserto foi o mínimo da decisão, e estas moram em repositórios de outros módulos, duas delas com
portão ou teste que as lê. Apagar é limpeza, sem efeito para quem usa: fica para quem mexer em cada repositório, com o
portão e o teste delas acompanhando.

**E os três privilégios** -- "Relatórios radar temperatura", "Relatórios de fluxo de movimento" e "Relatórios dashboard
colaboradores - plano carreira" -- continuam no banco, na `SessaoAcl` e na tela de privilégios, e não abrem mais nada.
Tirá-los é apagar linha (o caminho do "Vendas caixa", a resposta 122: `RegraDoPrivilegio::FORA_DE_USO` e o passo 3 do
corte), e foi ao lote (PARCEIRO-RADAR-E-FLUXO), com a recomendação de seguir o "Vendas caixa".

#### PARCIAL em 25/10/2026 -- a `ClienteRepository::fidelizacao` saiu, e o privilégio da Fidelização é o quarto sem tela

**A resposta 182 do dono** (14/10/2026), à pergunta "Os três privilégios que ficaram sem tela saem da lista?", foi a
**(a) "Como está"**: *"Os três ficam na lista, sem efeito."* Nenhuma linha apagada.

**Uma consulta saiu** na fatia 41 da fila das telas (FIDELIZACAO-SAI; a resposta 138 (a) do dono, o D306): a
`ClienteRepository::fidelizacao`, a da linha acima que só a rotina das 23:30 chamava. Desde 13/10/2026 só o
`scripts/conferir-caminhos-postgres.php` a exercitava, e o caminho saiu do portão junto com ela. A rotina das 03:10 da
Fidelização nunca a chamou: a consulta que essa rotina usava, a `VendaRepository::fidelizacaoPorColaborador`, saiu também,
mas não estava nesta tabela, porque tinha chamador. As outras seis linhas da tabela continuam sem chamador, como em
13/10/2026.

**E um quarto privilégio ficou sem tela:** o "Relatórios de fidelização" (`relatorios/fidelizacao`; no `franquiabv_dev`, a
funcionalidade 35, lida por `SELECT` em 25/10/2026). O único lugar que o conferia era o item escondido do menu
Relatórios do `layout.phtml` (`getAcl("relatorios", "fidelizacao", "ler")`), que saiu na mesma fatia; ele continua no
banco, na `SessaoAcl` (o `salvarAcesso` da linha 128) e na tela de privilégios, e marcar ou desmarcar não muda mais nada.
**Fica como os três**, pelo padrão da 182 (a) -- ficar, sem efeito, é o que não apaga linha. Se o dono quiser outro destino
para os quatro, é a mesma pergunta.

#### PARCIAL em 04/11/2026 -- o privilégio da Evasão é o quinto sem tela, e duas sobras da rotina dela ficaram sem chamador

**A Evasão antiga saiu** na fatia 76d da fila das telas (CARTEIRA-CA3; a resposta 138 (a) do dono, o D691): a tela, o PDF e a rotina
das 04:10, com as duas consultas que só a rotina chamava (`VendaRepository::evasaoPorColaborador` e
`::evasaoPorColaboradorTotalVendas`, que saíram junto). **O quinto privilégio sem tela** é o "Relatórios de evasão"
(`relatorios/evasao`; no `franquiabv_dev`, a funcionalidade 27, lida por `SELECT` em 04/11/2026): o único lugar que o conferia era o
`EvasaoController` e o item escondido do `layout.phtml`, e ele não abre a Carteira (a E9 de `spec-carteira.md`). Continua no banco,
na `SessaoAcl` (o `salvarAcesso` da linha 129) e na tela de privilégios, sem efeito -- **fica como os quatro**, pelo padrão da 182
(a), que não apaga linha. **Duas sobras sem chamador** (`git grep`, 04/11/2026), que a rotina era a única a usar e que moram fora
dela, como as da tabela acima: o `DataHora::removerUmAno`, e o terceiro parâmetro (o grupo) de
`UsuarioRepository::selecionarUsuariosComVendas` -- o Vendas antigo e a rotina dos atendimentos chamam o método sem ele, e o
`VendasNoRelatorioTest::testOFiltroDeGrupoRecortaOsColaboradores` continua afirmando o que ele faz. Ficam pelo mesmo motivo da
tabela: apagar é limpeza, sem efeito para quem usa.

#### PARCIAL em 08/11/2026 -- as consultas e os parâmetros sem chamador saíram; os três primeiros privilégios ficam pela 182 (a), e os da Fidelização e da Evasão esperam o dono

**A fatia 125 da fila** (CONSULTAS-MORTAS-E-PORTOES-DE-DQL). Reconferido antes de apagar, pelo `git grep` de cada nome em
`module/`, `scripts/`, `tests/`, `public/` e `frontend/` (os `.bak` do `ISRelatorio`, fora do git, não citam nenhum): só a
definição, comentários, o `conferir-cobertura-por-par.php` (num parágrafo de história) e os dois testes que as comparavam.

**Saíram** as seis linhas da tabela que ainda estavam no código -- `VendaProdutoRepository::totalServicosVendidosPorMesAnoColaborador`
e `::totalProdutosVendidosPorMesAnoColaborador`, `VendaRepository::selecionarUnidadesComVendasAnoMes` e
`::totalVendaPorAnoMesDiaUnidade`, `AgendaRepository::totalAgendamentoPorMesAnoColaborador` (o D427),
`UsuarioRepository::selecionarColaboradoresUnidadeDashboard` e `DataHora::totalDiaSemanaMes` --, o terceiro parâmetro
(`$dataDashCola`) de `UsuarioRepository::selecionarUsuariosRelatorio`, e as duas sobras de 04/11/2026: `DataHora::removerUmAno` e o
grupo de `UsuarioRepository::selecionarUsuariosComVendas`. **E duas fora da lista**, achadas pelo mesmo `git grep`:
`VendaRepository::selecionarTotalUnidadesComVendas` (sem chamador desde a importação do ERP, `git log -S`) e
`DataHora::totalDiasEntreDatas` (o último chamador era a tela antiga dos convênios, trocada na 1b5b3152). Cada uma deixou um
comentário curto no lugar, no molde das que saíram antes.

**Os testes que as comparavam:** o `ColaboradoresNoRelatorioTest` perdeu os quatro casos da lista do painel (o subconjunto, a
área 3, o `GROUP BY`, a comissão do painel) e fica com a lista comum; o `VendasNoRelatorioTest::testOFiltroDeGrupoRecortaOsColaboradores`
virou `testSemOGrupoVemOsColaboradoresDeTodosOsGrupos` -- os dois chamadores que ficam querem todo colaborador com venda, e um
recorte por grupo que voltasse calado fica vermelho. O `D485ConsultasSemChamadorSairamTest` confere que as dez saíram, que as
irmãs com chamador ficaram (no molde do `AniversariantesSaiuTest`) e as assinaturas dos dois métodos que perderam o parâmetro.

**Os privilégios** -- os cinco sem tela -- **ficam**, sem efeito, e nenhuma linha foi apagada; mas só **três** deles têm a resposta
do dono. A 182 (a) ("Os três ficam na lista, sem efeito") é sobre o Radar, o Fluxo e o Dash. Colaboradores - plano carreira, os
nomeados na pergunta de 13/10/2026. O da Fidelização (25/10) e o da Evasão (04/11) ficaram **pelo padrão** dela, porque ficar é o que
não apaga linha, e o corpo de 25/10 já dizia que outro destino para eles "é a mesma pergunta" -- mas ela **nunca foi ao lote** (a
verificação da fatia 125 procurou em `perguntas-do-lote.md` e na página: só existe a seção de 13/10, dos três). Por isso o título diz
**PARCIAL**, e não corrigido: a parte das consultas está feita, e a dos dois privilégios **foi ao lote em 08/11/2026** ("O que fazer
com os privilégios da Fidelização e da Evasão, que não abrem mais nada?", com a recomendação de seguir o que ele decidir para os três
da 182). Até a resposta, eles ficam como estão; se ela for a (a), o débito fecha sem mudar código.

**Medido no portão de cobertura:** nenhuma das que saíram era par (nenhum controller nem rotina as chamava). Os mutantes, um por
vez e devolvidos pelo sha1, nos dois bancos: E1 (uma `totalAgendamentoPorMesAnoColaborador` vazia de volta), E4 (um
`removerUmAno` de volta) e E5 (uma `selecionarTotalUnidadesComVendas` de volta) vermelhos no `testAsConsultasSemChamadorSairam`; E2 (o `$grupoId` de volta na assinatura) vermelho no
`testOsParametrosMortosSairamEOsMetodosFicaram`; E3 (o `usu.grupo = 1` de volta, calado) vermelho no
`testSemOGrupoVemOsColaboradoresDeTodosOsGrupos` e em mais dois casos do `VendasNoRelatorioTest`.

**Fica no `fixtures.sql`**, sem leitor no `module/`: a área 3 e o comentário dela (linha ~979) e o comentário do grupo 1 que cita
a Evasão (linha ~380). Mudar a fixture pede regravar a marca, e o texto só conta a história; quem mexer nela, os limpa.

#### DECIDIDO em 08/11/2026 -- os cinco privilégios sem tela ficam na lista, sem efeito (a fatia 138, RESPOSTAS-DE-08-11-API-E-CADASTRO)

**A resposta 376 do dono** (08/11/2026), à pergunta "O que fazer com os privilégios da Fidelização e da Evasão, que não abrem mais
nada?", foi a **(a)**: *"Como os três da 182: os dois ficam na lista, sem efeito. Nada muda."* Era o resto que a fatia 125 deixou
PARCIAL. Os cinco -- "Relatórios radar temperatura", "Relatórios de fluxo de movimento", "Relatórios dashboard colaboradores - plano
carreira" (a 182 (a), de 14/10/2026), "Relatórios de fidelização" e "Relatórios de evasão" (a 376 (a)) -- **ficam no banco, na
`SessaoAcl` e na tela de privilégios, sem efeito, por decisão do dono**: marcar ou desmarcar não abre nada, e nenhuma linha foi
apagada. Nada mudou no código nem no esquema. As consultas e os parâmetros sem chamador já tinham saído na fatia 125. O débito
fecha: se um dia o dono quiser limpar a lista, a (d) com a (b) da 376 faz os cinco de uma vez, no corte -- é pergunta nova.

### D486 — O portão de agendamento nunca achava a rotina apagada do código que ficava na tabela do crontab — CORRIGIDO em 13/10/2026

Achado ao tirar as duas rotinas dos painéis antigos (a resposta 137; o **D306**). A terceira pergunta de
`scripts/conferir-agendamento.php` -- "o crontab chama algo que não existe mais?" -- procurava `/api/<grupo>/<rotina>` na
coluna da rotina da tabela de `docs/f1-6-agendamento.md`, e a coluna escreve só o nome, entre crases
(`` `npsContabilizar` ``, `` `marketing/envios` ``): a pergunta nunca achou nada. Com as duas rotinas fora do código e as
linhas delas ainda na tabela, o portão dizia "chamadas órfãs no crontab . 0".

**O conserto:** cada nome entre crases da coluna é comparado pelo último segmento com o último segmento das rotas do
`ISApi`, como a segunda pergunta já comparava. **Provado** contra uma cópia do repositório com o código de hoje e a tabela
de antes: o portão novo acusa as duas (`relatoriosDashboardColaboradoresAtualizar` e
`relatoriosDashboardFluxoMovimentoAtualizar`, "chamadas órfãs no crontab . 2"); o antigo, na mesma cópia, dava 0. Com a
tabela de hoje, 0 órfãs. O portão continua saindo com 1 pelo `conveniosAtualizarVencimento` sem agendamento -- o D50, o de
sempre.

### D487 — O administrador e quem não é gerente perderam o Radar e o Fluxo: o painel que recebeu as duas opções só abre para o gerente de unidade — REGISTRADO em 13/10/2026; a pergunta foi ao lote — CORRIGIDO em 27/10/2026

**Corrigido em 27/10/2026** (a fatia 46 da fila das telas que faltam), pela **181 (b)** do dono: *"O painel abre também para o
administrador, como item do menu (a home dele continua a agenda), com a escolha da unidade -- o molde do Profissional
Parceiro [...] Sem a rede"*, e a nota dela: quem tinha o privilégio sem ser gerente fica sem as duas -- o painel é do gerente.

- **O menu:** a sessão publica a guarda nova `painelNoMenu` (o gerente ou o administrador; `SessaoJson::menu`), e o item
  "Painel" do `menu-do-erp.ts` passou a ela -- o mesmo destino, e não um a mais (15 grupos e 51 destinos, como antes). A
  `painelDaUnidade` não mudou de sentido: ela decide a HOME (`ConsoleLigado.tsx`), e o administrador fica com a agenda.
- **A API:** as guardas moram em `PainelDaUnidadeController::guardasDoPainel` (o papel é o de gerente OU o de
  administrador), e a unidade em `recorteDoPainel`: `?unidade=<id>` pela regra da unidade do filtro dos relatórios
  (`RecorteDoRelatorio::unidadeDoFiltro`) com o escopo da UNIDADE para todos -- `todas` é o 422 --, entre as que a lista
  suspensa do ERP oferece a quem pede. O gerente pedindo outra unidade é o 422 (antes o parâmetro era ignorado), e a
  inativa e a que não existe também. `GET /adm/api/painel/unidades` é a lista, e o recorte das três respostas diz a unidade.
- **A tela:** o `Painel` ganhou a `EscolhaDaUnidade` sem "todas as unidades", só para o administrador; a troca guarda o mês e
  a comparação, e pede os clientes e a semana da unidade nova; o "ver detalhes" leva o mês e a unidade ao Profissional
  Parceiro (`AberturaDoRelatorio.unidade`, que o Parceiro lê só para o administrador).
- **Quem tem o privilégio sem ser gerente nem administrador** continua recusado (403 `papel`) e sem o item, como a nota da 181
  manda. Provado no `PainelDaUnidadeTest` e no `SessaoApiTest`, nos dois bancos, e pelos mutantes P46-M1 a P46-M6 e P46-M9.


Achado ao fazer a resposta 137 (o **D306**), e **reescrito na revisão do mesmo dia**: a primeira versão deste registro, e a
da pergunta do lote, dizia que "no painel, o administrador vê a unidade da sessão dele" -- e o administrador não abre o
painel. Com essa premissa, o dono escolheria sobre uma perda menor do que a de verdade.

- **O painel é a home do gerente de unidade que não é administrador** (a decisão de 29/09/2026: "visível apenas para o
  gerente de unidade"). A sessão só publica a guarda com a marca de gerente e sem a de administrador
  (`SessaoJson::guardas`, `painelDaUnidade`); o item "Painel" do menu novo só aparece com ela (`menu-do-erp.ts`); e o
  Console só abre nele com ela (`ConsoleLigado.tsx`). Para o "Gerente geral" e o administrador, a home e o menu continuam
  a agenda (`docs/f3-relatorios.md`, seção 3). A API aceita o administrador; nenhuma tela o leva lá.
- **O Radar de Temperatura abria para quem tinha o privilégio dele**, com o atalho na barra do topo do ERP: o administrador
  escolhia a unidade, ou a rede inteira, e um colaborador; o colaborador do grupo 1 via só a si mesmo; os outros -- o
  caixa, o gerente -- a unidade da sessão. **O Fluxo de Movimento**, para quem tinha o privilégio dele: o administrador
  escolhia a unidade. Os dois aceitavam um intervalo de meses.
- **Com as duas opções no painel, só o gerente de unidade as vê**, da unidade dele, num mês. O administrador perdeu as duas
  inteiras -- a comparação com o ano anterior e o movimento por dia da semana, de qualquer unidade e da rede --, e quem
  tinha o privilégio sem ser gerente também. O colaborador contra o ano anterior está no histórico do Profissional
  Parceiro (12 meses, e o completo), para quem o Profissional Parceiro abre.
- **O Atendimentos não as devolve**, como está desenhado (`docs/f3-relatorios/spec-atendimentos.md`): o mês a mês de uma
  unidade no ano, com o crescimento contra o mês anterior -- o do ano é nulo, "comparar anos não foi pedido" -- e a rede
  recusada até o "mês fechado". A recomendação da primeira versão (deixar a rede e a série de meses com ele) partia da
  premissa errada.
- **Quantos não gerentes têm os dois privilégios não foi contado**: o banco não respondeu neste dia (o Docker travou).

**Por que não foi consertado:** abrir o painel ao administrador muda a decisão de 29/09/2026, e dar a ele a escolha da
unidade, ou da rede, é regra de quem vê o quê; a 137 não dizia de quem era a visão. A pergunta foi ao lote
(PARCEIRO-RADAR-E-FLUXO), reescrita com a premissa certa, com a recomendação de o painel abrir ao administrador como item
do menu, com a escolha da unidade e sem a rede.

### D488 — Quatro números do Radar não têm par no painel: a ocupação da agenda, os agendamentos pelo app e o valor dos serviços e dos produtos contra o ano anterior — REGISTRADO em 13/10/2026; a pergunta foi ao lote — CORRIGIDO em 27/10/2026

**Corrigido em 27/10/2026** (a fatia 46), pela **183 (b)** do dono: *"O valor dos serviços e o dos produtos entram nos números
comparados do painel (as mesmas vendas: nenhuma consulta a mais); a ocupação e os agendamentos ficam no Profissional
Parceiro."* O `PainelDaUnidadeJson::numeros` devolve `valorDosServicos` e `valorDosProdutos`, as somas que
`ContasDoPainelDaUnidade::resumo` já fazia, com o anterior pela mesma regra dos outros (o "sem base"); a lista `NUMEROS` do
`Painel.tsx` tem os dois cartões (catorze, e a grade para em sete colunas para nenhum ficar sozinho), e o "Como os números são
contados" tem as duas linhas. Conferidos ao centavo contra uma SQL independente pelo tipo do produto: no teste, nos dois
bancos, e na cópia (só SELECT; cinco unidades, março de 2026 contra fevereiro e julho contra julho de 2025, dez de dez). A
ocupação e os agendamentos pelo app ficam no Profissional Parceiro, como a resposta manda -- o lado a lado com o ano
anterior deles não volta, e isso é a escolha do dono, e não resto deste débito.


Achado na revisão do item PARCEIRO-RADAR-E-FLUXO (a resposta 137 do dono; o **D306**). O Radar de Temperatura mostrava,
contra o mesmo período do ano anterior, dez números (`git show 688922c:module/ISRelatorio/view/is-relatorio/radar-temperatura/index.phtml`):
o faturamento, o ticket médio, os clientes atendidos, os clientes novos, os serviços realizados, o **valor dos serviços**,
os produtos vendidos, o **valor dos produtos**, a **ocupação da agenda** e os **agendamentos pelo app**. Os números
comparados do painel (`PainelDaUnidadeJson::numeros`, a lista `NUMEROS` de `Painel.tsx`) cobrem seis deles, e mais os
descontos, as cortesias, os dias trabalhados, o faturamento por dia e os extras. Os quatro em negrito não estão:

- **o valor dos serviços e o dos produtos** são as mesmas linhas que o painel já lê, separadas pelo tipo; o painel mostra o
  valor dos serviços do mês no cartão dos serviços, sem o anterior, e o dos produtos em lugar nenhum;
- **a ocupação da agenda e os agendamentos pelo app** são da agenda, e não das vendas: estão no Profissional Parceiro (a
  agenda da unidade, mês a mês, a F4), um mês de cada vez, sem o lado a lado. A ocupação de antes da vigência do horário
  semanal (gravada desde 02/10/2026, a F4.1) é a da régua antiga, o retrato que a rotina das 23:30 gravava em
  `dashboard_colaboradores` -- até a vigência fazer um ano, a comparação com o ano anterior misturaria as duas réguas.

Nem o D306, nem o D487, nem as perguntas do lote diziam essa perda: quem abria o Radar para ver a ocupação ou os
agendamentos pelo app de junho contra junho do ano passado não acha isso em lugar nenhum.

**Por que não foi consertado:** a 137 diz "a comparação com o ano anterior" sem dizer de quais números, e levar ao painel
os da agenda é um pedido a mais e uma régua a escolher. A pergunta foi ao lote (PARCEIRO-RADAR-E-FLUXO), com a
recomendação de levar ao painel só o valor dos serviços e o dos produtos -- custo nenhum a mais -- e deixar a ocupação e os
agendamentos no Profissional Parceiro.

### D489 — As filas do parabéns e do "sentimos sua falta" saem 100 linhas por dia, e a sobra some na limpeza de 24 h sem ser mandada — REGISTRADO em 14/10/2026; a pergunta foi ao lote

**Achado na verificação do item WHATSAPP-E-APLICATIVO (14/10/2026)**, ao conferir de quando contam os 7 dias do **D478**.
Lido no código e no crontab, contado na cópia; nada foi mandado, e nenhuma rotina de envio rodou.

- **Quem põe:** a rotina do parabéns, às 06:00, e a do "sentimos sua falta", às 07:00, põem as mensagens da rede inteira
  -- de WhatsApp e do aplicativo -- nas filas `notificarAniversario` e `clientesNotificarSentimosSuaFalta` da `push_app`.
- **Quem manda:** o `marketingPushOutros`, **uma vez por dia, às 10:00** (o crontab de produção lido em 27/08/2026,
  `docs/f1-6-agendamento.md`), que chama o `marketingPush` uma vez por fila; cada chamada lê **até 100 linhas**, sem
  ordem (`PushAppRepository::selecionarCronJob`, `setMaxResults(100)`). O resto espera a chamada do dia seguinte.
- **Na produção, o resto não chega a ela:** o evento `limpar_push_app_24h` (o **D26**) apaga de hora em hora, aos 35
  minutos, toda linha de mais de 24 h, e a linha das 06:00 sai às 06:35 do dia seguinte, antes das 10:00. Sem tentativa
  e sem rastro -- nem a marca de falha do D302, porque a linha nunca é mandada.

**Na cópia** (o retrato de 16/08/2026, tirado depois das 18:57, a linha mais nova de `agendas_exclusoes` no D26): **316**
linhas do parabéns enfileiradas às 06:00 e **385** do "sentimos sua falta" das 07:00 ainda na fila, e nenhuma de 15/08,
que o evento já tinha apagado. As rodadas da rotina do parabéns na cópia local (de 08/09 a 01/10/2026) põem de **382 a
420** linhas cada. Só contagens, por fila e por minuto. Se a chamada das 10:00 de 16/08 mandou as 100, a rede enfileirou
perto de 416 parabéns naquele dia, e três de cada quatro não saíram por canal nenhum. Quantas saem por dia na produção --
e se a chamada das 10:00 roda --, só a produção diz.

**Depois do corte, sem a rotina da resposta 127, piora:** sem limpeza nenhuma, a sobra espera e sai 100 por dia -- o
parabéns chegaria dias depois do aniversário, e a fila do parabéns cresceria perto de 300 linhas por dia. Com a rotina da
127, fica como hoje.

**O que fazer é do dono:** muda o ritmo do WhatsApp das unidades (quantas mensagens cada número manda de uma vez) e, numa
das saídas, o crontab do corte. A pergunta foi ao lote (WHATSAPP-E-APLICATIVO, a verificação), com três saídas -- a
chamada das 10:00 manda a fila inteira, em voltas de 100; as duas filas saem a cada minuto das 10h às 22h, como a
`geral`; como está --, e a recomendação da segunda. A produção está congelada até o corte: o que for escolhido vale dele
em diante. Nada mudou no código.

### D490 — Os itens da comanda saem do banco sem ordem, e no PostgreSQL trocam de lugar depois de um ajuste — REGISTRADO em 14/10/2026 — CORRIGIDO em 16/10/2026

**Achado pelo ciclo do item CADASTRO-E-CONVENIO (14/10/2026), sem nada da rodada.** O
`D450CentavoDoItemRetiradoTest::testPelaApiAjustarAQuantidadeDeixaOTotalDaSomaDosItens` saiu vermelho no
PostgreSQL uma vez, com os dois itens da comanda na ordem trocada (`['170.81', '85.41']` no lugar de
`['85.41', '170.81']`), e verde no MySQL e na repetição. O teste estava certo em esperar a ordem: o defeito é da
coleção.

- `Venda::$produtos` (`module/ISVenda/src/Entity/Venda.php`) é um `OneToMany` **sem `@ORM\OrderBy`**, e o
  `ComandaJson::recurso` publica os itens na ordem em que a coleção chega (`$venda->getProdutos()->toArray()`).
- **No MySQL** a ordem calha de ser a do id (o InnoDB lê pelo índice de `venda`, que termina na chave primária), e
  por isso nunca apareceu. **No PostgreSQL** sem `ORDER BY` a ordem é a do disco, e a linha que passa por um
  `UPDATE` muda de lugar -- o ajuste da linha (`alterar`) regrava a outra.
- **Na tela:** a comanda nova mostra os itens nessa ordem, e no PostgreSQL -- o banco de depois do corte -- um
  item pode pular para o fim da lista depois de um ajuste ou de um desconto, sem nada ter mudado nele. O PDV antigo e
  o cupom fiscal leem a mesma coleção.

**O conserto provável** é `@ORM\OrderBy({"id" = "ASC"})` em `Venda::$produtos` -- a ordem de hoje no MySQL, sem
esquema --, com o caso que ajusta a linha nos dois bancos e um mutante sem o `OrderBy`. Não foi feito nesta rodada:
a coleção é lida por todo o PDV, e a mudança pede o ciclo inteiro. Vale conferir as outras coleções `OneToMany` sem
`OrderBy` que alguma tela mostra em lista (os pagamentos da venda, ao lado, também).

**Corrigido em 16/10/2026 (a fatia T2, ORDEM-DOS-ITENS-DA-VENDA).** A ordem do id é a de hoje no MySQL, e nenhuma
mudança de esquema: no MySQL nada visível muda; no PostgreSQL as listas deixam de trocar de lugar.

- **A venda:** `Venda::$produtos` e `Venda::$pagamentos` com `@ORM\OrderBy({"id" = "ASC"})`. Quem lê a coleção em
  ordem: a comanda nova (`ComandaJson::completo`, e a tela mostra na ordem do JSON, `comanda.itens.map`, sem ordenar),
  o detalhe da venda (`vendas/detalhes.phtml`), a tabela dos pagamentos do PDV antigo (`tabela-pagamentos.phtml`), o
  histórico de consumo do cliente e o PDF dele, o consumo do aplicativo e da API V4, o extrato do crédito (a forma da
  compra de crédito é a do primeiro pagamento, `VendaRepository::extratoCreditoCliente`) e o colaborador da última
  visita do cliente, que é o do ÚLTIMO serviço da lista (`VendaPagamentoService::gravarAteAVendaFechada`).
- **O texto acima errava em dois pontos.** O primeiro, o do mecanismo (corrigido na verificação de 16/10/2026): o
  ajuste não "regrava a outra" -- `VendaProdutoService::alterar` grava só a linha ajustada e a venda. A linha que passa
  por um `UPDATE` ganha no PostgreSQL uma versão nova onde o disco tiver espaço, antes ou depois das outras; no D450 a
  segunda linha, ajustada, voltou PRIMEIRO (`['170.81', '85.41']`), e não no fim. Os comentários novos de `Venda`,
  `Caixa`, `VipClassCheckpointPergunta` e `VendaProdutoRepository` diziam "pula para o fim" e foram corrigidos.
  O segundo: a tabela de itens do PDV antigo não lê a coleção, e sim duas DQL sem `ORDER BY`
  (`VendaProdutoRepository::selecionarProdutosVenda` e `selecionarProdutosVendaTemporariaPorUsuario`, pelo
  `VendaProdutoService::tabelaProdutos`). As duas ganharam `ORDER BY venpro.id`, e também as três da API das vendas da
  unidade (`CronJobService::unidadeVendas` e `unidadeVendasV2`): `selecionarProdutosVendaComColaborador`,
  `VendaPagamentoRepository::selecionarPagamentosPorVenda` (as formas saem "(CRE,DIN)" pelo id) e
  `selecionarEntidadesPorVenda`. As cinco passaram a ser chamadas pelo caso novo, e o `conferir-cobertura-por-par`
  desceu de 136 para 131 pares sem teste (conferido contra a lista do HEAD: saíram essas cinco, e nenhuma outra).
- **As outras coleções**, medidas pelo mapeamento do Doctrine: 38 `OneToMany`, nenhuma `ManyToMany`, e só
  `Saida::$parcelas` tinha ordem. Ganharam a ordem pelo id as que uma tela lê em ordem: `Caixa::$retiradas` (o detalhe
  das retiradas do relatório de caixas; cancelar é um UPDATE, e a cópia tem 1.102 canceladas), `Usuario::$areasAtuacao`
  (as áreas na tela nova dos usuários, pelo `UsuarioJson`; 61 usuários com duas ou mais),
  `VipClassCheckpointPergunta::$respostas` (as opções do checkpoint, para o colaborador e para o administrador; sem
  coluna de ordem, 63 perguntas com duas ou mais opções ativas, 8 opções editadas) e `Produto::$pacoteItens` (o
  fechamento cria as sessões do cliente nessa ordem, e a lista dos pacotes desempata pelo id da sessão as do mesmo
  vencimento). `Saida::$parcelas` ganhou o id como desempate do vencimento -- 20 saídas da cópia têm duas parcelas
  ativas no mesmo dia --, no mapeamento e no `Criteria` do `getParcelas`, que vence o mapeamento.
- **A verificação (16/10/2026) achou mais duas leituras em ordem**, e as duas ganharam a ordem pelo id:
  - `Nps::$itens`: o motivo escrito ("a rotina soma as notas, a ordem não muda a conta") estava errado. A rotina
    (`CronJobService::npsContabilizar`) grava a média na venda do PRIMEIRO item, e o `nps` da venda é o que a lista de
    vendas mostra. Os itens podem ser de vendas diferentes (a pesquisa leva a última venda de cada colaborador,
    `MAX(ven.id)`), e no PostgreSQL o banco escolhia qual venda recebia a nota. Na cópia, nenhuma pesquisa tem itens
    de vendas diferentes (0 de 37.957; 2.298 têm dois itens ou mais), e por isso não há efeito medido hoje.
  - `VendaProdutoRepository::pesquisarUltimosServicos`, a última visita do cliente no Marcar da agenda
    ("Colaborador - Serviço | ...", `ClientesController::pesquisarUltimosServicosAction`): só `ven.id DESC` ordenava,
    e os itens da mesma venda empatavam -- a frase trocava de ordem no PostgreSQL (na cópia, 728.956 vendas fechadas
    têm dois itens ou mais), e numa venda de seis itens ou mais (6.480) o `setMaxResults(5)` deixava o banco escolher
    quais cinco entravam. Agora `ORDER BY ven.id DESC, venpro.id ASC`, a
    ordem do MySQL de hoje (lê pelo índice da venda). Nenhum portão a pegava: não é listagem paginada (a régua do D47)
    nem `setMaxResults(1)` (a do D72).
- **As 30 que ficam sem ordem** têm o motivo escrito no próprio caso (`SEM_ORDEM_PORQUE`): o getter sem chamada no
  código (17), o valor de um select múltiplo ou uma lista de `IN` (8), a conta que a ordem não muda (os colaboradores
  da unidade na rotina do NPS, um a um, e o cancelamento dos pontos, que junta os clientes num conjunto) e as três que
  já ordenam no `Criteria` do getter (as perguntas e as opções da pesquisa, o DP-12; os pagamentos da parcela). O caso
  `testCadaColecaoTemOrdemOuOMotivoDeNaoPrecisar` lê o mapeamento inteiro: uma coleção nova sem ordem e sem motivo fica
  vermelha ali.
- **O custo:** no MySQL, o `EXPLAIN` da busca da coleção pela venda (e pelo caixa) com `ORDER BY id` lê o índice da
  chave estrangeira, sem `filesort` -- a ordem já era essa. No PostgreSQL, uma ordenação de poucas linhas.

**Provado** (`D490OrdemDasColecoesTest`, 16 casos depois da verificação, nos dois bancos): o cenário grava as linhas
com o id embaralhado (o 3, o 1, o 2), e no PostgreSQL a busca sem `ORDER BY` as devolve nessa ordem -- a mensagem do
assert a escreve. Treze mutantes da fatia, um por vez, restaurados e conferidos pelo sha256: os seis da ordem no
mapeamento deixam vermelho, no PostgreSQL, o caso da lista (o dos itens e o dos pagamentos levam junto o do JSON da
comanda; a mensagem do primeiro: "a ordem do banco: 90490003, 90490001, 90490002") e, nos dois bancos, os dois casos
do mapeamento; o do desempate no `Criteria` das parcelas, o caso das parcelas no PostgreSQL; o do desempate no
mapeamento das parcelas, o caso do mapeamento nos dois; e os cinco das DQL, cada um o seu caso no PostgreSQL.

**A verificação (16/10/2026) fechou as três lacunas da prova**, com quatro casos novos e dez mutantes -- três novos
(N1, N2, N3) e sete da fatia rodados de novo (M1, M7, M9 a M13) --, um por vez, restaurados pelo sha256:

- **O MySQL agora prova as consultas.** O caso `testAsConsultasPedemAOrdemAoBanco` lê o SQL que chega ao banco (o molde
  do D426) das cinco DQL, da última visita na agenda e do `Criteria` do `getParcelas` -- este o caso do mapeamento não
  pegava, porque o `Criteria` vence o mapeamento. Os seis mutantes que antes ficavam verdes no MySQL (o desempate no
  `Criteria` das parcelas e os cinco `ORDER BY` das DQL) ficam vermelhos nele agora, e no PostgreSQL levam também o caso
  de resultado de cada um.
- **O caminho de verdade.** `testAjustarPelaApiALinhaDeMenorIdNaoAMudaDeLugar`: três cortes lançados pela API da tela
  nova, e a linha de MENOR id ajustada pela API; a resposta lê a comanda de volta do banco. No PostgreSQL o caso pede a
  leitura pela ordem do disco (`SET LOCAL enable_indexscan = off`, e os dois parentes, até o fim da transação do
  caso), e sem a ordem da coleção a linha ajustada sai no fim: "a ordem do banco: 25, 26, 24", `[25, 26, 24]` no lugar
  de `[24, 25, 26]`. **Sem o `SET LOCAL` o caso não prova nada** (o mutante N3: a ordem dos itens tirada e a leitura
  pelo disco desligada -- verde no PostgreSQL): num banco recém-montado o PostgreSQL lê pelo índice da venda, e a linha
  ajustada não muda de lugar nele, provavelmente porque o ajuste não muda coluna indexada. É o mesmo motivo de as oito
  rodadas do D450 da fatia (com o D490, o `AjustarItemDaComandaTest` e o `ComandaApiTest`, 49 casos) **não provarem o
  conserto**: cada rodada remonta o banco, e o D450 ficou verde também no mutante sem a ordem dos itens. O vermelho de
  antes veio do ciclo inteiro, duas vezes, com o disco já mexido.
- **As duas leituras novas.** `testANotaDoNpsVaiParaAVendaDoItemDeMenorId` roda a rotina do NPS pelo serviço (o molde
  do D176) com dois itens de vendas diferentes, o de id maior gravado primeiro: a média 8,25 vai para a venda do de
  menor id, e a outra fica sem nota. `testAUltimaVisitaNaAgendaEscreveOsServicosPeloId` despacha a ação da agenda com a
  lavagem, o corte e a barba gravados fora de ordem, e confere a frase. Sem a ordem dos itens do NPS (N2), o caso da
  rotina fica vermelho no PostgreSQL e os dois do mapeamento nos dois bancos; sem o desempate da última visita (N1), o
  caso da frase no PostgreSQL e o do SQL nos dois. O M1 (sem a ordem dos itens da venda) leva agora também o caso do
  ajuste pela API, no PostgreSQL.

**O que segue sem prova:** o resultado das consultas no MySQL -- o InnoDB devolve pelo id com ou sem o `ORDER BY`, e
lá só o SQL fica vermelho; nada aberto num navegador.

### D491 — O conferir do ambiente de teste parava para sempre no FIFO do systemd — CORRIGIDO em 14/10/2026

**Achado no BVIP, no primeiro deploy de teste depois do reinício (14/10/2026).** O `bash
scripts/deploy-teste.sh conferir` imprimia `==> conferindo sistema.franquiabv.xyz` e ficava mais de
dez minutos sem mais nada. Os processos, lidos pelo dono numa segunda sessão: o `grep -RlE` da
conferência do cron (`conferir_cron`) sobre `/home/bvip/.config/systemd`, `/run/user/1001/systemd`
e `/run/user/1000/systemd`, sem arquivo aberto -- parado no `open()`.

- O systemd mantém em `/run/user/<uid>/systemd/inaccessible/` (e em `/run/systemd/inaccessible/`)
  um **FIFO de modo 000**. Só o root o abre, e o `grep -R` rodado como root (o `sudo` do script)
  o abria para ler e esperava para sempre quem escrevesse.
- Nunca apareceu antes porque nenhum ensaio daqui roda com o systemd de verdade, e o conferir não
  tinha chegado a rodar no BVIP até o fim.
- O mesmo vale para o `publicar`, o `atualizar` e o `voltar`, que chamam a mesma conferência.

**O conserto:** `grep -D skip` nas duas buscas do `conferir_cron` -- só arquivo comum, nunca FIFO,
socket ou dispositivo; um timer ou um cron é arquivo comum. As buscas do nginx (`/etc/nginx`) não
passam por `/run` e ficaram como estavam.

**Provado** num contêiner Linux, como root, com o FIFO de modo 000 em
`/run/user/9999/systemd/inaccessible/fifo`: a função do HEAD não sai (o `timeout` de 20 s corta,
saída 124); a nova sai com "cron: nada agendado contra este ambiente" (saída 0) e, com um
`um.timer` que chama o domínio na mesma pasta, reprova citando o arquivo (saída 1).

### D492 — O publicar sondava o endereço antes de o nginx terminar o reload, e tirava do ar um site certo — CORRIGIDO em 14/10/2026

**Achado no BVIP, logo depois do D491 (14/10/2026).** O `base` carregou a cópia nova até o fim, e o
`SENHA_HTTP=sim bash scripts/deploy-teste.sh publicar` construiu a tela nova, conferiu tudo, subiu o
nginx da pilha e ligou o vhost ("nginx: ... no ar") -- e a primeira sonda (`/api/cronjobs/sonda-do-deploy`)
recebeu o certificado de OUTRO site: `curl: (60) SSL: no alternative certificate subject name matches
target host name 'sistema.franquiabv.xyz'`. O freio tirou o site do ar.

- O certificado em disco é o certo (`CN = sistema.franquiabv.xyz`, Let's Encrypt, até 31/12/2026), e o
  nginx do host é quem escuta a 443, em `0.0.0.0` e `[::]` -- conferido pelo dono, com `openssl x509`,
  `ss -ltnp` e `certbot certificates`.
- A causa é o `ativar_vhost`: o `systemctl reload nginx` só manda o sinal ao mestre e volta, e a sonda vem
  na linha seguinte. No BVIP o nginx atende uns treze sites com certificado, e ainda servia a
  configuração de antes do vhost quando a sonda chegou. No ensaio daqui (`ensaiar-build-do-publicar.sh`),
  com um site só, o recarregamento acaba antes da sonda.

**O conserto:** `esperar_o_vhost_no_ar`, entre o `instalar_vhost` e o `conferir_publico` do último passo
de quem põe o endereço no ar (o `publicar` e o `_aplicar` do `atualizar` e do `voltar`): até 30 s
tentando fechar o TLS do domínio pelo loopback (o `curl` sem `-f` sai 0 com qualquer código, o 401 da
senha também). Se não fechar, as sondas seguem e reprovam como antes, com o motivo. O `conferir` não
espera: com o site fora, ele mede o fora do ar na hora.

**Provado** num contêiner, com o `curl` dublado: três falhas de TLS e depois o certificado certo, a espera
sai com 0 na quarta tentativa (3 s); o certificado certo de cara, na primeira (0 s); nunca, sai com 1
depois de 30 tentativas (30 s). O recarregamento lento do nginx em si não foi reproduzido daqui.

### D493 — O ambiente de teste rodava com o MySQL e o PHP-FPM de fábrica, e a comanda e a agenda da tela nova ficavam lentas — CORRIGIDO em 14/10/2026

**Relatado pelo dono no primeiro dia do teste no ar (14/10/2026):** "A lentidão foi na tela nova, na comanda e
na agenda", comparando com a produção. Medido no BVIP, pelo dono, duas horas depois de subir:

- o MySQL da pilha `franquiabv-teste` com `innodb_buffer_pool_size` de **128 MB**, o padrão de fábrica (o
  `docker-compose.yml` não o define, e não havia `.cnf` no projeto), contra uma base de ~2,3 GB (1.140 MB de dado
  e 1.204 de índice; `agendas` sozinha, 617 MB);
- o contêiner do MySQL com **55,7 GB lidos do disco** em duas horas, e 957 mil leituras de página fora do pool
  (`Innodb_buffer_pool_reads`);
- o app com 4 processos: o PHP-FPM no padrão da imagem, `pm.max_children = 5`, e a tela nova manda vários
  pedidos de uma vez ao abrir a comanda e a agenda;
- e folga de sobra: 62 GB de memória (46 disponíveis) e 24 núcleos no BVIP, o MySQL com teto de 8 GB e o app com
  teto de 4 GB (o `docker-compose.homologacao.yml`). A carga média de 15 com os contêineres quase parados combina
  com processo esperando disco.

O D189 já tinha medido aqui a consulta da agenda em 21 a 36 s com 128 MB e 0,9 s com 2 GB.

**O conserto, só no teste:** o `docker-compose.teste.yml` monta `docker/mysql/teste.cnf` (`innodb_buffer_pool_size
= 4G`) em `/etc/mysql/conf.d/` e `docker/php/fpm-teste.conf` (`pm.max_children = 16`, `start 4`, `spare 2 a 8`) em
`/usr/local/etc/php-fpm.d/zz-teste.conf`. O `atualizar` os aplica: o `docker compose up -d --wait mysql app` do
`_aplicar` recria os dois contêineres, e o volume da base fica. A produção (cujo `innodb_buffer_pool_size` nunca
foi lido, o D189), a homologação e o desenvolvimento local não mudam.

**Provado:** a junção dos três composes é válida e leva as duas montagens; num `mysql:8.4` descartável, com o
`.cnf` em modo 644 (como no clone do Linux -- montado de uma pasta do Windows ele aparece 777, e o MySQL ignora
`.cnf` com escrita para todos), o pool sai com 4.480 MB (os 4 GB arredondados para cima pelo tamanho do bloco e
das instâncias); na imagem do app, o `php-fpm -tt` com o pool dá `pm.max_children = 16` e "test is successful".
O ganho no BVIP não foi medido daqui: depois do `atualizar`, os primeiros pedidos ainda leem do disco, até o pool
encher.

### D494 — A pergunta no próprio botão estava copiada em seis telas, e nas linhas o duplo clique no "não" voltava a perguntar — REGISTRADO em 15/10/2026 — CORRIGIDO em 15/10/2026

**Achado ao juntar as cópias** (a fatia PERGUNTA-NO-BOTAO, a 2 da fila das telas que faltam; o item 4 de
`docs/f3-telas-restantes/telas-consolidado.md`). O botão que pergunta e confirma -- o primeiro clique troca o rótulo
para "confirmar", o segundo age -- estava escrito seis vezes: no botão de estado do detalhe dos convênios, dos cupons e
dos usuários ("desativar"), e numa linha de cada vez nos pacotes (o "remover" do serviço), no estoque (o "cancelar" da
movimentação) e na fidelidade (o "cancelar" do pacote). As três do botão de estado ignoravam todo clique repetido
(`e.detail > 1`); as três das linhas, só quando a linha já perguntava (`pedindo && e.detail > 1`). E havia uma sétima
cópia da guarda, que a primeira versão deste texto não contou: o "desativar"/"ativar" do pacote, que não pergunta,
tinha o seu `e.detail <= 1` e o seu Enter seguro escritos à mão.

**O que isso fazia, visto no navegador** (num arnês temporário, com as peças puras e os dados das prévias, apagado no
fim; a 1280 de largura): o "não" sai da célula, a coluna da ação encolhe para a direita, e o botão da linha fica
debaixo do cursor -- na fidelidade, o "não" ia de 1.120 a 1.157 px, e o "cancelar" que sobra, de 1.096 a 1.157. O
segundo clique de um duplo clique no "não" chega a ele com `detail` 2, e no código de antes **abria a pergunta de
novo** -- na fidelidade e no estoque, os dois medidos assim. Foi só isso que se viu. A pergunta reaberta desenha a
célula como a primeira, com o "não" de novo debaixo do cursor, e o clique seguinte no mesmo ponto cai nele e desiste:
era um incômodo (a pergunta que não fecha), e não um caminho para cancelar sem querer. A primeira versão deste texto
dizia que esse clique seguinte cancelava; era dedução, e o código a desmente (a verificação de 15/10/2026). Nos
pacotes a coluna tem largura fixa (`.pacote-acao`), e o caso não foi medido ali.

**O conserto:** uma peça só, o `usePerguntaNoBotao` (`frontend/src/telas/perguntaNoBotao.ts`), no molde do
`useFocoDaLista` (o D261), com o `semRepeticao` que os cupons e a fidelidade tinham cada um o seu, e o
`cliqueRepetido`, que o botão de estado do pacote passou a usar. O clique repetido não age em nenhuma das seis: nem
confirma, nem pergunta, nem desfaz. O resto ficou como estava: a pergunta do botão de estado se desfaz quando o estado
relido muda (agora pela peça), uma linha pergunta de cada vez, e o "não" devolve o foco ao botão que perguntou. Na
mesma fatia, o "todas ou as marcadas" dos convênios (o D256) saiu para o `escolhaDoVinculo.ts` e o
`TodasOuAsMarcadas.tsx`, para os avisos, a VIP Class e os assistentes.

**O que muda para quem usa, nas três telas das linhas** -- e contraria o "sem mudar o que o usuário vê" da fatia, de
propósito: são dois casos, e o segundo a primeira versão deste texto não contou.

- O duplo clique no "não", acima: o botão fica em "cancelar", com o foco nele.
- O duplo clique no "confirmar" com a recusa rápida: o primeiro clique age, o 403 chega antes do segundo, e o botão
  volta a "cancelar" com a recusa no cartão. O segundo clique, com `detail` 2, apagava a recusa e voltava a perguntar;
  agora não faz nada, e a recusa fica. Deduzido do código, e não visto no navegador; o caso novo da fidelidade o fixa.

Voltar ao de antes **não é uma linha na peça**, como dizia o relatório da fatia: a regra antiga das linhas posta nela
derruba o "não reativa" dos convênios, porque nos botões de estado o segundo clique depois da resposta rápida cai no
"ativar", que não pergunta, e agiria. Seria uma opção por chamada, só nas linhas.

**A verificação de 15/10/2026 achou outro, que já era assim antes da fatia:** nas linhas, a pergunta sobrevivia à
lista. No estoque, a lista fica montada durante a releitura: perguntar o cancelamento, virar para a página 2 e voltar
trazia a linha em "confirmar o cancelamento", a um clique; o mesmo depois de lançar. Na fidelidade, o esgotado que
perguntou saía da tela com o "só os com sessões", e voltava perguntando. A peça só desfazia pelo estado relido no botão
único; agora as linhas também lhe passam o estado da lista -- o `relendo` do estoque, que liga a cada página e a cada
lançamento, e o "todos" da fidelidade --, como a recusa, que já saía nos mesmos gestos. Nos pacotes, o "atualizar"
desmonta a lista, e o caso não existe. Também é uma mudança que se vê: a linha que volta à tela volta em "cancelar".

**Na mesma verificação**, sem mudança na tela: o estado da pergunta passou a ser um valor simples no tipo -- o objeto
literal travaria a tela em laço ("Too many re-renders"), e a função o `useState` tomaria por inicializador --, e o `tsc`
o confere com duas diretivas no caso da peça; a peça ganhou os casos do passo `'perguntou'`, por onde as linhas limpam a
recusa, e do teclado (`detail` 0); os cupons, os dois que faltavam (o "atualizar" e a recusa que não é conflito desfazem
a pergunta), sem os quais tirar as duas ligações deixava o vitest verde; e a escolha do vínculo, o `camposDoVinculo`,
de onde as marcadas partem -- as ligadas, as inativas também --, em vez de cada tela.

**Provado:** os casos que já existiam nas seis telas passam sem mudança; a peça tem 13 casos, e a escolha do vínculo,
14; as telas ganharam 8 (os três da recusa nas linhas, os dois dos cupons, o da página e do lançamento no estoque, e os
dois da fidelidade: a recusa rápida e a troca da lista). No navegador, o mesmo duplo clique real, no "não" da fidelidade
e no do estoque: no código de antes, o botão da linha recebe o segundo clique com `detail` 2 e mostra "confirmar"; no
de agora, recebe o mesmo clique e fica em "cancelar", com o foco nele. Os mutantes, um de cada vez: o `detail`
arrancado da peça derruba 10 casos em 7 arquivos; a regra antiga das linhas posta na peça derruba 4 (o caso novo da
peça, o da ação que não pergunta, o "não reativa" dos convênios e o da recusa rápida da fidelidade); e os da
verificação estão na entrada do `docs/roadmap.md` de 15/10/2026.

### D495 — Seis textos que a API grava não passam pela RegraDoTexto, e aceitam `<`, `>` e caractere de controle — REGISTRADO em 15/10/2026 — PARCIAL em 07/11/2026: as seis regras passam pela peça, e o texto gravado não trava a edição; falta o cliente na tela antiga e nas etapas da fidelidade

**Achado pela verificação da fatia REGRA-DO-TEXTO** (a 3 da fila das telas que faltam; o D344). A
`docs/convencao-api.md` dizia que todo texto de uma linha que a API grava passa pela
`ISBase\Regra\RegraDoTexto`. Passam quatro: o nome do item do catálogo, o do pacote, o do convênio e o do
cupom (a pesquisa também usa a peça, mas na tela antiga: ainda não tem API). Seis têm a sua conferência própria, com o
obrigatório ou o teto, e três delas o emoji, mas sem a recusa do `<` e do `>` nem a do controle:

- a descrição da saída (`RegraDaSaida::problemaDaDescricao`, também no `SaidaFilter` do ERP);
- a observação da remarcação (`RegraDaRemarcacao`);
- o nome do amigo da fidelidade (`FidelidadeService::FORA_DO_QUE_O_NOME_GUARDA`);
- o nome e os textos do cliente (`RegraDoCadastroDeCliente`, na API, na tela antiga dos clientes e na fidelidade);
- o motivo do desconto do item da comanda (`RegraDoItemDaComanda`);
- o motivo do fechamento da agenda (`RegraDeFechamento`).

**Na cópia** (`franquiabv_dev`, SELECT de contagem): nenhum dos 242.597 nomes de cliente tem `<`, `>` ou
controle; das 13.238 descrições de saída, uma tem `<` ou `>`, e nenhuma tem controle. As outras quatro
colunas não foram contadas.

**O risco** é o do D203: o texto que sai cru numa tela PHP. As listas e os detalhes que o D203 já escapou
não abrem nada; o que resta dele é a forma do getter em 33 telas, que as fatias das telas varrem.

**O que se faz:** cada um passa à peça na fatia da sua tela, com a contagem da sua coluna antes -- a
descrição de saída que já tem `<` deixaria de se editar sem tirá-lo, e o nome do cliente chega por três
telas. Nenhum deles foi mudado agora: a fatia 3 era a peça e o primeiro
uso, e a convenção passou a dizer quais a usam.

#### PARCIAL em 07/11/2026 -- as seis regras passam pela `RegraDoTexto` (a fatia 110, TEXTO-NAS-REGRAS)

**A contagem na cópia** (`franquiabv_dev`, só SELECT, em utf8mb4): das 13.238 descrições de saída, 1 tem o
sinal (`>`, "x > y") e nenhuma tem controle; dos 134.235 motivos de fechamento, 15 têm o sinal (o "->", "x > y") e
nenhum tem controle; dos 191.045 motivos de desconto preenchidos, 4 têm o sinal ("->") e 1 tem tabulação; dos
nomes, e-mails, CPFs, origens, CEPs, logradouros, números, bairros e cidades de cliente que a anonimização deixa,
nenhum tem o sinal ou controle. **A cópia não diz** as observações do agendamento, as anotações, as profissões e os
complementos: a anonimização os apaga (e troca os nomes e os endereços).

**O que mudou**, cada um pela peça e com a frase do campo:

- **A descrição da despesa** (`RegraDaSaida::problemaDaDescricao`): o sinal e o controle, depois do emoji e do teto
  como fica gravada; o texto que não é UTF-8, que o `=== 1` deixava passar para a normalização (um Deprecated),
  cai no "não guarda emoji". **O formulário do ERP perdeu o `StripTags`** da descrição (o molde do D344): ele
  cortava calado -- "x > y" virava "x  y" a cada edição da despesa que o tinha.
- **A observação do agendamento** (`RegraDaRemarcacao::conferirPedido`, no marcar e no remarcar da API e da tela
  antiga): pela peça de VÁRIAS linhas (`conferirComQuebras`) -- o campo da tela antiga é um `Textarea`, e a quebra
  serve; o sinal e a tabulação, não. A vazia, ou só de invisível, continua passando.
- **O nome do amigo da fidelidade** (`FidelidadeService::indicarAmigo`): pela peça, com a frase da forma de sempre
  (`AMIGO_FORA_DA_FORMA`, que passou a dizer "sem < nem >"), com o telefone de cliente ou não.
- **Os textos do cliente na API** (`RegraDoCadastroDeCliente::conferir`): nome, e-mail, CPF, RG, profissão, origem e os
  seis do endereço, de uma linha; as anotações, de várias. O emoji também: nenhuma conferência o recusava, e o
  nome com emoji era 500 no MySQL.
- **O motivo do desconto da comanda** (`RegraDoItemDaComanda`, na API e no PDV antigo): o emoji (também era 500),
  o sinal e o controle, procurado antes do `trim`.
- **O motivo do fechamento da agenda** (`RegraDeFechamento::problemaDoMotivo`, nas duas portas; ver o D421).

**O que já está gravado não trava a edição.** Quem altera e manda de volta o texto que está gravado passa; só o
novo ou mudado é conferido contra o sinal e o controle (o emoji e o teto valem sempre: a coluna não guarda
nenhum dos dois): a descrição da despesa (a API e o formulário do ERP recebem a gravada), a observação no remarcar
(o modal antigo vem com ela preenchida), o `PUT` do cliente (o cadastro como o `GET` o devolve) e o motivo da linha
que se ajusta. O fechamento não se edita, e o amigo e o cliente novo não têm o que poupar.

**Falta** (por isso PARCIAL):

- **O cliente nas outras duas portas:** a tela antiga dos clientes (o `ClienteFilter` corta o sinal com o `StripTags`,
  calado, e o `ClientesController` do ISCadastro grava) e as etapas da fidelidade (`FidelidadeService::salvarDados`
  grava o pedido cru). **Nenhuma fatia da fila a tem** (a verificação de 07/11/2026 leu os títulos e os débitos das
  fatias até a 130, e fez grep por `ClienteFilter` e `salvarDados`; a 117, USUARIOS-FICHA-ANTIGA, é a ficha do
  usuário -- o D476 é o "Meus dados" do `UsuariosController` --, e não o cadastro de clientes; não há desenho dessa
  tela em `docs/f3-telas-restantes`): fica aqui, no D495, para a próxima triagem dos débitos de pé dar a fatia.
- **A sangria, a terceira porta da descrição da despesa** (o item 5 de `tela-saidas.md`: a API, o ERP e a sangria).
  A retirada do caixa (`Adm\CaixasController::retiradaAction` e a tela antiga) confere só o vazio, o teto e o emoji
  do motivo (o D393); com o "financeiro", o `CaixaService` grava esse motivo em `saidas.descricao` pela
  `RegraDaSaida::descricaoDaSangria`, que corta no teto e não recusa -- "x < y" chega à descrição. O desenho manda
  cortar, e não recusar, ali (os itens 5 e 8); recusar o sinal no motivo da retirada é conferência nova numa porta
  que não é uma das seis daqui, e entra com a do cliente.
- **As telas novas que validam do lado delas** (Contas a Pagar, a agenda, a comanda) não conhecem o sinal: a recusa
  chega pelo 422 do servidor.

**Os testes:** o `TextoNasRegrasTest` (novo) e casos novos nos vizinhos que despacham as portas de verdade
(`D206D207SaidasPelaRotaNoErpTest`, `AgendaAntigaComoANovaTest`, `RemarcacaoDeAgendamentoTest`, `D477DescontoAteOValorDoItemTest`,
`D304EtapaSeisDaAdesaoTest`), nos dois bancos; os mutantes M1 a M15, um por vez, vermelhos nos dois bancos e devolvidos
pelo sha1.

**A verificação de 07/11/2026** (um segundo agente tentando derrubar) confirmou, e a correção consertou com caso:

- **A observação de várias linhas travava a remarcação no modal antigo.** Ela é gravada com `\n` (o `val()` do jQuery
  tira o `\r`), e o modal a devolve com `\r\n` (o `serialize()` troca cada `\n`): a mesma observação parecia mudada,
  e a com o sinal era recusada. E a comparação usava o `trim` do PHP, que tira também a tabulação, o `\x0B` e o NUL
  das pontas -- "\tPrefere tesoura\x0B" contra a gravada "Prefere tesoura" passava sem conferência, e a observação é
  gravada crua. Agora as duas se comparam com a quebra numa forma só (`\r\n` vira `\n`) e sem o espaço e a quebra
  das pontas, e mais nada (`RegraDaRemarcacao::comoSeCompara`). Os mutantes N1 e N2.
- **O motivo do desconto que não é UTF-8 pulava a conferência inteira** (o `=== 1` do "algo que se vê" com o
  `preg_match` que devolve `false`), e o PDV antigo o gravava: 500 nos dois bancos, de antes da fatia. Agora é
  recusado no emoji, como nas outras regras. O mutante N3.
- **O cupom com `>` no nome deixava de se aplicar no PDV antigo:** o motivo do cupom é o que o próprio PDV escreve
  (`RegraDoCupom::motivoNoPdv`), e o formulário antigo dos cupons deixa o `>` solto no nome. No PDV antigo, o motivo
  com a marca do cupom passa pela peça com o sinal permitido (`conferirComSinalDeTag`): o emoji e o controle
  continuam recusados, e na API a marca segue reservada. Na cópia, nenhum dos 146 cupons tem o sinal. O mutante N4.
- **O nome do amigo só de espaços passou a ser recusado** (antes, o amigo nascia com o nome " "): a mudança ficou,
  agora com caso no `D304EtapaSeisDaAdesaoTest`. O mutante N7.
- **O `!== 0` do emoji na descrição da despesa não tinha caso vermelho:** com o `=== 1`, a frase chegava a mesma pela
  peça, depois de um Deprecated da normalização, e o phpunit.xml não falha no Deprecated. O caso novo conta os avisos
  do PHP na chamada. O mutante N6.

### D496 — O convênio da agenda vale em qualquer item marcado, e o do caixa não: o "+" da tela nova tiraria esse desconto — REGISTRADO em 15/10/2026; a pergunta foi ao lote — CORRIGIDO em 15/10/2026 (a 227 (a))

**Achado na fatia 4 (RESPOSTAS-DO-BALCAO), lido no código e medido; o alcance corrigido pela verificação da fatia
no mesmo dia.** O lançamento (`VendaProdutoService::calcularLinha`, o PDV antigo e a API da tela nova) dá o convênio
só no item do tipo `ser` -- a regra mora desde a verificação em `RegraDoItemDaComanda::oCaixaDaOConvenio`, e a conta
do lançamento a lê. A comanda aberta pela agenda (`VendaService::comandaAbrirAgenda`) não olha o tipo: dá o convênio
em qualquer item marcado no horário -- a lavagem (`lavavip`, "Lava VIP"), o produto da barbearia (`probar`), o do
empório (`proemp`). A primeira escrita deste débito falava só da lavagem; a agenda também marca produto.

Desde a resposta 90 (o D363) a linha da agenda leva o motivo "Convênio X", e a tela nova a reconhece pelo prefixo: o
"+" e o "−" mandariam só a quantidade, e o servidor, que recalcula pela conta do caixa, tiraria o desconto sem aviso
(o caso que prendia essa conta, o da pomada da agenda, saiu com a resposta 227 (a): desde ela, quem a prende é o
`testOAjusteSoDaQuantidadeTiraODescontoDaLinhaJaGravada`, sobre a linha já gravada). Antes daquela data o "+"
recusava ("desconto sem motivo").

**O que a verificação fechou, sem decidir pelo dono (15/10/2026):** a comanda marca a linha
(`convenioQueOCaixaNaoDa`, de `RegraDoItemDaComanda::temOConvenioQueOCaixaNaoDa`: o prefixo do convênio, desconto
de fato, e o tipo em que o caixa não o dá), e a tela nova recusa nela o "+" e o "−", com a frase "veio da agenda com
o desconto do convênio, que o caixa não dá neste item" -- o padrão da resposta 177, que não perde dado. O "desconto"
do balcão já não aparecia nela (o prefixo). O "tirar" segue, e o lançamento novo sai pelo caixa, sem o convênio.

**Provado** nos dois bancos (`D363D465ComandaPelaAgendaComoOPdvTest`): a pomada e a lavagem marcadas na agenda levam
o convênio e o motivo (o estado de hoje, preso até a resposta), vêm marcadas na API, e sem convênio não vêm; e o
ajuste só da quantidade tira o convênio da pomada. No front, `ComandasLigada.teste.tsx`: o "+" e o "−" recusam com a
frase, sem pedir. **Mutantes**, cada um sozinho: a marca sempre falsa (2 vermelhos); o motivo só no `ser` (4); o
caixa dando em todo tipo (4); a conta do lançamento em todo tipo (1); no front, sem a recusa (1).

**Na cópia** (`franquiabv_dev`, só contagens, 15/10/2026): na agenda desde 2025, 552.116 horários do tipo `ser`, 4 de
`probar` (o último em 01/09/2025, nenhum de cliente com convênio hoje) e 1 de `proemp` (17/07/2025, de cliente com
convênio hoje); de `lavavip`, 24 em toda a história, o último em 14/07/2020, nenhum de cliente com convênio hoje. Nas vendas, 210
itens de produto com desconto e sem motivo em venda de convênio -- 85 `probar` e 125 `proemp`, R$ 2.918,34, o rastro
da agenda --, o último em 06/04/2020; nenhum desde então. Ninguém é alcançado hoje.

**É do dono** (qual item o convênio cobre é regra do contrato com a conveniada), e foi ao lote: (a) só no serviço,
pelas duas portas (a agenda deixa de dar na lavagem e no produto); (b) no serviço e na lavagem, pelas duas portas; (c)
em tudo o que a agenda marca, pelas duas portas; (d) como está, com a linha da agenda marcada. A recomendação é a (a).
Escolhida a regra, ela muda em `oCaixaDaOConvenio` e na agenda.

#### CORRIGIDO em 15/10/2026 -- a resposta 227 (a) (a fatia 4b, RESPOSTAS-DE-15-10)

**Decidido pelo dono em 15/10/2026** (a pergunta 227 da página do lote, respondida às 11:29, a opção (a), a recomendada):
*"Só no serviço, como o caixa: a comanda aberta pela agenda dá o convênio só nos itens do tipo Serviço."* A regra do caixa
não muda; a agenda passa a segui-la.

**O conserto:** `VendaService::comandaAbrirAgenda` pergunta `RegraDoItemDaComanda::oCaixaDaOConvenio` antes de dar o
convênio, a mesma regra que `VendaProdutoService::calcularLinha` lê: a lavagem e o produto marcados no horário entram pelo
preço, sem desconto e sem motivo, como no PDV antigo e na API da tela nova. As três portas dão o convênio no mesmo tipo.
O serviço não muda (o motivo "Convênio X" da resposta 90, a comissão da 81, a sessão do pacote).

**A marca `convenioQueOCaixaNaoDa` fica, para o que já está gravado** (o "+" e o "−" recusam), e alcança duas linhas: a
que a agenda gravou com o código da fatia 4 (o commit `9b8dcb2`, de 15/10/2026), que não chegou à produção — lá, a agenda
de julho grava o convênio sem motivo nenhum, e essa linha é a do desconto sem motivo (a tela pede o motivo antes do "+");
e **uma linha nova, que a marca ainda alcança:** o motivo digitado no balcão que começa por "Convênio " num item que não é
serviço, com desconto, pelo PDV antigo ou pela API. A tela a reconhece pelo prefixo, e sem a marca o "+" mandaria só a
quantidade, e o desconto digitado sairia calado. **Na cópia, nenhuma** (`franquiabv_dev`, só contagens, 15/10/2026,
pelo HEX do prefixo, como o `str_starts_with` e o `startsWith` da tela comparam): as 20.997 linhas que começam pelos
bytes de "Convênio " são todas de serviço. A primeira escrita deste parágrafo dava 7 linhas, todas `probar`, a última de
03/10/2022: a conta foi feita pela collation da coluna (`utf8mb3_general_ci`, que ignora caixa e acento), e as 7
começam por "convenio" (4), "CONVENIO " (1), "Convenio" (1) e "CONVENIO" (1) — nenhuma vem marcada, e para a tela são
o desconto do PDV com motivo, que o "+" devolve (o `testAMarcaForaDoServicoLeOPrefixoPelosBytes`, no
`RegraDoItemDaComandaTest`, prende a comparação). A linha nova que a marca alcança existe só em princípio. Por isso a
frase da recusa deixou de dizer de onde a linha veio: era "veio da agenda com o desconto do convênio...", e passou a
"tem o desconto do convênio, que o caixa não dá neste item. Mudar a quantidade aqui tiraria o desconto; para mais
unidades, lance outra linha." (`ajusteDaLinha.ts`).

**Provado** nos dois bancos, em série (`D363D465ComandaPelaAgendaComoOPdvTest`, que trocou os casos do estado de antes):
a pomada e a lavagem marcadas na agenda do cliente de convênio saem pelo preço; a agenda e o PDV gravam o mesmo item (o
valor, o desconto e a comissão; o motivo vazio nas duas, `''` no PDV e nulo na agenda); a pomada da agenda não vem mais
marcada, e o "+" segue o preço; a linha já gravada com o convênio fora do serviço vem marcada, e o ajuste só da quantidade
tiraria o desconto dela (o porquê da marca); o motivo digitado com o prefixo num produto do PDV vem marcado; e a linha de
julho (o desconto sem motivo) segue a do desconto sem motivo. Na caracterização, 129 casos nos cinco arquivos tocados e
vizinhos, verdes no MySQL e no PostgreSQL; no front, 1.785 no vitest. **Mutantes**, cada um sozinho (MySQL): a agenda sem
a regra do caixa (5 vermelhos); a marca sempre falsa (3).

### D497 — A volta da etapa 1 devolve as rotinas ao pool do site, e o teto curto do D67 as mataria nele — REGISTRADO em 15/10/2026; a pergunta foi ao lote — DECIDIDO em 15/10/2026: a 228 (a)

**Achado pela verificação da fatia 4b (RESPOSTAS-DE-15-10), lido no documento; nada medido.** O **D67** (decidido em
31/08/2026: "fica para o desenho do corte") pede três coisas ao php-fpm, e a segunda é "pool separado para as rotinas de
cron, com o do aplicativo em teto curto". A resposta 205 (a) do dono põe o php-fpm na janela e diz: *"A volta não precisa
desfazer nenhum dos dois; com o vhost de antes, as rotinas voltam ao pool de todos."* As duas juntas não fecham:

1. a janela libera com o pool das rotinas no teto longo e o pool do site no curto;
2. dias depois vem uma volta: a cópia do vhost do passo 3 volta, com o `fastcgi_pass` único, e o php-fpm fica como está;
3. o crontab de antes volta, e `/api/cronjobs/*` cai no pool do site, que tem o teto curto;
4. a rotina que passar desse teto é morta pelo processo mestre, toda noite, e o crontab de antes cala o erro (o
   `> /dev/null 2>&1`). A mais longa das que ficaram não foi medida (o item 4 de "O que falta medir" do
   `docs/procedimento-de-corte-etapa-1.md`); o crontab dá 600 s ao `relatoriosRoyaltiesAtualizar`, o retrato que a fatura
   do dia 16 soma.

A primeira escrita da 205 (a) no procedimento (a fatia 4b) dizia, no passo 8, "o resto segue no pool do site, com o teto
curto", e, na volta, "nada a desfazer".

**O que ficou, sem decidir pelo dono:** o procedimento (o passo 8, o 9, o item 7 de "pronto", o item 14 de "O que falta" e
a volta) põe o pool do site, até a resposta, **com o teto das rotinas** (o requisito 1 do D67, acima de 21 minutos), e não
com o curto: a volta segue sem nada a desfazer, como a letra da 205 (a), e nenhuma rotina morre nela. O preço é o de hoje
num ponto: um pedido do site pendurado prende um processo do pool do site por até meia hora. O pool separado, que já entra
na janela, tira as rotinas do caminho do aplicativo — o porquê do requisito 2 — nos dias em que não há volta.

**É do dono**, e foi ao lote (TELAS-RESTANTES [corte], 15/10/2026): (a) o teto curto do site só depois de ele declarar o
fim da volta, numa entrega à noite, como as outras; (b) o teto curto já na janela, e a volta o desfaz, no recarregar que
ela já faz; (c) o teto curto já na janela, e a volta mantém o pool das rotinas e a entrada delas no vhost de antes. A
recomendação é a (a).

#### DECIDIDO em 15/10/2026 -- a resposta 228 do dono, opção (a) (a fatia 5, RESPOSTAS-DO-PROGRAMA)

**A decisão:** *"O tempo curto do site só depois de você declarar o fim da volta — até lá, os dois grupos com o tempo das
rotinas; depois da declaração, o tempo curto entra numa entrega à noite, como as outras. A volta fica como a sua 205 (a),
sem nada a desfazer."* É o que o procedimento já seguia até a resposta. No `docs/procedimento-de-corte-etapa-1.md`, o "até a
resposta do D497" virou "decidido em 15/10/2026, a 228 (a)" — no cabeçalho, no item 7 de "pronto", no item 14 de "O que
falta" e no parágrafo depois dele, nos passos 8 e 9, na volta e nas decisões do dono —, "Depois da janela" ganhou a entrega do
teto curto, e "Até quando se volta" o pôs entre o que espera a declaração (a verificação do mesmo dia): depois da declaração, numa noite, sem migração nem troca de árvore — o arquivo do pool do site com o teto
curto, o recarregar e a conferência. O preço, que a opção dizia: até a declaração (no mínimo 48 horas), um pedido do site
travado pode prender um processo do site por até meia hora. Nada no código.

### D498 — O backup da janela da etapa 1 ainda não tem o comando do passo 3, nem o roteiro da restauração com as três réplicas — REGISTRADO em 15/10/2026

**Achado pela verificação da fatia 4b (RESPOSTAS-DE-15-10).** A resposta 204 (a) do dono decidiu a forma do backup da
janela: *"O dump da rotina, disparado à mão na janela [...]; e a restauração dele ensaiada antes, numa máquina que não é a
produção (o roteiro dela, com as três réplicas, faz parte do ensaio)."* O `docs/procedimento-de-corte-etapa-1.md` (o passo
3) escreve a ordem, a espera do dump de hora em hora que já disparou e as conferências. O item 8 de "O que falta fazer
antes da janela" trazia "a registrar" na coluna do débito, e o relatório da fatia deixou o roteiro de fora do que ficou
por fazer. Falta:

1. **o comando do passo 3**, o da rotina como ela roda, sem mudá-la: espera os quatro fatos que a pergunta 204 pedia e que
   não vieram com a resposta — se uma restauração já foi testada; o minuto, o usuário e o comando do `dumpv3.sh`; se ele
   leva os eventos e a posição do log binário; e onde ele grava — e o destino de fora do servidor. São do dono (a leitura
   14), e não escolha;
2. **o roteiro da restauração num primário com três réplicas**: se o dump atravessa a replicação ou se as réplicas se
   refazem dele; o `GTID_PURGED` que o `mysqldump` escreve, se o servidor usa GTID; e a versão de cada réplica contra a do
   primário (as leituras 1 e 21). É escrita do repositório, com essas leituras na mão;
3. **o ensaio da restauração**, numa máquina que não é a produção: do dono. Nenhum ensaio da etapa 1 no BVIP o cobre (lá
   não há réplica).

A janela não começa sem os três (o item 10 de "O que tem de estar pronto"). Nada no repositório copia a produção.

### D499 — O ensaio de permissão saía 1 no MySQL no melhor da rede: a suíte, como root, deixava a trava do Profissional Parceiro que a web não abria, e a resposta era 200 com a página de erro — REGISTRADO em 15/10/2026 — CORRIGIDO em 15/10/2026

**Achado pelo ciclo, três vezes** (a R31a em 13/10/2026, o a56bdb8 e o 2da725c em 15/10/2026):
`scripts/ensaiar-permissao-adm-api.php` saiu 1 no MySQL sempre na mesma linha — `com a marca GET
/adm/api/profissionais/destaques 200 <-- ESPERAVA 200` — e passou sozinho logo depois, no mesmo banco de contrato; no
PostgreSQL, nunca.

**Não era a ordem das linhas.** A hipótese era um `ORDER BY` sem desempate que pusesse como destaque ora o próprio
colaborador, ora outro com o nome à mostra. Lido o código: a lista do melhor da rede sai de
`ContasDoPainelDaUnidade::colaboradores`, ordenada em PHP pelo faturamento e depois pelo id;
`ContasDoProfissionalParceiro::destaques` só troca de linha com valor estritamente maior, e o empate fica com o primeiro,
sempre o mesmo (o D47); a
consulta (`VendaRepository::painelItens`) termina em `ORDER BY ven.id, venpro.id`. E `ProfissionalParceiroJson::destaques`
publica o `colaborador` só no escopo `rede`: quem não administra nunca vê quem é, nem quando é ele mesmo (a resposta 11). O
ensaio está certo em recusar o nome no escopo `proprio`.

**Era a trava do D311, envenenada como a do D98.** `destaquesAction` pede
`TravaDeExecucao::obter('parceiroMelhorDaRede-' . usuarioId)`, e quem cria o arquivo em `/tmp` vira dono dele, com 0644.
O `ProfissionalParceiroTest` pede o melhor da rede como o colaborador 90001000 — o id do `colab.teste` do ensaio —, e a
suíte roda como root no contêiner: o arquivo nascia de root. O ensaio, pela web (o php-fpm, como www-data), o abria para
escrever; o `fopen` recusava, o aviso saía antes da resposta (o `display_errors` do local) e a `RuntimeException` vinha
atrás: **200 com a página de erro no corpo**, que o ensaio, que só aceita 200 com JSON, recusava. Passava "sozinho" porque
o roteiro dos ensaios do PostgreSQL apaga `/tmp/*.lock` antes de começar, e o pedido seguinte recriava o arquivo do
www-data: no PostgreSQL o ensaio nunca encontrava o de root. Falhava só nos ciclos em que o arquivo não existia quando a
suíte rodou. O CI não tem os dois donos: a suíte e o `php -S` rodam com o mesmo usuário.

**Quem apagava o arquivo antes de cada falha** (lido na verificação de 15/10/2026, nas transcrições e nas datas dos
arquivos do rascunho): uma rodada PARCIAL do roteiro do PostgreSQL. Depois da primeira rodada inteira, o arquivo é do
www-data e fica — com `fs.protected_regular` em 0 no contêiner (medido), o root da suíte o abre sem trocar o dono. Uma
rodada inteira o apaga e o recria como www-data no próprio ensaio de permissão; uma parcial, com só o ensaio das escritas
do aplicativo, o apaga e não o recria, e a suíte do ciclo seguinte o cria como root. Foi o que precedeu as duas falhas
de 15/10/2026: antes do E1-APP, três rodadas de `medir-ensaios-pg.sh ensaiar-escritas-do-aplicativo` (23:13 e 23:41 de
14/10, e uma com o ensaio da sessão às 00:50 de 15/10), e a saída da falha guardada às 01:40; antes do f4b, uma às
12:33 de 15/10 (`ensaios-pg-4b.txt`, de 12:34), e a saída da falha guardada às 13:52. Na R31a, em 13/10/2026, a trava era código novo, e quem a criou
primeiro foi a suíte. O "passou sozinho" vale, então, só depois de uma rodada inteira.

**Reproduzido nos dois sentidos** (15/10/2026, no MySQL de contrato, com o código de antes): com o arquivo apagado,
`--filter testOMelhorDaRedeComOVolumeMinimo` o deixou `root 644`, e o ensaio saiu 1 na mesma linha — o corpo, que o
ensaio agora imprime, era `Warning: fopen(/tmp/parceiroMelhorDaRede-90001000.lock): Failed to open stream: Permission
denied in .../TravaDeExecucao.php on line 62 An error occurred`; com o mesmo arquivo passado ao www-data (`chown`) e o
banco montado de novo, o ensaio saiu 0.

**O conserto, em `TravaDeExecucao::obter`:** o `flock` não pede escrita — o arquivo que não abre para escrever abre para
ler e trava igual. É UMA trava para os dois donos, e não uma por usuário, que era a objeção do D98 a trocar o nome; só o
pid deixa de ser escrito por quem só lê. As duas aberturas vão com `@`, e o motivo do sistema vai na exceção: o aviso
impresso antes da resposta é o que fazia o erro sair com 200. Vão os DOIS motivos, o da escrita primeiro (a verificação
de 15/10/2026): o `@` não tira o erro do `error_get_last()`, e o último vence — com a pasta sem escrita ou o disco cheio,
a leitura falha com "No such file or directory", e só esse motivo esconderia o da criação. **O irmão:** `CrudController::acquireLock` (o do código do
WhatsApp, `whatsappCodigo<unidade>`) era uma cópia da trava, com o mesmo veneno — e a exceção dela nem existia: o
`RuntimeException` sem `use`, no namespace `ISBase\Controller`, daria "classe não encontrada" no lugar da frase —, e
passou a delegar a ela; `CronJobService::acquireLock` já delegava. E o ensaio imprime o começo do corpo que não é JSON, para a próxima falha não
pedir leitura à mão.

**E a conferência do ensaio era vazia** (a verificação de 15/10/2026). O ensaio pede o melhor da rede de fevereiro de
2026, e nas fixtures ninguém chega aos 30 atendimentos do mínimo nesse mês — o `colab.teste` tem dois: a resposta vinha
com `concorrentes: 0` e os dez destaques nulos, e "nenhum destaque traz o nome" contava zero sempre. Com a API publicando
o nome de quem fosse o melhor em qualquer escopo, o ensaio continuaria verde; pela HTTP, a linha provava só "200 com
JSON". Agora o roteiro dá ao colega da mesma unidade (o 90001003, `evasao.teste`) tantas vendas finalizadas de um serviço
no mês quanto o mínimo da regra, numa faixa de ids que nenhuma fixture usa (90009600 em diante), e as apaga no `finally`,
com o privilégio; e a conferência exige `concorrentes` ≥ 1 e pelo menos um destaque preenchido, todos com `colaborador`
nulo. Quando não é o certo, diz o que veio — os concorrentes, os preenchidos e o id de quem aparece.

**Provado** (15/10/2026): `tests/Caracterizacao/D499TravaDeOutroDonoTest.php`, sem banco — como root, o outro dono é o
www-data (`posix_seteuid`); sem root, como no CI, o próprio dono sem a escrita. O arquivo de outro dono que se lê trava,
nos dois sentidos, sem aviso e sem escrever o pid; o que nem se lê recusa com a exceção e o motivo, sem aviso; e a do
controller é a mesma trava. Verde no MySQL, no PostgreSQL e como www-data. Cinco mutantes vermelhos, um por vez (sem a
leitura, sem o `@`, o pid escrito por quem só lê, sem o motivo, o controller com a cópia antiga), e o arquivo do HEAD
inteiro vermelho nos três casos, como root no PostgreSQL e como www-data. O ensaio de permissão saiu 0 três vezes seguidas
no MySQL, cada uma com o banco de contrato montado de novo e a trava plantada como root — o estado que derrubava —, e uma
vez no PostgreSQL com a mesma planta, feita como root logo depois do `rm -f /tmp/*.lock` do roteiro (o
`medir-ensaios-pg-plantado-t1.sh` é o de sempre com o `touch` e o `chmod 0644` a mais); a trava continuou de root, e
vazia.

**Na verificação de 15/10/2026** (cinco achados, todos tratados — os quatro de cima e a redação da planta):
o caso novo `testOArquivoQueNaoNasceDizOMotivoDaCriacao` monta a pasta sem escrita para quem pede, pelo nome da trava
(o `sys_temp_dir` não muda depois de o PHP subir), e três mutantes ficam vermelhos nele, um por vez, como root e como
www-data — só o motivo da leitura (o código de antes: a exceção dizia "No such file or directory"), só o da escrita, e
motivo nenhum. No ensaio, três mutantes pela HTTP, cada um com o banco de contrato montado de novo: a API publicando o
nome em todo escopo deu **1**, com o 90001003 nos dez destaques; o ensaio sem o concorrente plantado deu **1**, com zero
concorrentes; e a mesma API mutada com a conferência de antes deu **0** — o vazio que o achado descrevia. Os vizinhos
da trava que chamam rotina proibida ficam para o ciclo e não rodaram na verificação: o `CronTravaSimultaneaTest`
(`royaltiesAtualizar` com a trava presa — rodou uma vez na implementação, no `franquiabv_cilocal`, e passou), o
`FaturasDeRoyaltiesTravaTest` (`relatoriosRoyaltiesFaturasGerar`, com o Asaas dublado) e o `WhatsAppQueFalhaTest`
(`marketingEnvios`, com a trava presa e fora do horário). Pela leitura, o conserto não muda o caminho deles: o caso cria
e abre os próprios arquivos, a primeira abertura não falha, e dali em diante o código é o de antes. Verde depois: o
`D499TravaDeOutroDonoTest` (4 casos) no MySQL e como www-data; o filtro da fatia (D499, D428, `WhatsAppTelaAntiga`,
`ProfissionalParceiroTest`), 133 testes e 1.552 asserções em cada banco; e o ensaio de permissão 0 três vezes no MySQL,
cada uma com o banco de contrato montado de novo e a trava de root antes, e uma no PostgreSQL — sem resíduo da faixa
90009600 nos dois.

**As consultas do Profissional Parceiro e do Painel, pela régua do D72 e do D490:** nenhuma escolhe linha sem desempate.
`painelItens` (venda e item), `painelComprasDeCredito` (venda), `parceiroAgenda` (colaborador, data e id),
`parceiroNpsDoColaborador` e as duas das gorjetas (id), `parceiroHorarios` (id), `parceiroReguaAntiga` (id),
`painelRecebidoPorForma` (ordenação e id); `profissionalPorColaborador` e `painelPrimeiroServicoOuCredito` são agregados por
chave; as visitas (`DQL_DAS_VISITAS`) não têm `ORDER BY` e não precisam — `FidelizacaoAoColaborador::visitas` as guarda em
conjunto, com os dias ordenados. As ordens em PHP desempatam pelo id (`colaboradores`, os produtos do painel, as vigências
e os horários da agenda). O D490, os itens da comanda, segue onde está: não é destas telas, e o conserto pede o ciclo
inteiro.

**O que fica fora é a produção** — ver o D98: com o conserto, uma rotina que hoje esteja parada no servidor por uma trava
de outro dono volta a rodar quando o código subir. No código que roda lá, só os envios de marketing usam a trava
(`marketingEnvios` e `marketingPush*`, pelo `CronJobService::acquireLock` de origem, com o mesmo `fopen`); as outras a
ganham com o código novo. Conferir `/tmp/*.lock` no servidor antes do corte, só de leitura, é do dono: a pergunta foi ao
lote (TELAS-RESTANTES [corte], 15/10/2026).

#### A NOTA DE 15/10/2026 -- a resposta 229 do dono, opção (a) (a fatia 5, RESPOSTAS-DO-PROGRAMA)

**A decisão:** *"Conferir antes do corte — a listagem entra nas leituras do procedimento; se aparecer uma trava de
marketing de outro usuário, você decide antes de o código subir se aquele envio volta a rodar ou fica parado."* No
`docs/procedimento-de-corte-etapa-1.md`: a **leitura 22** (`ls -la /tmp/*.lock` no servidor, só leitura, e a mesma no
`/tmp` privado do php-fpm, se a leitura 7 der `PrivateTmp`), com os quatro nomes das travas do código de lá
(`marketingEnvios`, e `marketingPush` com `geral`, `notificarAniversario` e `clientesNotificarSentimosSuaFalta`); e o item
12 de "O que tem de estar pronto", a decisão do dono sobre a trava de outro usuário que a leitura achar. O conserto do
código não muda: a trava de outro dono continua aberta para leitura, e é por isso que a conferência vem antes de o código
subir.

**Dono diferente não basta: o modo decide** (a verificação do mesmo dia). O código de lá abre a trava só para escrever
(`fopen(..., 'c')`, o `CronJobService::acquireLock` da importação), e o novo para escrever e, se não puder, para ler. Com
outro dono, são três casos, e a leitura 22 os lê no `ls -la`: **(a)** o pool escreve (`-rw-rw-rw-`): o envio roda hoje e
segue rodando — fora a proteção do kernel (`fs.protected_regular`, lida junto), que recusa a escrita na trava de um dono que
não é o root nem o pool numa pasta como o `/tmp`, e a põe no (b); **(b)** o pool só lê (`-rw-r--r--`, a de quem a criou
como root com a máscara de fábrica): parado hoje, roda com o código novo — o caso que a 229 descreve; **(c)** o pool nem lê
(`-rw-------`): parado hoje e parado com o código novo, agora com a exceção do `TravaDeExecucao::obter` a cada disparo, e
voltar a rodar pede apagar a trava no servidor, uma escrita que só entra com a decisão do dono (o item 12 de "pronto" diz o
que cada decisão pede, por caso).

### D500 — Cancelar a retirada de uma devolução de crédito devolve o dinheiro à gaveta e deixa o crédito do cliente baixado — REGISTRADO em 15/10/2026; a pergunta foi ao lote — CORRIGIDO em 16/10/2026: a 231 (a), cancelar a retirada desfaz a devolução

**Achado na fatia 5c (15/10/2026), lido no código.** Pela resposta 89 do dono, a devolução de crédito sai do caixa aberto
como uma retirada (`CaixaService::retiradaDaDevolucaoDeCredito`), e a linha do registro aponta para ela
(`clientes_creditos_devolucoes.retirada`). A retirada é uma retirada como as outras: as duas portas do caixa a cancelam
(`CaixaService::retiradaCancelar`, pelo ERP e pela API), e o dinheiro volta ao saldo do caixa -- mas o crédito do cliente
continua baixado, e o registro continua dizendo que a devolução aconteceu. Se o cancelamento foi o operador "desfazendo" a
devolução, o cliente perdeu o crédito sem ter levado o dinheiro; se foi engano, a gaveta fecha com o dinheiro a mais.

**Não decidido aqui:** o que fazer é regra de negócio que a resposta 89 não cobre. Até a resposta, fica como as retiradas
(o padrão que não perde dado: nada é apagado). A pergunta foi ao lote (TELAS-RESTANTES [vendas], 15/10/2026): amarrar as
duas, como a resposta 141 fez com a despesa da sangria; recusar o cancelamento dessa retirada (o erro se corrige com uma
compra de crédito); ou como está.

#### CORRIGIDO em 16/10/2026 -- a resposta 231 (a) (a fatia 5e, RESPOSTAS-231-E-232)

**A resposta do dono** (a página do lote, 16/10/2026), diferente da recomendação, que era a (b): a **231 (a)** -- *"Amarrar
as duas, como a sua 141 fez com a despesa da sangria: cancelar a retirada da devolução desfaz a devolução -- o crédito volta
ao cliente, e o registro fica marcado como desfeito, com quem desfez e quando. Pede duas colunas novas no registro (só
acrescentam)."*

**O conserto.** `CaixaService::retiradaCancelar`, o único lugar por onde as duas portas do caixa passam (o ERP,
`CaixasController::retiradaCancelarAction`; a API, `Adm\CaixasController::cancelarRetiradaAction`), pergunta ao registro se a
retirada veio de uma devolução (`devolucaoDaRetirada`, por `RegistroDaDevolucao::daRetirada`: a ligação do registro com a
retirada, que nada regrava, lida antes das travas, como a da despesa da sangria). Se veio, `ClienteService::desfazerADevolucao`
trava o cliente (pela referência: nenhuma leitura simples antes das travas) e o crédito (a MESMA linha que a devolução
baixou -- o cliente e a unidade do registro, a mais antiga do par, o D72 --, por leitura com trava) ANTES do caixa, a ordem
da devolução (`creditoDevolucao`); cancela a retirada sob a trava do caixa, relida (cancelar de novo não devolve de novo, o
D199; o caixa fechado recusa, a 171 a); e só então devolve o valor ao crédito, por conta no banco (`somarAoSaldo`, a resposta
85), e marca o registro (`RegistroDaDevolucao::marcarDesfeita`: `desfeita_por`, quem entrou na sessão, e `desfeita_em`). Tudo
numa transação. Só quem opera o caixa cancela (a 175 a): as duas portas já recusam o caixa de outro operador. **A retirada da
devolução não tem despesa** (`financeiro` 0), e o estorno no financeiro não a alcança -- `SaidaService::estornarPagamento` só
acha a retirada pela despesa (`retiradaDaDespesaRelida`), conferido no código.

**Por que a ordem e a leitura com trava importam, medido.** Com o cliente buscado por `find` (uma leitura simples) no lugar da
referência, o MySQL tira a foto da transação antes de esperar a trava: no caso com outro processo, o cancelamento daqui
esperou o outro, releu a retirada pela foto (ainda ativa) e devolveu o crédito DE NOVO -- vermelho no MySQL, verde no
PostgreSQL (cada comando vê o confirmado). E com o caixa travado antes do cliente, o cancelamento seguraria o caixa esperando
o cliente que uma devolução do mesmo cliente segura esperando o caixa: o outro processo, pedindo o caixa com `NOWAIT`, caiu.

**As duas colunas, só acrescentam** (a 83 a e a própria 231 (a)): `clientes_creditos_devolucoes.desfeita_por` (int, sem chave
estrangeira, como `gravado_por`) e `desfeita_em` (datetime), anuláveis -- nulas, a devolução vale. Estão no `CREATE TABLE` de
`scripts/migracao-clientes-creditos-devolucoes.sql` (a produção cria a tabela já com elas, no passo 0) e num `ALTER`
idempotente depois dele, para a tabela que já existe sem elas (`ALGORITHM=INSTANT`, o molde de
`migracao-saidas-motivos-e-autores.sql`); no `mysql.sql`; no `postgres/schema.sql` (regerado: as duas linhas); e na carga
(`migrar-dados.sh`, regerado do `mysql.sql`, sem rodar). O portão do passo 0 ficou verde, e vermelho com o `mysql.sql` de
cópia sem uma delas ou com outra nulabilidade. Ensaiada em dois bancos descartáveis (apagados no fim): a tabela de 15/10 mais
a migração nova duas vezes, e sem a tabela mais a migração nova duas vezes, deram o mesmo `SHOW CREATE TABLE`. **Nas duas
cópias locais, em 16/10/2026**, conferido antes que a tabela existia, com 0 linhas nos dois bancos, nenhuma transação no
`INNODB_TRX` e nenhum processo além do agendador: no MySQL, às 10:29:08, a própria migração como o laço do ensaio a roda
(`docker exec -i franquiabv-mysql-1 mysql ... franquiabv_dev < scripts/migracao-clientes-creditos-devolucoes.sql`; o `SHOW
CREATE TABLE` de antes e o de depois diferem só nas duas linhas; às 10:29:09, de novo, "colunas do D500 ja existem, nada a
fazer"); no PostgreSQL (esquema `bvip`), às 10:29:22, como `franquiabv`, as duas linhas que o gerador emite, numa transação,
com a conferência de ausência dentro dela, pelo `psql` do contêiner com o roteiro na entrada (`docker exec -i
franquiabv-postgres-1 psql -U franquiabv -d franquiabv_dev < <o roteiro>`), saída 0; na segunda vez, "as colunas ja existem:
nada feito". O roteiro:

  ```sql
  \set ON_ERROR_STOP on
  BEGIN;
  SET LOCAL search_path = bvip, public;
  SET LOCAL lock_timeout = '10s';
  SELECT count(*) = 0 AS ausente_antes
    FROM information_schema.columns
   WHERE table_schema = 'bvip' AND table_name = 'clientes_creditos_devolucoes'
     AND column_name = 'desfeita_por' \gset
  \if :ausente_antes
  \else
    \echo 'as colunas ja existem: nada feito'
    ROLLBACK;
    \quit
  \endif
  ALTER TABLE clientes_creditos_devolucoes
      ADD COLUMN desfeita_por                     integer DEFAULT NULL,
      ADD COLUMN desfeita_em                      timestamptz DEFAULT NULL;
  COMMIT;
  ```

O código pergunta pelas duas (`RegistroDaDevolucao::guardaODesfazer`, a `ColunaNoBanco`, com os nomes literais que os portões
de esquema leem): sem elas, ou sem a tabela (a base do teste do BVIP até a próxima cópia), o cancelamento é o de antes -- o
dinheiro volta à gaveta, e o crédito fica baixado --, sem erro.

**O que a pessoa vê.** O ERP diz, depois de cancelar, *"Retirada cancelada com sucesso. A devolução de crédito foi desfeita: o
valor voltou ao crédito do cliente."* (`CaixasController::DEVOLUCAO_DESFEITA`). A API diz na própria retirada, no recurso do
caixa: `retiradas[].devolucaoDeCredito` -- `{cliente: {id, nome}, desfeitaEm}` na retirada que veio de uma devolução, nulo na
sangria (`CaixaJson::retirada`, por `RegistroDaDevolucao::doCaixa`) --, e a tela nova do caixa mostra na linha *"devolução
desfeita: o valor voltou ao crédito do cliente"*. O extrato do crédito (a aba do histórico do cliente no ERP) mostra a
devolução desfeita, com quem e quando: a linha de débito fica (o dinheiro saiu de verdade), e uma de crédito vem na hora do
cancelamento, *"Devolução de crédito desfeita por <quem> (a retirada foi cancelada no caixa, e o valor voltou ao crédito)"* --
na personificação, *"... por <quem entrou>, na sessão de <quem opera o caixa> (...)"* (a verificação, abaixo) --; as duas
juntas não mudam o saldo do extrato, que `calcularSaldoCreditoClientePorUnidade` lê. **E a tela antiga avisa antes de
cancelar** (a verificação, abaixo): o modal do cancelamento, que só perguntava "Deseja realmente cancelar a retirada?", diz
também *"Se for a retirada de uma devolução de crédito, a devolução é desfeita: o valor volta ao crédito do cliente."*. A tela
nova não cancela retirada (`frontend/src/api/caixa.ts`); quando o gesto vier, a retirada com `devolucaoDeCredito` pede o aviso
antes, escrito no cabeçalho de lá.

**Provado** nos dois bancos, em série: `D500DevolucaoDesfeitaTest` (novo, 11 casos: o crédito volta, o caixa recebe de volta e
o registro fica desfeito; cancelar de novo não devolve de novo; as travas -- o cliente, o crédito e o caixa, sem leitura
simples entre o registro e a trava do caixa; a sangria cancela como antes; o caixa fechado não cancela; sem as colunas, o
cancelamento de antes; o extrato com as duas linhas; o ERP com a frase; a API com `devolucaoDeCredito` antes e depois; quem não
opera o caixa recebe 404; e DOIS CANCELAMENTOS AO MESMO TEMPO, com outro processo segurando o cliente e o crédito por 1,5 s e
pedindo o caixa com `NOWAIT`: o daqui espera mais de 0,7 s, acha a retirada cancelada e desiste, o crédito volta a 100 e não
a 130, e o caixa recebe os 30 uma vez), fora da transação do teste, com tudo desfeito e conferido depois. O instrumento do
outro processo virou um traço (`tests/Caracterizacao/OutroProcessoSeguraALinha.php`); o D321 continua com a cópia dele. O
`CaixaSobTravaTest::testOCancelamentoTravaOCaixaAntesDeLer` passou a aceitar, antes da trava do caixa, só a leitura do registro
(a ligação) e a pergunta pelas colunas -- nada do caixa nem da retirada. Os mutantes, um por vez, restaurados e conferidos
pelo sha256 (`medir-mutantes-5e.py`, no rascunho), com os 66 casos do D500, do D501, do D179, do `CaixaSobTravaTest` e do
`DevolucaoDeCreditoSobTravaTest`:

| mutante | vermelho no MySQL | no PostgreSQL |
|---|---|---|
| `retiradaCancelar` sem o ramo da devolução | 7 (o crédito, de novo, as travas, o extrato, o ERP, a API, a corrida) | os mesmos 7 |
| o crédito volta sem perguntar ao caixa | 3 (de novo, o caixa fechado, a corrida) | os mesmos 3 |
| o cliente pela busca (a leitura simples antes da trava) | 2 (as travas e a corrida: o crédito devolvido duas vezes) | 1 (as travas) |
| o caixa travado antes do cliente | 2 (as travas e a corrida: o outro cai no `NOWAIT`) | os mesmos 2 |
| o crédito sem trava | 1 (as travas) | 1 |
| sem marcar o registro | 5 (o crédito, de novo, o extrato, o ERP, a API) | -- |
| o extrato sem a linha da desfeita | 1 | -- |
| a API sem `devolucaoDeCredito` | 1 | -- |
| o ERP sem a frase | 1 | -- |
| o desfazer sem perguntar pelas colunas | 1 (sem as colunas) | -- |
| (front) a linha da retirada sem a marca da desfeita | 1, no vitest (`CaixaLigado.teste.tsx`) | -- |

#### A verificação da fatia (16/10/2026): seis achados, todos tratados

- **O crédito na linha certa não tinha caso.** O desfazer devolve à linha do cliente e da unidade do registro, a mais antiga
  do par (o D72), mas o cliente das fixtures tem uma linha só: tirar o filtro da unidade passava verde. Na cópia, 32.358
  clientes têm linha de crédito em mais de uma unidade (60 com saldo em mais de uma) e há 3 pares repetidos. Dois casos novos:
  `testOCreditoVoltaALinhaDaUnidadeDaDevolucao` (a linha da outra unidade, 90001100, com id MAIOR, baixada no caixa de quem
  opera lá, volta lá, e a desta unidade não muda) e `testOCreditoVoltaALinhaMaisAntigaDoPar` (o par repetido: volta à mais
  antiga, a que a devolução baixou; e a mais antiga é guardada depois no disco, mudando duas vezes uma coluna de índice, como
  o PostgreSQL faz com a linha que muda -- o D490).
- **As guardas das colunas só eram provadas no caminho, e não nas consultas.** O caso sem as colunas as esconde só da
  `ColunaNoBanco`, e elas continuam no banco da suíte: tirar a guarda de `RegistroDaDevolucao::desfeitas` passava verde, e num
  banco com a tabela e sem as colunas o extrato e todo `GET` do caixa da API com uma devolução cairiam com a coluna
  desconhecida. Caso novo, `testSemUmaDasColunasNenhumaConsultaANomeia`, com cada coluna escondida por vez: pelo caixa da API,
  pelo cancelamento da API e pelo extrato, nenhum comando que o banco recebe nomeia `desfeita_`.
- **A sangria cancelada com uma devolução no mesmo caixa não tinha caso.** O `testARetiradaComumCancelaComoAntes` rodava com
  o registro vazio, e uma consulta que achasse a devolução de OUTRA retirada passava. Agora ele faz a devolução antes, e confere
  que cancelar a sangria deixa o crédito, o registro e a retirada da devolução como estavam.
- **Na personificação, o extrato mostrava só quem entrou.** `desfeita_por` guarda quem entrou na sessão, como o `gravado_por`
  do mesmo registro, e o extrato dizia *"desfeita por <o administrador>"*, sem dizer que foi na sessão do operador -- fora da
  regra do rastro (`CrudController::autorDaEscrita`: a pessoa assumida a parte, só quando difere). Agora diz *"desfeita por
  <quem entrou>, na sessão de <quem opera o caixa>"* quando os dois diferem (`RegistroDaDevolucao::desfeitas`, o
  `naSessaoDe`). **Sem coluna nova**: a 231 (a) pediu duas, e a pessoa assumida já está no banco -- as duas portas do caixa só
  cancelam a retirada do caixa do `usuarioId` (o ERP por `selecionarPorUsuario`, a API por `doOperador`), que na
  personificação é a pessoa assumida, e o caixa guarda quem o opera (`caixas.usuario`). Caso novo pelo ERP, com a sessão que
  personifica: `testNaPersonificacaoOExtratoDizEmNomeDeQuem`.
- **O aviso antes de cancelar foi adiado para uma fatia que não existe.** Nenhuma das 97 fatias cancela retirada na tela nova.
  Na tela antiga custava uma linha: o modal do cancelamento (`caixas/fechar.phtml`, o mesmo para toda retirada) diz agora que a
  retirada de uma devolução de crédito, cancelada, desfaz a devolução. Caso novo, que desenha a tela pelo `gerenciar`:
  `testATelaAntigaAvisaAntesDeCancelarQueADevolucaoSeDesfaz`. A tela nova não cancela retirada; o cabeçalho de
  `frontend/src/api/caixa.ts` diz que o gesto, quando vier, pede o aviso pela `devolucaoDeCredito`.
- **A página do ambiente de teste não falava das duas colunas.** O `docs/ambiente-teste.md` diz agora que o primeiro
  `atualizar` depois desta fatia recusa de novo pelo esquema (o `mysql.sql` e a migração mudaram), que `ACEITAR_ESQUEMA=sim`
  continua o certo sem ALTER, e que até a próxima cópia cancelar a retirada da devolução é, no teste, o cancelamento de antes.

O `D500DevolucaoDesfeitaTest` foi a 17 casos (eram 11). Os mutantes novos, um por vez, restaurados e conferidos pelo sha256
(`medir-mutantes-5e-correcao.py`, no rascunho), com os 72 casos do D500, do D501, do D179, do `CaixaSobTravaTest` e do
`DevolucaoDeCreditoSobTravaTest`:

| mutante | vermelho no MySQL | no PostgreSQL |
|---|---|---|
| o crédito sem o filtro da unidade | 1 (a linha da outra unidade) | 1 |
| o crédito pela mais nova do par (`ORDER BY ... DESC`) | 1 (o par) | 1 |
| o crédito sem a ordem pelo id | 0 -- a InnoDB devolve o par pela chave primária; fica o comentário `@linha-sorteada-conferida` | 1 (o par, com a linha guardada depois no disco) |
| `desfeitas` sem a guarda das colunas | 2 (as duas colunas escondidas) | 2 |
| a guarda sem perguntar por `desfeita_em` | 1 | -- |
| a guarda sem perguntar por `desfeita_por` | 2 (os dois casos sem as colunas) | -- |
| o registro que acha a devolução de outra retirada (`OR dev.id > 0`) | 1 (a sangria cancelada) | 1 |
| o extrato sem a pessoa assumida | 1 (a personificação) | -- |
| a pessoa assumida dita mesmo sem diferir | 1 (o extrato de sempre) | 1 |
| o modal sem o aviso | 1 (a tela antiga) | -- |

### D501 — A devolução de crédito maior que o saldo do caixa é recusada sem decisão do dono, e o saldo que ela confere não é o dinheiro da gaveta — REGISTRADO em 15/10/2026; a pergunta foi ao lote — CORRIGIDO em 16/10/2026: a 232 (c), a devolução sai mesmo além do saldo do caixa

**Achado na verificação da fatia 5c (15/10/2026), lido no código.** Pela resposta 89 (b), a devolução de crédito sai do
caixa aberto de quem opera como uma retirada, e *"sem caixa aberto, a devolução é recusada"* -- a única recusa que a
resposta nomeia. A fatia levou junto a regra da retirada da API: `CaixaService::retiradaDaDevolucaoDeCredito` recalcula o
saldo do caixa sob a trava e recusa o que passa dele (`registrarRetirada`); `ClienteService::creditoDevolucao` devolve
`DEVOLUCAO_ALEM_DO_CAIXA`, a API responde 409 com `motivo: alem_do_caixa`, e o ERP diz *"Valor indisponível no caixa"*. A
escolha foi do implementador, sem pergunta.

**O saldo que a recusa confere não é a gaveta.** É o de `CaixaService::atualizar`: o valor inicial, mais os pagamentos de
todas as vendas fechadas do caixa, de qualquer forma (dinheiro, cartão, PIX, crédito), menos as retiradas, e sem tirar o
troco. Dois efeitos: no começo do dia, com o valor inicial de 50 e nenhuma venda, o cliente com R$ 80,00 de crédito não
recebe, mesmo com o gerente autorizando; e com R$ 300,00 no cartão e R$ 70,00 em dinheiro na gaveta (uma venda de 70 paga
com 100, troco de 30), a devolução de R$ 200,00 passa e a gaveta fica em -130 em dinheiro, sem aviso. É a regra da sangria
da API de hoje (`retiradaComSaldoRecalculado`), herdada; a coluna nunca fica negativa, a gaveta pode ficar.

**Não decidido aqui:** recusar pelo saldo, pelo dinheiro da gaveta, ou não recusar é regra de negócio. Até a resposta,
fica a recusa pelo saldo, como está -- o padrão que não perde dado: o cliente continua com o crédito, e a devolução pode ser
feita depois. A pergunta foi ao lote (TELAS-RESTANTES [vendas], 15/10/2026).

#### CORRIGIDO em 16/10/2026 -- a resposta 232 (c) (a fatia 5e, RESPOSTAS-231-E-232)

**A resposta do dono** (a página do lote, 16/10/2026), diferente da recomendação, que era a (a): a **232 (c)** -- *"Não
recusar por saldo: a devolução que o gerente autoriza sempre sai, e o saldo do caixa pode ficar negativo, que a tela do caixa
mostra; quem confere a gaveta no fechamento vê a diferença."*

**O conserto.** `CaixaService::retiradaDaDevolucaoDeCredito` recalcula o saldo sob a trava do caixa, como antes, e grava a
retirada sem a comparação com o saldo (`registrarRetirada`, o `$recusarAlemDoSaldo` falso só aqui; o valor que não é positivo
continua recusado, o D382 e o D480). A recusa `DEVOLUCAO_ALEM_DO_CAIXA` saiu de `ClienteService::creditoDevolucao` (a retirada
nula, que agora só viria de um caixa que não a recebeu, responde `DEVOLUCAO_SEM_CAIXA`, como rede) e das duas portas: o ERP
(`ClientesController::creditoDevolucaoAction`, a frase *"Valor indisponível no caixa"*) e a API
(`Adm\FidelidadeController::devolverCreditoAction`, o 409 `alem_do_caixa`). A tela nova não tinha nada próprio para ele (a
recusa genérica, sem "ir ao caixa"); o caso do front que a simulava passou a ser o da recusa do saldo do cliente. Os avisos
das duas telas da devolução dizem que o valor sai *"mesmo que passe do saldo do caixa, que fica negativo"*. **A sangria não
muda**: `retirada` e `retiradaComSaldoRecalculado` continuam recusando o que passa do saldo, e a de R$ 0. O caso da
pergunta: R$ 80,00 num caixa de R$ 50,00 saem e deixam -30,00, na coluna e pelos lançamentos.

**Conferido** (`D501DevolucaoAlemDoSaldoTest`, novo, 6 casos, nos dois bancos): o caixa negativo abre nas duas telas, com o
sinal -- a antiga (`fechar.phtml`, o "Valor final" -30,00) e a da API (`totais.saldo` "-30.00"; a tela nova mostra "-R$ 30,00"
no resumo e na confirmação do fechar, o caso novo do `CaixaLigado.teste.tsx`) --, recalcula sem erro, e fecha pelas duas
portas carimbando -30,00; a sangria seguinte é recusada pelas três portas (os dois serviços, o ERP com "Valor indiponível." e
a API com o 409); e a recusa da sangria além do saldo, a da sangria de zero e a da devolução de zero são as de antes, e a do
saldo exato passa. As três portas da devolução (o serviço, o ERP e a API) mudaram de sentido no `D179DevolucaoRegistradaTest`
(de recusa para "sai e deixa -30,00"). **O saldo continua não sendo a gaveta**, como a pergunta dizia: a regra nova não
confere o dinheiro, e quem confere a gaveta no fechamento vê a diferença, como a resposta diz.

| mutante | vermelho no MySQL |
|---|---|
| a devolução volta a recusar pelo saldo | 9: as três portas no D179 e os seis do D501 |
| a sangria deixa de recusar pelo saldo | 5: três do `CaixaSobTravaTest` e dois do D501 (a sangria seguinte, e as recusas de antes) |
| (front) o aviso da devolução sem a frase do saldo | 1: o aviso, no `FidelidadeLigada.teste.tsx` |
| (front) o dinheiro sem o sinal | 1: o saldo negativo, no `CaixaLigado.teste.tsx` |


### D502 — A API de unidades não conferia os campos: o tipo aceitava qualquer texto, os booleanos passavam crus, as faixas e os tetos não existiam, e a chave que não se grava era calada — CORRIGIDO em 16/10/2026

O U4 do desenho da tela de Unidades (`docs/f3-telas-restantes/tela-unidades.md`), registrado e consertado no commit da
fatia 6 da fila das telas que faltam (UNIDADES-SERVIDOR). As regras moravam num método privado de
`module/ISConfiguracao/src/Controller/Adm/UnidadesController.php`, e conferiam só a presença: o `tipo` aceitava qualquer
texto (só existem `barbearia` e `emporio`, `Arrays::unidadesTipos`; o exemplo do comentário usava "Própria"); `visivelApp`,
`agendaPrimeiroNome` e os sete dias de convênio iam crus ao setter (o `"false"` virava verdadeiro); a validade dos pontos,
os pontos por real, a comissão de indicação e o aviso de cadastro não tinham faixa; os textos não tinham o teto da coluna
nem a recusa do emoji (as colunas são `utf8mb3`, e o MySQL estrito respondia 500); o e-mail não tinha formato, o DDI podia
não ser inteiro, latitude e longitude entravam cruas; e o `PUT` com `nps`, `nome` ou `whatsapp` respondia 200 sem gravar
nada.

**O conserto:** a regra pura `module/ISConfiguracao/src/Regra/RegraDaUnidade.php` (`conferir` e `paraOServico`, no molde da
`RegraDoConvenio`), com o campo nomeado pelo caminho do recurso. O tipo é um dos dois -- e o que a unidade já tem continua
servindo na alteração (as quatro unidades de fixture têm "PROPRIA" e "FRANQUEADA"; a cópia, nenhuma fora da lista). Os
textos passam pela `RegraDoTexto` (o teto, o emoji, o `<` e o `>`, o controle), com os tetos das colunas -- iguais nos dois
bancos; a entidade diz 255 no faturamento, e a coluna tem 100. Na cópia, nenhum texto de unidade tem `<`, `>` nem
caractere de controle (medido em 16/10/2026), e nenhum e-mail tem forma errada: a regra não tira nada de ninguém. A chave
que não se grava é 422 com o nome e a frase dela ("Montado do endereço: mude a cidade ou o bairro.", "Calculado pela rotina
da noite.", "O WhatsApp muda na tela antiga.").

**Na verificação (16/10/2026): o cadastro exige o faturamento**, como o ERP (a decisão 5 do desenho; a F5 diz que "a regra
da F0 já cobre o POST") -- as nove chaves menos o complemento. Sem ele, a unidade nascia pela API com as colunas
`faturamento_*` nulas, e a fatura do dia 16 criaria o cliente no Asaas sem documento (`FranquiaService`, `Asaas.php`). O
caminho só se abriu com o conserto do D510: antes, todo `POST` da API dava 500. O CPF do sócio administrador continua fora
da API (a LGPD, o D369).

**Provado:** `tests/Caracterizacao/RegraDaUnidadeTest.php` (puro: o 422 com o campo; e o cadastro sem o faturamento marca as
nove chaves, sem o complemento) e `UnidadeEscritaApiTest` (o 422 com o campo, e nada gravado; o cadastro sem o faturamento é
422 com as nove), nos dois bancos; os casos 158 e 159 do `ensaiar-recusas-adm-api.php`.

### D503 — A alteração da unidade não conferia o que a tela leu, e gravava por cima do que outra aba tinha gravado — CORRIGIDO em 16/10/2026

O U5 do desenho. A tela nova grava por seção, e cada seção leva só as chaves dela; sem conferência, a seção salva numa aba
velha desfazia, calada, o que outra aba, a tela antiga ou a API tinham gravado entre a leitura e o salvar.

**O conserto** (o molde do D293): o `PUT /adm/api/unidades/:id` aceita o `alteradoEm` que a leitura devolveu; diferente do
banco, é **409** com `motivo: mudou`, e nada é gravado. Sem ele, vale o contrato de antes (o ERP não o manda, e grava a
página inteira). `UnidadeService::update` abre a transação com a linha travada -- a primeira leitura é
`SELECT data_alteracao ... FOR UPDATE`, com ou sem a tabela da vigência do D368 -- e confere o instante pelo segundo, nos
dois bancos (o MySQL devolve a data no fuso da aplicação; o PostgreSQL, `timestamptz` com o deslocamento). A rotina das
04:10 não muda a data de alteração, e o 409 não dispara à toa. Quando a conferência recusa, a transação fecha com
`commit`, e não com `rollBack`: dentro de outra, o `rollBack` sem ponto de salvamento marcaria a de fora para desfazer.

**Provado:** `UnidadeEscritaApiTest` (o nulo que a leitura da unidade nunca alterada devolve passa, e depois de gravada o
mesmo nulo é 409; o velho é 409 sem gravar; o que a leitura devolveu passa; e a leitura da data é a travada, antes de
qualquer `UPDATE`), nos dois bancos; o caso 160 do ensaio de recusas. *Até a verificação de 16/10/2026, o caso que este
débito citava para o nulo media o contrário -- ele gravava a data antes, e a unidade já era alterada; o nulo da nunca
alterada só era medido de carona, pelo `PUT` que devolve o que leu (a 90001200 não tem data).* Na cópia, 1 unidade real
(inativa) não tem data de alteração; as 4 de fixture também não.

### D504 — Quem tivesse "escrever" em unidades sem ser administrador alteraria a unidade da rede -- a taxa de royalty, o desativar e os sócios — CORRIGIDO em 16/10/2026

O U6 do desenho, no molde do D255 dos convênios. A unidade é da rede: a taxa de royalty vai à fatura do dia 16, e desativar
tira do sistema quem trabalha nela (o D351). **Medido na cópia antes de fechar:** nenhuma linha de `configuracoes/unidades`
(a funcionalidade 23, "Configurações unidades") em `privilegios_usuarios` nem em `privilegios`; só os 10 administradores
ativos entram, e sem linha nenhuma. A guarda não tira nada de ninguém.

**O conserto:** na API, criar, alterar, `/ativacao` e `/desativacao` respondem 403 *"Só o administrador altera unidade:
ela é da rede."* a quem não é administrador, depois da sessão e do privilégio e antes do 404 (a ordem das guardas); ler
continua com `ler`. No ERP, o novo, o editar, os textos da unidade (`mensageria`), o ativar e desativar e as três ações de
sócio pedem o administrador também -- as telas voltam à lista com a mesma frase, e as de sócio respondem `sucesso: false`
com ela --, e a lista só mostra o Nova, o editar e o alternar a ele (o Nova aparecia a quem só lê, e levava a um 404: o U18,
no D377). **Decisão minha, do desenho** (a decisão 1, "que continua sua"): desfazer é tirar as guardas; nada se perde.

**Provado:** `UnidadeEscritaApiTest` (as quatro escritas, com escrever e sem ser administrador: 403, o banco intacto) e
`TelasDasUnidadesDoErpTest` (as sete portas do ERP, e a lista), nos dois bancos; os casos 161 e 162 do ensaio de recusas,
e o `/ativacao` no de permissão.

### D505 — A lista da API de unidades lia mais de um milhão de linhas por página para o total e o último acesso — CORRIGIDO em 16/10/2026: na API; a lista antiga continua com as colunas

O U7 do desenho. `UnidadeRepository::listagemIndex` monta o "Tot. Acessos" e o "Últ. Acesso" com duas subconsultas POR
LINHA sobre `usuarios_acessos` (1.880.166 linhas na cópia). **Medido no `franquiabv_dev`, em 16/10/2026,** a página de 20
ativas: **1.384.862 linhas lidas** (`Handler_read*` da sessão, o número que não depende do cache) com as duas, contra **58**
sem elas; e, só como referência do laptop, 16,2 s e 16,7 s com o buffer de fábrica (128 MB), contra 0,4 ms.

**O conserto:** `listagemIndex($request, $comAcessos)`; a `GET /adm/api/unidades` pede `false`, e o recurso da lista
(`UnidadeJson::daListagem`) não tem mais `ultimoAcesso` nem `totalAcessos`. **Na verificação (16/10/2026),** a linha ganhou
o `tipo` e o `whatsapp` (`ativo` e `provedor`, na forma do recurso inteiro; a chave continua fora), que a seção 1 do desenho
põe como colunas da lista: sem eles, a tela nova pediria um `GET` por unidade -- o custo que esta lista acabou de tirar. **A lista antiga continua com as duas
colunas**, contadas só pelas entradas da própria pessoa (o D76, a resposta 162 do dono) -- e é para ela que a tela nova leva
quem quiser os acessos; o número é de relatório, e está na lista do D306. Desfazer é passar `true`.

**Provado:** `UnidadeEscritaApiTest` (a lista da API não pergunta por `usuarios_acessos`, e a linha da 90001200 traz o
tipo e o WhatsApp), `UnidadeApiTest` e o
`D76AcessosDaPropriaPessoaNasListasTest` (a API sem as colunas; a lista antiga com elas, nos dois casos dele), nos dois
bancos.

### D506 — Os filtros da lista de unidades só oferecem as cidades, os bairros e os países das unidades ativas — PARCIAL em 16/10/2026: a API oferece os da situação pedida; a lista antiga segue como era — PARCIAL em 16/10/2026: a tela nova filtra pela situação; a lista antiga, que o menu do ERP ainda abre, segue igual — CORRIGIDO em 07/11/2026

O U8 do desenho. Os combos da lista antiga (`paisesSelecionar`, `popularComboboxCidades`, `popularComboboxBairros`) só
leem as ativas, e a UF vem da tabela de estados: na cópia, 14 das 28 inativas ficam em cidade que não aparece no filtro, e
Portugal também não aparece.

**O conserto, na API:** `GET /adm/api/unidades/filtros?estado=ativo|inativo` devolve `paises`, `ufs`, `cidades` e `bairros`
das unidades da situação pedida, sem o vazio e o nulo, na ordem do português (o MySQL e o PostgreSQL ordenavam diferente).
**O que resta:** a lista antiga continua com os combos dela; sai quando a tela nova a substituir no menu (a fatia 7).
*Na fatia 7 (16/10/2026), a tela nova entrou no menu do Console, e não no do ERP: o resto continua (abaixo).*

**Provado:** `UnidadeApiTest` (a inativa numa cidade só dela aparece nos filtros das inativas, e não nos das ativas) e
`UnidadeEscritaApiTest::testOsFiltrosPeloEndereco` (a ação: as quatro listas da situação pedida a quem só lê -- as das
ativas também, e o estado que não vem é o ativo; a cidade da unidade desativada passa às inativas e sai das ativas --, 422
com o campo para o estado que não existe, 401 sem sessão), nos dois bancos; o `/filtros` no ensaio de permissão.

#### A tela nova -- 16/10/2026 (a fatia UNIDADES-LEITURA)

"Unidades", no menu, abre a tela nova (`frontend/src/telas/Unidades.tsx`), e os filtros de país, UF, cidade e bairro são
os da situação escolhida: as das ativas ao abrir, as das inativas na primeira vez que a busca as escolhe, pelo
`/filtros?estado=`. A cidade escolhida que a outra situação não tem continua escolhida, e diz isso ("Blumenau (nenhuma
inativa aqui)"). **Provado:** `UnidadesLigado.teste.tsx` (as opções das ativas na abertura,
as das inativas pedidas uma vez com Portugal e a cidade só delas, o escolhido fora das opções, a falha com o "pedir de
novo") e `UnidadesLigado.contrato.teste.tsx` (a situação no ar ou pronta não se pede de novo; a que falhou, sim); os
mutantes da fatia (o roadmap, a entrada de 16/10/2026).

**O que resta, e por isso PARCIAL** (a verificação de 16/10/2026; a primeira versão deste registro dizia CORRIGIDO): a
lista antiga continua com os combos só das ativas e a UF da tabela de estados (`UnidadesController::indexAction`), e não
é só o link "lista antiga" da tela nova que leva a ela -- o menu do ERP, em Configurações › Unidades
(`module/ISBase/view/layout/layout.phtml`, com `configuracoes/unidades/ler`), continua abrindo a lista antiga. Fecha quando
a lista antiga sair (no corte, com o ERP), ou quando os combos dela lerem a situação pedida, como o
`UnidadeRepository::opcoesDosFiltros` da API.


#### CORRIGIDO em 07/11/2026 -- os combos da lista antiga (a fatia 118, UNIDADES-TELA-ANTIGA)

A lista antiga, que o menu do ERP ainda abre, lê os combos de país, UF, cidade e bairro do
`UnidadeRepository::opcoesDosFiltros` da situação pedida (`UnidadesController::opcoesDosFiltros`, pelo `?status=`: "0" são
as inativas, o resto as ativas, como a lista). A UF leva o nome da tabela de estados quando ele existe, e a sigla quando não
(antes eram as 27 da tabela, tivessem unidade ou não). O escolhido que a situação não tem continua no combo, marcado: a busca
filtra por ele, e o combo vazio esconderia o filtro. As consultas `popularComboboxCidades` e `popularComboboxBairros`, só das
ativas, ficaram sem chamador e saíram; o `paisesSelecionar` fica, pelo `/api/paises/selecionar` do aplicativo.

**Provado** nos dois bancos (`TelasDasUnidadesDoErpTest::testOsFiltrosDaListaAntigaSaoDaSituacaoPedida`): a inativa num país,
UF, cidade e bairro só dela aparece nos quatro combos das inativas, com o nome da UF, e em nenhum das ativas; a cidade só das
ativas não aparece nas inativas; a cidade das inativas escolhida na busca das ativas continua marcada. Mutantes: a situação
fixa nas ativas, o escolhido fora do combo e a UF sem o nome -- vermelhos.

**Na verificação da fatia (07/11/2026), dois ajustes com caso:** cada combo sai em ordem do que aparece, pelo `Collator`
do português -- o repositório ordena a SIGLA, e a UF trocada pelo nome saía "Amazonas, Amapá", e não a ordem dos nomes que a
tela antiga dava; o escolhido de fora entra no lugar dele, e não no fim (`testOsCombosDaListaAntigaSaoEmOrdemDoQueAparece`).
E a situação é uma só para a lista e para os combos: o `indexAction` reduz o `?status=` a 0 ("0") ou 1 (o resto) antes de
ambos -- a lista ligava o texto cru, e o `?status=` vazio, escrito à mão, dava as inativas no MySQL (o '' vale 0) e o erro do
booleano (500) no PostgreSQL, com os combos das ativas (`testOStatusQueNaoEZeroEAsAtivasNaListaENosCombos`, com '', 'abc' e
'1', e o controle '0'). Mutantes: as UFs pela sigla, sem ordem, e o status cru -- vermelhos nos dois bancos.

### D507 — Cadastrar e editar unidade no ERP gravavam o POST cru, e o editar gravava a unidade do `id` do corpo — CORRIGIDO em 16/10/2026

O U10 do desenho, da família do D202 (que não o listou porque a unidade não tem franquia dona). `UnidadesController::
novoAction` e `::editarAction` passavam `getPost()->toArray()` ao serviço, e o serviço pegava a linha pelo `id` do corpo: a
unidade da rota só servia para conferir que existia. O POST alcançava o `asaas_id`, o `nps`, a `data_criacao`, o
`relatorio_atendimentos` (que decide quais unidades a rotina processa) e o `hash` (o setter gera outro, sem a janela do
`rotacionarHash`: a integração que confere o hash parava na hora). O `tipo` tinha `disable_inarray_validator`, os filtros
`StripTags` e `StringTrim` nunca chegavam ao banco, e os textos não tinham o teto da coluna -- o número, `maxLength` 100
numa coluna de 20.

**O conserto:** o que vai ao serviço é `RegraDaUnidade::doFormularioDoErp` -- o que `$form->getData()` devolveu, só dos
campos do formulário e só das chaves que o navegador mandou (o nulo do campo que não veio não apaga nada), sem o `id`, o
`security` e o `status` --, com o id da rota. O `tipo` só aceita o que o `<select>` oferece, e o que a unidade já tem. O
`UnidadeFilter` ganhou o teto da coluna, o emoji e o controle nos 24 textos, com as frases da API.

**Na verificação (16/10/2026): o `<` e o `>` são recusados com a frase, e não cortados.** Os quinze textos que tinham o
`StripTags` (país, cidade, bairro, logradouro, número e os dez do faturamento) o perderam, e os 24 ganharam a recusa da
`RegraDoTexto`: desde que o serviço grava o `getData()`, o "Vila <Nova> Sul" gravava "Vila  Sul", calado, e os outros nove
gravavam o que a API recusa ("Loja > 100m2" na observação) -- a tela nova não conseguiria reenviar a seção. O e-mail da
unidade e o do faturamento têm a forma da API (`RegraDaUnidade::email`). Na cópia, nenhuma unidade tem `<` nem `>` nesses
campos, e nenhum e-mail falha (medido no mesmo dia): nada do que está gravado deixa de salvar.

**Provado:** `TelasDasUnidadesDoErpTest` (o formulário de verdade: o que ele recusa, com a frase da API -- o `<`, o `>` e o
e-mail sem forma também; o POST forjado com o `id` da outra unidade, o estado, o `hash`, o `nps`, o `asaas_id` e a data de
criação muda só a da rota e só no que o formulário tem, e a cidade chega aparada; o CADASTRO com o POST forjado nasce ativo,
sem NPS nem `asaas_id`, com o hash dele e o texto aparado -- até a verificação, nenhum caso despachava o novo por POST, e
voltar ao POST cru ficava verde; e a página de editar renderizada de verdade, com o POST montado dos campos que ela desenha,
como o navegador os serializa sem o JavaScript: o tipo atual marcado, e a linha inteira volta como foi lida), nos dois
bancos. *A página serializada mostrou o que já era assim antes desta fatia, e ficou como está:* o campo vazio do ticket
grava zero e o do link grava o texto vazio, onde a unidade tinha nulo (o POST cru fazia o mesmo; na cópia, nenhuma ativa tem
o ticket nulo); e a UF que não está na tabela `estados` não é oferecida pelo `<select>`, e salvar a apagaria -- na produção a
tabela tem as 27 (`scripts/limpar-dicionario-cep.sql`), e no `franquiabv_dev` só 4.

### D508 — Salvar a edição antiga da unidade desfazia a ativação ou a desativação feita em outra tela — CORRIGIDO em 16/10/2026

O U11 do desenho, no molde do D259. O estado ia num `<select>` escondido (`editar.phtml`, dentro de um `display-none`), que
devolvia o que a página leu ao abrir; salvar depois de outra aba (ou da API) desativar a unidade a reativava, calado. **O
conserto:** a edição não toca no estado (`doFormularioDoErp` não leva o `status`); ele muda só pelo alternar da lista e por
`/ativacao` e `/desativacao`. O cadastro nasce ativo, como na API. **Provado:** `TelasDasUnidadesDoErpTest` (desativada no
meio, salva com `status=1`, continua desativada; e, desde a verificação, também com o POST que a página de editar desenha, em
que o `<select>` escondido leu "ativa"), nos dois bancos.

### D509 — Os sócios da unidade: o id de um sócio de outra unidade o movia para esta, o removido voltava e devolvia o cadastro inteiro, e o id que não existe dava 500 — PARCIAL em 16/10/2026: falta o token do formulário de sócio — CORRIGIDO em 07/11/2026

O U12 do desenho. `socioSalvar` com o `id` de um sócio de outra unidade o movia para a do formulário (`getReference` e
`setUnidade` em `UnidadeService::socioSalvar`), e o removido voltava ativo; `socioPesquisar` devolvia CPF, nascimento e
endereço também do removido, e o `getReference` de um id inexistente dava 500 ao ler o nome; `socioRemover` regravava a
data de quem já tinha saído.

**O conserto:** o sócio é lido pelo id com `find`, e só o ATIVO serve (`UnidadesController::socioAtivo`); no salvar, só o
da unidade do formulário. O de outra unidade e o removido são recusados com *"Sócio inválido: ele não é desta unidade, ou foi
removido."*; o que não existe e o removido, na pesquisa, respondem "não achei" (a tela já diz *"Não foi possível localizar"*):
200 com `sucesso: false`, e não o 404 que o desenho pedia -- o `mod-configuracoes.js` só diz *"Não foi possível localizar o
cliente."* nesse caso, e o erro HTTP diria *"Erro ao localizar."*.
O que fazer com os dados do removido continua sendo o D369 (a LGPD).

**O que resta: o token do formulário de sócio** (o `security` do `UnidadeSocio`), que nunca é desenhado na página nem
conferido. Não entrou: a barreira de origem da entrada do ERP já recusa o POST de outra origem (08/10/2026), e o token do
Laminas expira em 5 minutos -- conferi-lo faria o salvar do sócio falhar na página aberta há mais tempo, o que hoje não
acontece. Entra se a barreira sair, ou com um token de validade própria.

**Provado:** `TelasDasUnidadesDoErpTest` (o sócio da 90001200 pela 90001150 e o removido, recusados, o banco intacto; o da
própria unidade grava; a pesquisa do inexistente e do removido sem o cadastro, e sem 500), nos dois bancos.


#### CORRIGIDO em 07/11/2026 -- o token do formulário de sócio (a fatia 118, UNIDADES-TELA-ANTIGA)

Com o token de validade própria que este registro pedia: o `security` do `Form\UnidadeSocio` passou a não ter prazo e a ter
sal próprio (`unidades_socio_salvar`), no molde do D535 e dos banners; a página de editar o desenha dentro do formulário do
sócio, que o `unidadesSocioSalvar` manda inteiro (`serialize`: o JavaScript não mudou); e o `socioSalvar`, depois da guarda
do administrador, o confere antes de tudo e recusa com *"O formulário expirou: recarregue a página e salve o sócio de
novo."* (`UnidadesController::FORMULARIO_DO_SOCIO_EXPIROU`). Sem prazo, a página aberta há mais tempo continua salvando, que
era o motivo de não o conferir; o sal próprio separa o recipiente dele do token do formulário da unidade, na mesma página.

**Provado** nos dois bancos (`TelasDasUnidadesDoErpTest`): sem token e com o token do formulário da unidade, recusado e nada
gravado; com o da página, seis minutos depois de abrir (o relógio avançado de `time()`), grava
(`testOTokenDoSocioEConferidoENaoVenceEmCincoMinutos`); a página desenha o token no formulário do sócio, e o POST com ele grava
(`testAPaginaDeEditarDesenhaOTokenDoSocio`). Mutantes: a conferência arrancada, o prazo de fábrica, o sal de fábrica e o token
fora da página -- vermelhos.

### D510 — Todo `POST /adm/api/unidades` terminava em 500: o Doctrine gravava nulo em oito colunas que o banco não aceita nulas — CORRIGIDO em 16/10/2026

**Achado nesta fatia, ao escrever o caso do cadastro.** Os sete dias de convênio (`convenio_segunda` a `convenio_domingo`)
e a validade dos pontos são `NOT NULL` com padrão no banco (`TRUE` e 365, nos dois), e a entidade os deixa nulos; o
Doctrine grava todas as colunas mapeadas, e o banco recusava o nulo explícito. O formulário do ERP sempre os manda, e por
isso só a API caía -- e nenhuma tela a chamava. **O conserto:** o cadastro da API completa a chave que não veio com o
padrão que o próprio banco escolheu (`Adm\UnidadesController::PADROES_DO_BANCO`), e não com um novo; as dez cobradas
continuam sem padrão. **Provado:** `UnidadeEscritaApiTest` (o cadastro sem os dias e sem a validade: 201, com 3,5% de
serviço, os dias de convênio ligados, 365 dias e ativa; e, desde a verificação de 16/10/2026, o corpo vence o padrão: a
validade zero e o domingo sem convênio que vieram ficam, e o `alteradoEm` no cadastro é 422), nos dois bancos. *O título
dizia "sete colunas" até a verificação; são oito: os sete dias e a validade.* O conserto abriu o cadastro pela API sem o
faturamento, e a regra passou a exigi-lo (o D502).

### D511 — A prévia dos Usuários no Claude Design dizia "o cadastro tem um valor fora da lista" da agenda: os dados de exemplo eram de antes do D345 — CORRIGIDO em 16/10/2026

**Achado na fatia UNIDADES-LEITURA, ao conferir o tipo da prévia nova** (a regra das notas do `.design-sync`: "confira o
tipo das prévias antes da captura"). Desde o D345 (04/10/2026) o `visivelAgenda` do usuário é um dos quatro valores da
agenda (`ext_int`, `int`, `ext`, `nenhuma`) e o recurso traz o `naAgenda`; os três usuários de exemplo de
`frontend/.design-sync/previews/_configuracoes.ts` seguiam com `true` e `false` e sem o `naAgenda` -- três erros de tipo
que o build não vê, porque as prévias ficam fora do `tsconfig.app.json`. Sem o `naAgenda` e sem um dos quatro valores,
a ficha aberta de cada um dizia "Aparece na agenda: não: o cadastro tem um valor fora da lista" -- o caminho da tela para
o dado estragado, mostrado ao agente de design como se fosse o normal. **O
conserto:** o Anderson (colaborador) aparece na do ERP e no aplicativo (`ext_int`, `naAgenda: true`); o Kauã e o Diego, em
agenda nenhuma (`nenhuma`, `naAgenda: false`). **Provado:** o `tsc --noEmit` de todas as prévias, com o tsconfig de
rascunho das notas, só com os dois erros conhecidos do `_relatorios.ts`. A célula não foi fotografada de novo: o envio ao
Claude Design é do ciclo.

### D512 — A tela nova de Unidades mostra a quem só lê o que o ERP só mostra ao administrador: o royalty do ciclo, o faturamento e os sócios — PARCIAL em 16/10/2026: o royalty só ao administrador; o faturamento e os sócios foram ao lote — DECIDIDO em 17/10/2026: o faturamento e os sócios ficam a quem lê (a resposta 236 a)

**Achado na verificação da fatia UNIDADES-LEITURA** (16/10/2026). A unidade é da rede, e quem tem
`configuracoes/unidades/ler` abre a ficha de qualquer uma, sem recorte, como a lista do ERP. Quem lê sem ser
administrador via na ficha nova:

- **o royalty do ciclo em reais** (`alcance.royaltyDoCiclo`, do `GET /adm/api/unidades/:id`, desde a fatia 6). No ERP, o
  valor do royalty só aparece no relatório de royalties, cujo item do menu é do PAPEL de administrador, "pela palavra do
  dono" (`frontend/src/telas/menu-do-erp.ts`); e para quem não é administrador a lista daquele relatório só oferece a
  própria unidade (`FranquiasController::royaltiesAction`, pelo `UnidadeRepository::popularCombobox($sessao)`). A tela
  antiga de unidades nunca mostrou valor de royalty;
- **o faturamento** (nome, documento, e-mail, telefone, endereço de cobrança e o nome do sócio administrador) **e os
  sócios** (nome, e-mail e telefone). No ERP, eles só aparecem no editar e na pesquisa de sócio, que pedem escrever E ser
  administrador (`UnidadesController::editarAction` e `::socioPesquisarAction`); a lista antiga mostra a unidade, o
  telefone, o NPS, os acessos, o aplicativo e a situação.

**Latente:** na cópia, 0 linhas da funcionalidade 23 em `privilegios` e em `privilegios_usuarios` (contagem de
16/10/2026) -- só os administradores entram, e sem linha nenhuma. Basta um administrador dar o `ler` a um gerente, para
ele ver o endereço e o horário, e ele passa a ver também o que está acima, de todas as unidades.

**O conserto do royalty:** o `alcance` (as pessoas que deixam de entrar, as que entraram em 30 dias e o royalty do ciclo)
só vai ao administrador (`Adm/UnidadesController::recurso`). Ele existe para a frase do desativar, que é do
administrador; as quatro escritas já eram dele, e a diferença era o `GET`. A ficha mostra o "Royalty do ciclo" quando o
alcance vem; as quatro taxas da seção 5 continuam a quem lê, como o desenho manda. **Provado:**
`UnidadeEscritaApiTest::testOAlcanceSoVaiAoAdministrador` (quem só lê e quem escreve sem ser administrador recebem a
unidade sem o alcance e com as taxas do ciclo; o administrador, com ele), nos dois bancos; três mutantes do
`recurso()` (o alcance a todos, a ninguém, e ao contrário), um por vez, vermelhos.

**O que resta, e é do dono:** o faturamento e os sócios. O desenho os põe na ficha de quem lê (as seções 8 e 10 de
`docs/f3-telas-restantes/tela-unidades.md`), e a API já os devolvia a `ler` desde a Fase 2 (o `GET` da unidade e o
`/socios`); a tela antiga só os mostrava a quem altera. Na cópia, 1 unidade tem no documento do faturamento os 11
dígitos de um CPF, e 27 os 14 de um CNPJ (só contado, nada impresso). É da família do D369 (a LGPD). Até a resposta, a
tela segue o desenho, que não perde nada; a pergunta foi ao lote (TELAS-RESTANTES [seguranca], 16/10/2026).

**A resposta do dono** (236 (a), 17/10/2026): "como o desenho (como está)" -- quem lê as unidades vê a ficha inteira, com o
faturamento e os sócios de todas. Nada muda no código: a API já os devolve a `ler` e a tela já os mostra. O royalty do
ciclo segue só ao administrador, que não era a pergunta. O que resta da LGPD sobre esses dados (o CPF de 1 unidade no
documento do faturamento) é do D369, e não deste.

### D513 — Reativar alguém devolve o molde de hoje do grupo, e nenhuma das duas telas diz isso antes do "ativar" — REGISTRADO em 17/10/2026 — PARCIAL em 17/10/2026: a tela de grupos diz; falta o "ativar" dos usuários — CORRIGIDO em 18/10/2026

**Achado na fatia ACL-API** (17/10/2026), com a resposta 212 (b) do dono. Desde ela a reativação apaga as linhas de quando
a pessoa saiu e grava as do molde do grupo (o D336, a seção de 17/10). Na cópia de 16/08/2026: 66 inativos tinham algo
tirado, 8 tinham mais do que o molde dá, e 112 tinham funcionalidade que o molde não tem (164 linhas).

- **A tela nova de usuários** reativa sem perguntar: o botão "ativar" age no primeiro clique, e só o "desativar" pergunta no
  botão (`frontend/src/telas/Usuarios.tsx`, o comentário "DESATIVAR PERGUNTA NO PRÓPRIO BOTÃO, e ativar" acima do botão).
- **A tela antiga** também: o botão da lista e o campo "Status" da edição. A recusa do grupo de rede chega com a frase.

Quem ajustou a pessoa à mão antes de ela sair não fica sabendo, antes de clicar, que os ajustes se perdem. O efeito é o que
o dono escolheu; o que falta é a tela dizê-lo, como a casa faz no desativar.

**O conserto:** o "ativar" da tela nova passa a perguntar no botão, com a frase de que a pessoa volta com o molde de hoje do
grupo (e, se a API passar a contar, quantas funcionalidades mudam); a tela antiga, a mesma frase no aviso de sucesso ou num
modal. Fica para a fatia que mexer na tela de usuários -- a 12 (ACL-TELA), à qual o lote ligou a Q4, ou a 15
(USUARIOS-DADOS). Não é escolha do dono: o efeito ele já decidiu, e a pergunta no botão é o molde da casa.

**E a mesma frase do outro lado, na tela de grupos** (a verificação da fatia, 17/10/2026): o alcance que a tela nova vai
mostrar antes de gravar um molde dizia "As N pessoas inativas do grupo não mudam" (`docs/f3-telas-restantes/tela-acl.md`,
seção 1.1). Com a 212 (b), quem for reativado volta com o molde que a gravação deixa: a frase passa a ser "não mudam agora;
quem for reativado volta com o molde novo". O desenho já foi corrigido; a frase é da fatia 12, que fecha este débito.

**Já feito na verificação:** depois do "ativar", a tela nova relê os privilégios (ver o D336, a seção de 17/10) -- antes, a
aba mostrava as linhas de quando a pessoa saiu até o "atualizar". O que falta é a pergunta ANTES.

#### PARCIAL em 17/10/2026 -- a frase do lado dos grupos (a fatia ACL-TELA)

A tela nova de Grupos e privilégios (`frontend/src/telas/Acl.tsx`) mostra, antes de gravar um molde, o alcance que o
servidor calcula, e a frase dos inativos é a da 212 (b): "As N pessoas inativas do grupo não mudam agora; quem for
reativado volta com o molde novo." (`alcanceDoMolde.ts::frasesDoAlcance`, com a conta `inativos` do alcance). O caso
`o exemplo do desenho, frase a frase` (`alcanceDoMolde.teste.ts`) e o das setas (`AclLigado.teste.tsx`) a conferem; o mutante
que volta à frase de antes ("não mudam.") fica vermelho nos dois.

**O que falta:** o lado dos usuários -- o "ativar" da tela nova continua agindo no primeiro clique, sem dizer que a pessoa volta
com o molde de hoje do grupo, e a tela antiga também. É a fatia que mexer na tela de usuários (a 15, USUARIOS-DADOS): o
"ativar" passa a perguntar no botão (a peça `perguntaNoBotao`), com a frase.

#### CORRIGIDO em 18/10/2026 -- o "ativar" das duas telas de usuários (a fatia USUARIOS-DADOS)

**A tela nova** (`frontend/src/telas/Usuarios.tsx`): o botão do estado pergunta nos dois sentidos, pela mesma peça
(`usePerguntaNoBotao<'desativar' | 'ativar'>`, a chave é o gesto, e o estado relido desfaz a pergunta). O primeiro clique em
"ativar" troca o rótulo para "confirmar" ("confirmar: ativar Fulano", descrito pelo alcance) e mostra, no corpo, o que acontece:
"Fulano volta a entrar no ERP e no aplicativo, no grupo «X». Quem é reativado volta com os privilégios do molde de hoje do
grupo: os de quando saiu — e os ajustes feitos à mão — não voltam." -- e, se o login tem o sufixo, que ele perde o «_old» se
o original ainda estiver livre (só o servidor sabe; a frase não promete). O "não ativar" devolve o foco ao botão, e o duplo
clique não confirma. O "confirmar" do ativar não é vermelho: ativar concede. Casos: `ativar pergunta no próprio botão, dizendo
que volta com o molde de hoje do grupo, e o segundo clique ativa` e `"não ativar" desfaz a pergunta e devolve o foco, sem pedir
nada; e o duplo clique não ativa` (`UsuariosLigado.teste.tsx`); os casos de antes que ativavam no primeiro clique passaram a
confirmar. Mutantes: o "ativar" sem a pergunta (`clicar(e, gesto, ativo)`) e a pergunta sem a frase do molde, vermelhos.

**A tela antiga** (`usuarios/editar.phtml`, `UsuariosController::editarAction`): quem está inativo lê, logo abaixo do campo
"Status", antes de salvar, a mesma frase (`RegraDoUsuario::FRASE_DA_VOLTA_PELO_MOLDE`); e o aviso depois de salvar com o
"Ativo" a repete ("Editado com sucesso. Quem é reativado volta…"). O botão da lista antiga só desativa (quem está inativo se
reativa pela edição), e por isso a frase mora nela. A FRASE É UMA SÓ: o `UsuarioDadosPelaApiTest::testOAtivarDaTelaNovaDizAMesmaFrase`
lê que a da tela nova é a da antiga, inteira; e o `testATelaAntigaDizComQuePrivilegiosVoltaQuemEReativado` renderiza a
página de quem está ativo (sem a frase) e de quem está inativo (com ela), e confere o aviso depois de salvar e que a linha
posta à mão saiu (o molde de hoje do grupo das fixtures não tem linha). Mutantes: o campo sem a frase e o aviso sem ela,
vermelhos.


### D514 — Quem personifica alguém segue com os privilégios de quando a assumiu, mesmo que eles mudem — REGISTRADO em 17/10/2026 — CORRIGIDO em 07/11/2026: a personificação guarda o sinal do acesso da pessoa, e a guarda de despacho devolve o administrador a ele mesmo quando o sinal muda

**Achado na fatia PRIVILEGIO-DERRUBA-A-SESSAO** (17/10/2026), com a resposta 102 (b) do dono (o D291). Desde ela, quem
tem o acesso mudado sai do sistema: o `token_web` dela sai do banco, e toda sessão que guarda aquele token cai no pedido
seguinte (`UsuarioService::tirarDoSistema`). A sessão do administrador que assumiu a pessoa ("Autenticar como") guarda o
token DELE, e não o dela (o D315, `SessaoAcl::manterTokenDoLogin`): se o acesso da pessoa muda durante a personificação --
pela outra aba dele ou por outro administrador --, a sessão segue com o que a pessoa podia quando foi assumida, até ele
voltar ("voltar a ser eu") ou sair. Assumir de novo lê o de agora.

O alcance é pequeno: só o administrador personifica (10 na cópia de 16/08/2026), e o que a sessão mostra a mais é o que a
pessoa podia minutos antes -- ele, por si, pode tudo. Mas é a mesma foto que a 102 (b) tirou da sessão da pessoa.

**O que daria para fazer, sem coluna:** a personificação guardar, ao assumir, um sinal barato do que a pessoa podia (o
`data_alteracao` dela, se `tirarDoSistema` passar a gravá-lo, ou o efetivo resumido), e a guarda de despacho conferi-lo só
nas sessões com a marca `usuarioSessao` -- uma consulta a mais, só nelas -- e devolver o administrador a ele mesmo quando
mudar. O token da pessoa não serve: ela pode estar fora do sistema (o token é nulo e continua nulo), e o login dela noutro
lugar o troca sem mudar privilégio nenhum. Não é escolha do dono: o efeito ele já decidiu; o que falta é escolher o sinal e
medir o custo.

#### CORRIGIDO em 07/11/2026 -- a personificação segue o acesso de agora (a fatia 109, SESSAO-LE-O-BANCO)

- **O sinal** (`Permissions\PersonificacaoComOAcessoDeAgora::sinal`): um `sha256` do grupo da pessoa (o id, o nome, que é o papel do
  `Acl`, e as quatro marcas), da marca "Acesso dashboard" e do EFETIVO das linhas de `privilegios_usuarios` dela -- por
  funcionalidade, o OU do ler e do escrever, como o `Acl` soma, sem a funcionalidade que não concede nada, em ordem de nome. É o
  critério da `RegraDoPrivilegio::saiDoSistema`, que tira a própria pessoa: tirar uma linha "nada" (104 linhas assim no
  `franquiabv_dev`, de 32 pessoas) ou juntar gêmeas que somam o mesmo não devolvem o administrador. E a pessoa do grupo de
  administrador não tem linhas no sinal: o `Acl` a libera em tudo, e a 102 (b) nunca a tira; o grupo dela continua no sinal. A
  primeira versão fazia o `sha256` das linhas cruas, e devolvia o administrador em mais casos do que a 102 (b) tira a pessoa (a
  verificação da fatia). Fora do sinal, de propósito: o token (o motivo está acima), o `data_alteracao` (a edição do cadastro o grava sem mudar
  acesso), a situação (a decisão (f2) do D315: desativar a pessoa assumida não mexe na personificação, e a entrada aceita assumir
  quem está inativo) e a unidade (não é privilégio, e mudá-la não tira a própria pessoa).
- **Ao assumir**, o `UsuariosController::autenticarComoAction` guarda o sinal na sessão (`SessaoAcl::guardarSinalDoAcesso`), depois
  do `setAcl` e do token do login. **A guarda de despacho** (`ISConfiguracao\Module::handleAuthRedirect`, depois do
  `UmDispositivoPorVez`) chama `voltarSeMudou` em todo pedido de sessão logada -- as páginas do ERP, a `/adm/api` e o `validarHash`
  --: fora da personificação volta sem consulta nenhuma; nela, refaz o sinal e, se mudou, devolve o administrador a ele mesmo como
  o "voltar a ser eu" (a sessão dele montada de novo, com o token do login dele). A personificação aberta antes da entrega, sem o
  sinal, volta uma vez (o precedente do D315).
- **O pedido que provoca a volta NÃO SEGUE** (`respostaDaVolta`; a verificação da fatia). A primeira versão o deixava seguir como o
  administrador, e ele foi montado na tela da pessoa assumida: o `CrudController` lê a unidade da sessão já devolvida, e o cliente
  digitado na tela da unidade Y nasceria na unidade X, a do administrador. E o "voltar a ser eu" que chega depois da mudança -- o
  cadeado do layout e o botão da tela nova mandam o `POST` ao `autenticarComo` com o id de quem entrou -- caía, com a sessão já
  normal, no ramo de assumir: o administrador "assumia a si mesmo", com o "voltar" na barra e uma linha "assumida por" ele mesmo no
  registro de acessos. Agora a guarda responde no lugar do pedido (o `return` do ouvinte para o despacho): a página do ERP vai à
  lista de usuários, onde o "voltar a ser eu" leva, com a mensagem "O acesso de quem você assumiu mudou..."; a `/adm/api` responde
  `401 personificacao_encerrada` com a mesma frase no corpo, e nada fica guardado na sessão. E o `autenticarComoAction` recusa
  assumir o próprio id (sem a marca, o id da rota igual ao de quem está na sessão): vai à lista, e a sessão fica como está.
- **Na tela nova** a primeira versão não mostrava nada: a frase ia ao `FlashMessenger`, que nem a `/adm/api` nem o `/novo` leem, e
  a faixa "Você está vendo o sistema como Fulano" e o menu de Fulano ficavam na tela. Agora o 401 `personificacao_encerrada` passa
  pelo porteiro do `/novo` (`api/cliente.ts`, que entrega a ele o corpo do 401): o `retomar` devolve a sessão do administrador,
  outra pessoa, e o Console é refeito para ele -- sem a faixa, com o menu dele --, o pedido recusado não é refeito, e a frase do
  servidor fica no topo até ele a fechar (`ConsoleLigado`). Na carga, o mesmo. O pedido de fundo da página antiga (o `validarHash`
  de 48 s) segue o redirecionamento e descarta a página: a frase vai com ela, e a página seguinte já é a do administrador, sem a
  faixa.
- **O custo**, só na sessão personificada: duas consultas por pedido (a pessoa com o grupo, e as linhas dela; uma, se ela é do grupo
  de administrador). Medido no `franquiabv_dev`, NOS DOIS BANCOS (só SELECT; o PostgreSQL pela conexão do papel `bvip_app`, com o
  RLS e o inquilino declarados, sem trocar o `local.php`): as dez pessoas fora do grupo de administrador com mais linhas (48 cada),
  200 voltas por pessoa com o mapa de identidade limpo a cada uma, com o sinal do efetivo: no MySQL 0,165 ms por sinal (a mediana;
  o maior, 0,167 ms), no PostgreSQL 0,281 ms (o maior, 0,285 ms) (`medir-p109c-custo-do-sinal.php` no rascunho da sessão).

**Provado** no `D514PersonificacaoSegueOAcessoTest` (15 casos, 5 deles pelo provedor das mudanças), nos dois bancos: sem mudança a
personificação segue; a linha mudada, as linhas apagadas, a troca de grupo, a marca do painel e a marca de gerente do grupo dela
devolvem o administrador (logado, com o token dele, e o pedido NÃO segue: a página do ERP vai à lista de usuários com a mensagem);
a linha nova, pela rota da `/adm/api` também (o 401 `personificacao_encerrada` com a frase, e nada no `FlashMessenger`); o login
dela noutro lugar, a saída dela, a edição do cadastro e a desativação não devolvem; sem o sinal, volta; fora da personificação, um
gerenciador que recusa qualquer uso não é tocado. Desde a verificação: a linha "nada" acrescentada e tirada não devolve; na pessoa
do grupo de administrador, as linhas mudadas e apagadas não devolvem, e a troca de grupo dela sim; o "voltar a ser eu" depois da
mudança não faz o administrador assumir a si mesmo (nem pela guarda, nem pela ação com a sessão já normal), e o registro de
acessos dele fica sem a linha "assumida por"; o sinal muda com o nível de uma linha; e, por um gerenciador de mentira que escolhe a
ordem da leitura (o `selecionarPorUsuario` não tem `ORDER BY`), o sinal é o mesmo com as linhas em outra ordem, com a linha "nada" e
com as gêmeas que somam o mesmo, e no grupo de administrador sem as linhas -- o caso de antes ("não depende da ordem") calculava o
sinal duas vezes sem mudar a ordem, e não provava o que o nome dizia. Na tela nova: `ConsoleLigadoEntrada.teste.tsx` (o 401
`personificacao_encerrada` no meio do trabalho refaz o Console para o administrador, sem a faixa, com a frase, sem refazer o
pedido; o mesmo na carga; o 401 de sempre sem a frase) e `porteiro.teste.ts` (o porteiro recebe o corpo do 401). O `D315PersonificacaoTest` inteiro segue verde (o caso (f2)
pegou a primeira versão do sinal, que tinha a situação). Mutantes P1 a P6, vermelhos e devolvidos pelo sha1: a chamada arrancada
da guarda, o sinal não guardado ao assumir, as linhas fora do sinal, a falta do sinal contada como "igual", a marca do painel e a
marca de gerente fora do sinal. Os da verificação, também vermelhos e devolvidos (nos dois bancos, menos o N3, N7 e N9, só no
MySQL, e os F, no vitest): N1 as linhas cruas no lugar do efetivo, N2 as linhas do grupo de administrador no sinal, N3 sem o
`ksort`, N4 o pedido que segue depois da volta, N5 a ação que deixa assumir a si mesmo, N9 a `/adm/api` tratada como página do
ERP; F1 e F2 o `ConsoleLigado` sem a frase (no porteiro, na carga), F3 o porteiro sem o corpo do 401.

### D515 — As gêmeas que a produção ainda tem até o passo 3 do corte ficaram sem caso no banco: a suíte nasce com a chave única — REGISTRADO em 17/10/2026 — PARCIAL em 17/10/2026: as gêmeas voltaram aos casos, numa tabela sem a chave; falta o ensaio geral do corte

**Achado na fatia PRIVILEGIOS-GEMEOS** (17/10/2026, a resposta 125 (a) do dono; o D289 e o D374). Desde ela, a chave única
em (pessoa, funcionalidade) está no `mysql.sql`, no `postgres/schema.sql` e nas duas cópias locais, e a segunda linha do par
não entra em nenhum banco da suíte nem dos ensaios. A produção continua com as gêmeas até o passo 3 do corte -- 117 pares na
cópia de 16/08/2026, 116 deles de linhas iguais -- e a base do teste do BVIP até a próxima cópia exportada daqui; e o código
novo, que roda sobre o MySQL de produção desde a etapa 1, continua a somá-las, a juntá-las e a tirá-las.

**Esse código mudou na fatia, e não ficou "sem mudança"**, como a primeira versão deste débito dizia: em
`UsuarioService::gravarPrivilegio` a pessoa sai do sistema antes de a linha receber o valor, a linha nova nasce por
`linhaNova`, e, desde a verificação, a linha nova trava a pessoa mesmo quando ela não sai (o D289). No par que já tem linhas
-- o das gêmeas -- o que se grava é o mesmo de antes; mas a prova do commit `922f0c6` era de outra versão.

**E a primeira versão da fatia tirou das gêmeas os casos que podiam ficar vermelhos**: um dublê do serviço devolvia a gêmea
em memória, uma entidade nova, e o `remove` dela não faz nada. Tirar a gêmea ao gravar, tirar só a primeira ao excluir e ler
o par em ordem contrária ficavam verdes na suíte inteira; e os casos da propagação com gêmea, o `JUNTA`, o `repetidas` e a
lista com o par somado tinham saído.

#### PARCIAL em 17/10/2026 -- as gêmeas de volta, na tabela sem a chave (a verificação da fatia)

`CaracterizacaoTestCase::semAChaveDasPessoas` põe, dentro da transação do caso, uma TABELA TEMPORÁRIA com o mesmo nome, a
mesma estrutura e as linhas de agora, sem a chave, que esconde a de verdade nesta conexão -- a da aplicação inteira, que o
caso despacha em processo. No MySQL o `CREATE TEMPORARY TABLE` não fecha a transação (o `DROP INDEX` fecharia, e o que o
caso gravou antes ficaria no banco da suíte); no PostgreSQL o DDL é transacional, e a temporária vem antes do `bvip` na
busca. O `tearDown` a tira. É a tabela da produção até o passo 3, sem as chaves estrangeiras, que nenhum destes casos usa.

**Voltaram, com as gêmeas gravadas:** a soma e a ordem do id, a gravação que deixa a mais antiga e a exclusão do par
inteiro (`PrivilegioDaTelaNovaTest`); o par somado e o par inteiro pela API e pela janela do ERP
(`PrivilegiosPelosControllersTest`, e um caso novo: a lista da `/adm/api/usuarios/:id/privilegios` com o par numa entrada, o
que o caso 07 do `scripts/ensaiar-usuarios-adm-api.php` provava com duas linhas); a gêmea em dia que vira uma linha, a
diferente comparada pelo efetivo e a exclusão do molde que tira o par (`PrivilegioPropagacaoTest`); e o `JUNTA` e o
`repetidas` da API dos grupos (`MoldePelosControllersTest`). Os casos com a gêmea em memória saíram.

**Mutantes, um de cada vez, vermelhos nos dois bancos:** sem tirar a gêmea ao gravar (7 casos); tirando só a primeira ao
excluir (6); o par em ordem contrária (4); a propagação sem agrupar as linhas por pessoa (4); sem contar as repetidas (2); a
lista da API sem somar o par (1).

**O que falta:** o ensaio geral do corte (`scripts/ensaiar-corte.sh`) não roda a migração: a cópia local já está juntada.
Numa cópia nova, exportada da produção com as gêmeas, a carga do PostgreSQL pararia na chave; antes dela, o
`scripts/conferir-limpeza-da-unicidade.php` acusa a chave declarada com a origem suja e sem bloco em
`pre-migracao-limpeza.sql`. Quem trouxer a cópia nova põe a linha no `passo3` do ensaio (e o dublê no
`scripts/ensaiar-clones-do-ensaio-de-corte.sh`). E os ensaios por HTTP rodam no banco do contrato, que tem a chave: o caso
07 do `ensaiar-usuarios-adm-api.php` grava uma linha só, e a lista com o par somado é provada na suíte.

**Fecha quando:** a produção passar pelo passo 3 (a etapa 2) e a base do BVIP for exportada de novo -- daí em diante o
código das gêmeas não tem mais o que juntar, e pode sair, junto com a tabela sem a chave da suíte.

### D516 — Um número de rota maior que o `integer` da coluna dá 500 no PostgreSQL, e não 404, na `/adm/api` — REGISTRADO em 17/10/2026 — PARCIAL em 17/10/2026: os grupos; falta o resto da API — PARCIAL em 18/10/2026: os grupos e o controller dos usuários; falta o resto da API — PARCIAL em 07/11/2026: as rotas e o corpo da `/adm/api`; falta a busca, o destino do troco e o ERP antigo — PARCIAL em 08/11/2026: o destino do troco também; falta a busca e o ERP antigo

**Achado na fatia ACL-TELA** (17/10/2026), ao escrever a busca nova `GET /adm/api/grupos?funcionalidade=`. As rotas da
`/adm/api` só conferem algarismos (`'constraints' => ['id' => '\d+']`), e os controllers fazem `find((int) ...)` com o que
vier. Com `99999999999` -- que passa no `\d+` e no `(int)` do PHP de 64 bits --, o MySQL devolve nulo, e a resposta é o 404
de sempre; o PostgreSQL recusa o parâmetro numa coluna `integer` (`out of range for type integer`), e a resposta é 500.

**Medido nos grupos:** o `MoldePelosControllersTest::testOQueNaoExisteE404` e o `::testAListaPorFuncionalidadeQueNaoServe`
pedem o grupo e a funcionalidade 99999999999, pela rota e pela busca; sem a guarda, os dois ficam com erro no PostgreSQL
(`Errors: 2` e `Errors: 1`, os mutantes da fatia) e verdes no MySQL. **Consertado nos grupos:** o
`Adm\GruposController` só consulta o id que cabe na coluna (`MAIOR_ID`, o de `ClientesController` e
`ProfissionalParceiroController`, que já faziam isso para um campo do corpo -- o D234, "a revisão de 05/10/2026"), e o resto
é 404. Os ensaios ganharam o caso 176 (o grupo 99999999999 pelo HTTP).

**E a rota dos grupos que mora em outro controller** (a verificação da fatia, 17/10/2026): o texto de antes contava 37
chamadas, e o grep dava 38 -- uma delas era `GET /adm/api/grupos/:grupo/privilegios`, o `doGrupoAction` do
`Adm\PrivilegiosController`, filha da mesma rota `adm-api-grupos` (`module/ISConfiguracao/config/module.config.php`), e o
"PARCIAL: os grupos" do título não a cobria. Derivada a lista do `module.config.php`, ela é a única rota de
`/adm/api/grupos` fora do `Adm\GruposController`; ganhou a mesma guarda (`MAIOR_ID`), o caso
`PrivilegiosPelosControllersTest::testOMoldeDoGrupoQueNaoExisteE404` (90099999 e 99999999999, e o controle; sem a guarda, erro
no PostgreSQL e verde no MySQL, o mutante da verificação) e o caso 178 do ensaio de recusas. Desde ela, o título vale para o
prefixo inteiro.

**O que falta (lido, não medido):** as outras 37 chamadas `find((int) $this->params()->fromRoute(...))` dos controllers da
`/adm/api` (o `grep -rn 'find((int) $this->params()->fromRoute' module/*/src/Controller/Adm/` de 17/10/2026, depois da
verificação: 16 arquivos, de `Adm\SaidasController` a `Adm\AgendasController`; 3 delas no `Adm\PrivilegiosController`, nas
rotas `/adm/api/usuarios/:id/privilegios`) -- e as que leem o id por outro caminho. O
conserto é um ajudante só no `ApiAdmController` (o id da rota, ou nulo quando não cabe), e um caso por rota com o número
grande, nos dois bancos; o `scripts/conferir-*.php` pode acusar o `find((int)` cru. Não é escolha do dono: a resposta certa
é o 404 que o MySQL já dá.

**E as duas leituras novas dos usuários** (17/10/2026, a fatia USUARIOS-LER-AGENDA-E-COTA): `GET /adm/api/usuarios/:id/agenda`
e `/cota-parte` nascem com a guarda -- o `usuarioDaRota` do `Adm\UsuariosController`, com o mesmo `MAIOR_ID` --, o caso
`UsuarioAgendaECotaParteApiTest::testONumeroQueAColunaNaoGuardaE404` (99999999999, 2147483648, 90099999 e 0, nas duas) e os
casos 24 e 25 do `scripts/ensaiar-usuarios-adm-api.php`, nos dois bancos. Sem a guarda, o caso fica com erro no PostgreSQL e
verde no MySQL (o mutante da fatia). As 37 chamadas de antes continuam de fora -- o `verAction`, a ativação e a desativação
do mesmo controller entre elas: o grep de 17/10/2026, depois da fatia, ainda dá 37 em 16 arquivos.

#### PARCIAL em 18/10/2026 -- o controller dos usuários inteiro (a fatia USUARIOS-DADOS)

O `PUT /adm/api/usuarios/:id` (novo) nasce com o `usuarioDaRota`, e o `verAction` e a ativação e a desativação
(`mudarEstado`) passaram a ele: nenhuma ação do `Adm\UsuariosController` faz mais o `find((int)` cru. Caso:
`UsuarioDadosPelaApiTest::testAGuardaEADaTelaAntigaEAPropriaPessoaAlteraOsSeus` pede o 99999999999 ao `PUT`, ao `ver`, à
ativação e à desativação (404 nos quatro, nos dois bancos). **O que falta:** o grep de 18/10/2026, depois da fatia, dá 35
chamadas em 15 arquivos -- o resto da `/adm/api`, as 3 do `Adm\PrivilegiosController` (as rotas
`/adm/api/usuarios/:id/privilegios`) entre elas.

#### PARCIAL em 07/11/2026 -- a `/adm/api` inteira pelo número da rota, e os ids do corpo (a fatia 116, API-ADM-IDS-E-PDV)

**Conferido antes:** o grep de 07/11/2026 dava 43 `find((int)` em 16 controllers de `Controller/Adm/`, e o
`D516IdQueNaoCabeNaColunaTest` (novo), rodado ANTES do conserto, deu 84 casos com erro no PostgreSQL
(`value "99999999999" is out of range for type integer`) e todos verdes no MySQL. **O conserto:** um ajudante só,
no `ISBase\Controller\ApiAdmController` -- `idDaRotaQueCabe()`, `acharPeloId()` e `acharPelaRota()` --, pela
`ISBase\Util\IdDaColuna::queCabe()` (o mesmo `(int)` de antes no que cabe; nulo acima de 2147483647, em zero e no
que não é número nem texto). Passaram a ele as 43 chamadas, os `(int) $this->params()->fromRoute(` que iam a um
`find` por outro caminho (`Adm\ClientesController` na alteração e na ativação; `Adm\CaixasController::doOperador`,
nas quatro rotas do caixa; o item e o pagamento da `Adm\ComandasController`; o `Adm\RelatorioDeCaixaController`),
as três cópias da guarda (`GruposController`, `PrivilegiosController`, `UsuariosController`, que perderam o
`MAIOR_ID` delas), o `clienteId` da comanda nova e o `?colaborador=` dos horários livres e dos serviços da agenda
(a `RegraDaMarcacao::idPositivo` aceita 19 algarismos). No ERP antigo, só o que a fatia 111 apontou e os irmãos
dele: o `venda_id` e o `id` do item no `VendasProdutosController` (o lançamento e a remoção) e o `venda_id` e o
`id` do pagamento no `VendasPagamentosController` -- no PostgreSQL a `vendas.id` é `bigint`, e o erro ali era o
texto (`abc`), e não o número grande.

**O teto:** as colunas `id` do MySQL são `int` (conferido no `franquiabv_dev`: `vendas`, `vendas_produtos`,
`agendas`, `caixas`), e as entidades mapeiam `integer`; no PostgreSQL três delas são `bigint` (`vendas`,
`vendas_produtos`, `agendas`), e por isso as rotas da comanda, da venda e do agendamento não ficavam vermelhas
lá -- o teto vale igual, e o maior `vendas.id` da cópia é 990.099.318.

**Os testes:** `D516IdQueNaoCabeNaColunaTest` (186 casos nos dois bancos, com os 3 da verificação): as 159 rotas da `/adm/api` com número no
caminho, DERIVADAS da configuração (cada uma com 99999999999 em cada número, pelo roteador e pela fábrica de
verdade, por um administrador da unidade: 404); 12 números de dentro com o pai das fixtures (a parcela, o
pagamento, o item do pacote, a movimentação, o pacote do cliente, a funcionalidade do usuário, o item e o
pagamento da comanda, a retirada do caixa); 13 ids do corpo, cada um com o controle 90099999 (a mesma recusa, o
mesmo código e os mesmos campos, com o número e com o texto, com 99999999999 e com 2147483648); e os dois
`?colaborador=` da agenda. E o
`D429DuasAbasNoPdvAntigoTest::testOIdQueAColunaNaoGuardaEAVendaQueNaoExiste` (99999999999 e `abc`, nas quatro portas
do PDV antigo: a frase da venda descartada, e nada gravado). **O portão:** `scripts/conferir-id-que-cabe.php`
(no `conferir-tudo.sh` e no `ci.yml`) lê os 51 controllers de `Controller/Adm/` sem comentários e acusa o
`->find((int)`, o `->find(X::class, (int)` e o `(int) $this->params()->fromRoute(`; confere que os três ajudantes
passam pela `IdDaColuna` e que ela confere o teto. Os mutantes: o ajudante sem o teto (97 erros no PostgreSQL), a
`IdDaColuna` sem o teto (102, e o portão vermelho), a forma do débito, os serviços da agenda e o caixa voltando ao
`find` cru (1, 1 e 4 erros no PostgreSQL), o `comandaDaUnidade` de volta (só o portão: a `vendas.id` é `bigint` lá),
e as três portas do PDV antigo (o `abc` e o pagamento, no PostgreSQL); um comentário com `find((int)` deixa o
portão verde.

**A verificação da fatia 116 (07/11/2026) confirmou quatro achados no D516, consertados:**

1. **Dois ids do corpo ainda iam crus a um `find`, por uma variável, onde o portão não olha:** a forma do pagamento da
   comanda (`Adm\ComandasController::adicionarPagamentoAction`: o `(int)` passava no `<= 0`, a irmã da quitação do
   débito, que a fatia consertou) e o serviço e o cliente da marcação (`Adm\AgendamentosController::criarAction`: a
   `RegraDaMarcacao::idPositivo` aceita 19 algarismos). Os três eram 500 no PostgreSQL (`formas_pagamentos.id`,
   `produtos.id` e `clientes.id` são `integer` lá) e passaram ao `acharPeloId`; três casos novos no `idsDoCorpo`, cada
   um vermelho no PostgreSQL com o `find` de antes (os mutantes V3, V4 e V5) e verde no MySQL. Antes desta
   verificação o texto acima dizia que só faltavam a busca e o ERP antigo -- e faltavam estes dois.
2. **Três cópias do teto em `Controller/Adm/` tinham ficado fora do ajudante:** a retirada do `Adm\ProdutosController`
   (`$id <= 2147483647 ? ...->find($id)`), o `MAIOR_ID` do `Adm\CotaParteController` e o do
   `Adm\ProfissionalParceiroController`. Conferiam o teto (não havia 500), mas o ajudante não era o único, e o
   comentário do `ApiAdmController` contava três cópias quando eram seis. Passaram ao `acharPeloId` e à
   `IdDaColuna::queCabe`, e o portão acusa agora o `const MAIOR_ID` e o 2147483647 antes de um `->find(` na mesma
   instrução (os mutantes V7 e V8: portão vermelho). A cópia escrita só num `if`, sem o `find` na instrução, ele não vê.
3. **O caso do fechamento da agenda ficava verde sem o conserto:** a guarda de antes (`ctype_digit` e
   `strlen(...) <= 10`) já tirava o 99999999999, de onze algarismos; o buraco dela era de 2147483648 a 9999999999. Todo
   id do corpo, e o `?colaborador=` da agenda, é pedido agora também com 2147483648 (o número e o texto); com a linha
   do `HEAD` de volta, o caso do fechamento fica com erro no PostgreSQL (`value "2147483648" is out of range`, o
   mutante V6) e o portão vermelho.
4. **A remoção do item no PDV antigo já era segura antes:** o `(int)` fazia do texto 0, que o `find` não acha, e o
   99999999999 cabe na `vendas_produtos.id` e na `vendas.id` (`bigint` no PostgreSQL). A mudança ali é só a mesma
   forma do lançamento; o comentário do controller e o do caso do `D429DuasAbasNoPdvAntigoTest` dizem agora que, das
   quatro portas, três eram erro no PostgreSQL, e que a afirmação da remoção do item é de regressão.

**O que falta (lido, não medido):** os ids da BUSCA da `/adm/api` que não vão a um `find` -- 25 `fromQuery` com
nome de id em 21 controllers (`unidade`, `grupo`, `cliente`, `categoria`, `tipo`...), a maioria conferida por uma
regra ou pela lista de unidades; o `?grupo=` da lista de usuários vai cru à consulta (`gru.id = :grupo`). E o ERP
antigo: 16 `find((int)` crus nos controllers de fora da `/adm/api` (o grep de 07/11/2026), além dos do PDV; e os ids
da `RegraDaMarcacao::idPositivo` (19 algarismos) que o `AgendasController` antigo leva a um `find` (o serviço e o
agendamento clicado na grade). E o `destinoTroco` da finalização da comanda nova, que vai cru a um `getReference` no
`VendaPagamentoService::finalizar` (achado na verificação; espera a regra do D739, que decide a conferência).

**O destino do troco, em 08/11/2026 (a fatia 137; o D739, a resposta 372 c):** o `destinoTroco` só chega ao
`getReference` depois de `VendaPagamentoService::gorjetaForaDaLista`, que aceita só o id (inteiro, ou texto só de
algarismos, no teto de `IdDaColuna::queCabe`) da lista de quem atende no PDV da unidade da venda; o resto é recusado antes
de qualquer escrita, nas duas portas. O caso é o `D739GorjetaDeQuemApareceNoPdvTest::testOServicoRecusaQuemNaoEstaNaListaDaUnidadeDaVenda`
(`99999999999` e `90001003x`). Falta a busca e o ERP antigo, como acima.

### D517 — O PDV da tela nova oferece como quem atendeu quem tem "Aparece no PDV: não" — REGISTRADO em 17/10/2026 — CORRIGIDO em 07/11/2026

**Achado na fatia USUARIOS-LER-AGENDA-E-COTA** (17/10/2026), escrevendo a frase da aba Cota-parte para quem não aparece no
PDV nem na agenda. O desenho (`docs/f3-telas-restantes/tela-usuarios-resto.md`, a seção 1) mandava dizer que a cota-parte
dessa pessoa "não é gerada" -- e isso só é verdade no PDV antigo. O cadastro tem o campo "Aparece no PDV"
(`usuarios.visivel_pdv`), e a lista de quem atendeu do PDV antigo o respeita (`ISVenda\Form\Venda`,
`popularCombobox(null, $unidade, 1, 0)`: só `visivel_pdv = 1`). A comanda nova monta a mesma lista por `GET /adm/api/usuarios`
(`listarTodosOsColaboradores`, `ComandasLigada.tsx`), que não filtra o campo -- a forma mínima
(`UsuarioJson::daListagemMinima`) nem o leva --, e o `POST` do item confere só que o colaborador existe e é da unidade
(`Adm\ComandasController`). Quem o cadastro tira do PDV pode ser escolhido na comanda nova, e a cota-parte sai no nome dele.

**Medido no `franquiabv_dev`** (17/10/2026, a cópia de 16/08): 103 ativos que não são administradores com `visivel_pdv = 0`,
em 48 unidades -- 10 deles com percentual de cota-parte no cadastro, e 2 na agenda ou no aplicativo; e 255 ativos com
`visivel_pdv = 1`. Quantos foram escolhidos na comanda nova não se mede: o item não guarda a porta.

**O que a fatia fez:** a aba Cota-parte diz o que é verdade nas duas portas -- "a cota-parte só sai das comandas em que ela
estiver como quem atendeu" (`agendaDoUsuario.ts`, `avisoDaCotaParte`) --, e não promete o "não é gerada".

**O conserto, sem decisão:** a porta do PDV (`vendas/comandasNova/ler`) da listagem devolver só quem tem `visivel_pdv = 1`,
como o PDV antigo, e o lançamento do item na comanda nova recusar quem não tem -- o campo do cadastro já decide; a comanda
nova é que não o lê. Cuidado com a comanda aberta pela agenda (`VendaService::comandaAbrirAgenda`), que lança o item no
nome de quem atendeu na agenda, com qualquer `visivel_pdv`: a recusa vale só para o item escolhido na comanda.

#### CORRIGIDO em 07/11/2026 (a fatia 116, API-ADM-IDS-E-PDV)

**Conferido antes, no `franquiabv_dev` (só contagem):** 103 ativos que não são administradores com `visivel_pdv = 0`,
em 48 unidades, e 258 com `1`; nenhum dos 103 está na agenda (`visivel_agenda` diferente de 0); 2 deles têm itens
em vendas desde 01/07/2026 (213 itens) -- a porta não se mede. **A lista:** `GET /adm/api/usuarios` ganhou a porta
do PDV, `?porta=pdv`, que devolve só quem tem `visivel_pdv = 1` (o filtro novo no `UsuarioRepository::listagemIndex`,
com o booleano), na forma mínima; outra porta é 422; sem a porta, a lista é a de antes (a forma mínima para quem só
entra por `vendas/comandasNova/ler`, a do D168). A comanda nova pede a porta para quem atendeu
(`listarTodosOsColaboradores`, `frontend/src/api/catalogo.ts`), e a mesma lista serve a retirada do estoque dela, como
a TELA do PDV antigo (`ProdutoRetirada`, o mesmo `popularCombobox(null, $unidade, 1, 0)`). **O lançamento:** `Adm\ComandasController::adicionarItemAction` recusa com
422 no `colaboradorId` (`RegraDoItemDaComanda::FRASE_FORA_DO_PDV`, pela `atendeNoPdv`) quem é da unidade da sessão
e não aparece no PDV, antes de gravar; o de outra unidade segue com a recusa da unidade, do serviço. A comanda
aberta pela agenda lança pelo serviço, e o ajuste e a troca pelo cupom relançam o colaborador do item: nenhum passa
pela recusa. **Os testes:** `D517QuemAtendeNoPdvTest` (6 casos nos dois bancos: a porta do PDV para quem só entra
pelo PDV, com `?porta=pdv` e a lista de antes sem ela, o cadastro com a porta e sem ela, a porta desconhecida, a recusa com o controle, o de outra unidade,
e a comanda pela agenda no nome de quem não aparece no PDV, com o ajuste da linha pela API); no front,
`catalogo.teste.ts` (a porta em toda página, e o controle sem ela) e as duas listas de pedidos do
`ComandasLigada.teste.tsx`. Os mutantes: a recusa arrancada (vermelho nos dois bancos), o filtro da consulta
arrancado (dois casos, nos dois bancos), a porta ignorada para quem tem o cadastro, a recusa sem a conferência da
unidade, e a porta tirada do front (três casos no vitest).

**A verificação da fatia 116 (07/11/2026) confirmou três achados no D517:**

1. **A gorjeta do troco tinha mudado calada (consertado).** A lista de quem atendeu (`catalogos.colaboradores`) ia
   também ao `Finalizar`, que monta com ela o destino do troco ("gorjeta para ..."), e a porta valia sempre para quem
   só entra pelo PDV: quem tem "Aparece no PDV: não" deixou de poder receber gorjeta na comanda nova, sem estar no
   débito nem no lote. Quem recebe a gorjeta não é o "Aparece no PDV": o PDV antigo oferece o grupo Colaborador
   inteiro, com qualquer `visivel_pdv` (`VendaPagamentoService::tabelaPagamentos`, `popularCombobox(1, $unidadeId)`);
   na cópia, 9 ativos do grupo Colaborador têm `visivel_pdv = 0`. Agora a porta só vale com `?porta=pdv`, e a comanda
   nova pede uma lista à parte para a gorjeta, sem a porta (`listarQuemRecebeGorjeta`, `catalogos.gorjeta`): a de antes
   da fatia, todos os ativos da unidade. Quem recebe a gorjeta é do dono -- o D739, com a pergunta no lote. Os casos:
   `D517QuemAtendeNoPdvTest::testAPortaDoPdvSoMostraQuemApareceNoPdv` (sem a porta, quem só entra pelo PDV recebe a
   lista de antes; vermelho nos dois bancos com a porta de volta para ele, o mutante V1), no front `ComandasLigada.teste.tsx`
   ("a gorjeta do troco oferece a lista de antes, e quem atendeu só a do PDV"; vermelho com o `Finalizar` de volta à
   lista do PDV, o V2) e `catalogo.teste.ts` (a lista da gorjeta sem a porta). **Superado em 08/11/2026 (a fatia 137 e
   a verificação dela; o D739, a resposta 372 (c) do dono):** a lista à parte saiu (`listarQuemRecebeGorjeta` e
   `catalogos.gorjeta` não existem mais), a gorjeta das duas telas é a lista de quem atendeu, e quem só entra pelo PDV
   volta a receber a lista do caixa com ou sem a porta; o caso `testAPortaDoPdvSoMostraQuemApareceNoPdv` cobra isso
   agora. Ver a seção CORRIGIDO do D739.
2. **A retirada do estoque segue o PDV antigo só na tela (escrito).** A `POST /adm/api/produtos/:id/estoque/retirada`
   confere quem pediu pela `RegraDaRetiradaDeEstoque::solicitanteAceito` (ativo e da unidade do produto), sem o
   `visivel_pdv`, como o modal antigo, cujo filtro também é só do formulário; um POST escrito à mão grava a retirada
   no nome de quem não aparece no PDV, enquanto o item da mesma comanda o recusa. Não mudou: a regra é uma só para as
   duas portas, e na cópia 2.372 das 9.260 retiradas (21 desde 01/01/2026) estão no nome de quem hoje tem
   `visivel_pdv = 0` -- recusar no servidor mudaria o modal antigo também, e não foi pedido.
3. **O PDV antigo segue só com o filtro da tela (escrito).** `VendasProdutosController::adicionarAction` confere só que
   o `colaborador_id` não veio vazio, e `VendaProdutoService::insert` só a unidade: um POST escrito à mão em
   `/vendas-produtos/adicionar` grava o item no nome de quem não aparece no PDV. Não é regressão (a tela antiga só
   oferece `visivel_pdv = 1`), e o débito pedia a comanda nova; a recusa do servidor existe só na `/adm/api`.

### D518 — A agenda e a cota-parte dos colegas a quem só lê os usuários: o desenho as dava, e a tela antiga não — REGISTRADO em 17/10/2026 — DECIDIDO E FEITO em 18/10/2026: a agenda a quem lê, a cota-parte só a quem altera (a resposta 238 c)

**Achado na verificação da fatia USUARIOS-LER-AGENDA-E-COTA** (17/10/2026). As duas leituras novas, `GET
/adm/api/usuarios/:id/agenda` e `/cota-parte`, nasceram com `configuracoes/usuarios/ler`, como a ficha e os privilégios da
mesma API e como a tabela "Quem vê e quem escreve" do desenho (`docs/f3-telas-restantes/tela-usuarios-resto.md`, a seção
1). A tela antiga das mesmas coisas pede `escrever`: `editarAgendaAction` e `comissaoAction`
(`module/ISConfiguracao/src/Controller/UsuariosController.php`) chamam `getAcesso()` sem argumento, e o padrão é
`escrever` (`ISBase\Controller\CrudController::getAcesso`). No ERP, quem só lê os usuários vê a lista; a janela da agenda e a
da cota-parte lhe dão 404. Com `ler`, a tela nova passava a mostrar a quem só lê a cota-parte de cada colega da unidade -- a
base de serviço e de produto e as extras, o primeiro dado de remuneração da `/adm/api` servido a `ler` (a ficha,
`UsuarioJson::completo`, não leva comissão nenhuma) --, e o link "alterar na tela antiga" das duas abas o levava a um 404.

**Latente:** no `franquiabv_dev` (17/10/2026, por contagem, sem dado pessoal), 0 não administradores ativos com só `ler` na
funcionalidade 21 (e 0 inativos), 0 moldes de grupo com `ler` sem `escrever`, e 107 que escrevem. Basta um gerente dar a um
colaborador só o `ler` de "Configurações usuários" (a `RegraDoPrivilegio::concedivel` deixa: não é de rede), ou um
administrador pôr o molde de um grupo em "ler" pela tela da fatia 12.

**O que se fez, sem decidir:** a guarda das duas leituras passou à da tela antiga, `escrever` e o `alcanca`
(`Adm\UsuariosController::agendaAction` e `::cotaParteAction`, a recusa `SEM_A_AGENDA_E_A_COTA`); a tela nova só mostra as
duas abas a quem escreve (`Usuarios.tsx`, `abasDe`; o `podeEditar` do `UsuariosLigado`), e a nota dos Dados de quem só lê
volta a dizer que a agenda e a cota-parte ficam na tela antiga. Ninguém perde nada: os 107 que viam na tela antiga veem na
nova. **Provado:** `UsuarioAgendaECotaParteApiTest::testSemSessaoE401ESoComOLerE403` (só com o `ler`, 403, nas duas; com o
`escrever`, 200), nos dois bancos, e os mutantes `getAcesso('ler')` de volta em cada uma, vermelhos; no front, o caso "a
nota dos Dados diz as abas que existem" (quem só lê tem só Dados e Privilégios), e os mutantes da aba para quem só lê,
vermelhos.

**O que resta, e é do dono:** se quem só lê os usuários vê a agenda, a cota-parte, as duas ou nenhuma. Na pergunta parecida
das unidades (a 236, o D512), a resposta foi "como o desenho". A pergunta foi ao lote (TELAS-RESTANTES [seguranca],
17/10/2026); a troca, qualquer que seja, é a palavra da guarda de cada ação, a sessão dublê do caso e o `abasDe`.

#### DECIDIDO E FEITO em 18/10/2026 -- a resposta 238 (c) (a fatia 16b da fila das telas que faltam, RESPOSTAS-237-238)

**A resposta do dono** (238 (c), 18/10/2026): *"A agenda a quem lê, a cota-parte só a quem altera"* -- os horários da
equipe ficam à vista de quem consulta; o quanto cada um ganha, não (a recomendação da pergunta).

**O que mudou:** `GET /adm/api/usuarios/:id/agenda` passou a pedir `configuracoes/usuarios/ler`, a guarda da ficha
(`Adm\UsuariosController::agendaAction`), com o mesmo `alcanca` de quem escreve -- a unidade da sessão, o teto do D204 e o
de rede do D346 -- e a mesma resposta, campo por campo (as opções do formulário são catálogo e a ordem dos colegas no
aplicativo, que os empates já mostram; nada de remuneração). O `GET .../cota-parte` e todas as escritas (os `PUT` da agenda,
da cota-parte e dos dados, a ativação e a desativação) seguem pedindo `escrever`; a recusa da cota-parte a quem só lê diz só dela (`SEM_A_COTA_PARTE`). Na
tela nova, quem só lê tem as abas Dados, Agenda e Privilégios (`abasDe`, com a Agenda pelo `temAgenda` e a Cota-parte pelo
`comCotaParte`, que o invólucro liga com o `podeEditar`); a Agenda dele é a leitura de quem altera sem o "alterar a agenda",
sem a faixa da gravação (a mesma da verificação dos Dados de 18/10/2026) e com a nota "Só para consulta: a agenda se altera
por quem altera os usuários." no lugar da do formulário. A nota dos Dados de quem só lê diz que a agenda se lê ali e que a
cota-parte só aparece a quem altera -- e PERDEU O LINK DA TELA ANTIGA: ele levava ao `editar` dela, que pede `escrever` e
responde 404 a quem só lê (era assim desde a fatia 13). A tela antiga não mudou: a janela da agenda segue pedindo
`escrever`, e quem só lê continua sem ela lá -- a consulta é pela tela nova.

**Ninguém perde nada:** no `franquiabv_dev` de 17/10/2026, 0 não administradores ativos com só o `ler` de "Configurações
usuários" (107 escrevem); a mudança vale para quando alguém der só o `ler`.

**Provado** nos dois bancos: `UsuarioAgendaECotaParteApiTest::testSemSessaoE401EAAgendaAQuemLeEACotaParteSoAQuemAltera`
(sem sessão 401 e sem privilégio 403 nas duas; só com o `ler`, a agenda 200 e igual à de quem escreve, e a cota-parte 403 com a
frase dela) e `::testAQuemSoLeAAgendaTemORecorteEOsTetosDeQuemEscreve` (a quem só lê, a de outra unidade, a do grupo de
administrador e a de quem tem rede são 404; o administrador que só lê alcança as três; a própria, com a rede, 200); o
`gravarAgenda` com só o `ler` segue 403 (`UsuarioAgendaPelosControllersTest::testAGuardaEADeQuemEscreveEAPropriaPessoaGrava`,
que até a verificação da fatia se chamava "a guarda é a da leitura"). No front, `UsuariosLigado.teste.tsx`: "a nota dos
Dados diz as abas que existem" (quem altera e a pessoa tem agenda: as duas abas daqui, o ramo que a verificação achou sem
caso; quem só lê tem Dados, Agenda e Privilégios, ou Dados e Privilégios sem agenda, e nenhum link da tela antiga) e "quem só lê vê a agenda, só para consulta" (um pedido só da agenda, a semana e a ordem lidas, sem o
alterar, sem a faixa e sem a Cota-parte). Os mutantes, um por vez, estão no roadmap de 18/10/2026 (RESPOSTAS-237-238).

### D519 — As horas, o tempo, as férias e a cor da agenda do colaborador gravavam sem conferência nenhuma — REGISTRADO em 17/10/2026 — CORRIGIDO em 17/10/2026

**O D-H do desenho** (`docs/f3-telas-restantes/tela-usuarios-resto.md`, a seção 2), registrado e consertado na fatia
USUARIOS-GRAVAR-AGENDA (a 14 da fila das telas que faltam). O filtro da tela antiga da agenda
(`ISConfiguracao\Form\UsuarioAgendaFilter`) só exigia o tempo e a ordem; as 28 horas, as férias e as duas cores chegavam ao
banco como vinham: o dia de uma ponta só (o gerador não o abre), a abertura igual ou depois do fechamento (o "dia de zero
minutos" que a régua da ocupação não sabe medir, a F4.2), o almoço fora do turno ou num dia fechado, o dia que passa das 71
voltas do gerador (`Agenda::getTempoAtendimento`: o fim dele não aparece na grade nem no aplicativo), as férias de uma
ponta só ou invertidas, e a cor de até 100 caracteres numa coluna de 10.

**Medido no `franquiabv_dev`** (17/10/2026, a cópia de 16/08, por contagem): dos 175 ativos com agenda, 15 têm hoje alguma
coisa que a regra recusa -- 3 com a abertura igual ou depois do fechamento num dia, 2 com uma ponta só, 1 com o almoço fora do
turno, 1 com almoço num dia fechado, 1 que passa das 71 voltas, 2 com as férias de uma ponta só, 7 sem ordem (o 0) e 4 sem o
tempo (estes dois o `<select>` da tela antiga já obrigava a escolher). Nenhum com o tempo nulo e dia aberto (o "00:00" do
gerador que o desenho temia: 0).

**O conserto:** a regra pura `ISConfiguracao\Regra\RegraDaAgenda`, a mesma nas duas portas -- o `PUT
/adm/api/usuarios/:id/agenda` novo e o `editarAgendaAction` da tela antiga, que traduz o POST para a língua do corpo
(`doFormularioDoErp`) e grava traduzido de volta (`paraOServico`), e não mais o POST cru. Cada dia: as horas em `HH:MM`, as
duas pontas ou nenhuma, a abertura antes do fechamento, o almoço com as duas pontas, dentro do turno e nunca num dia fechado
-- e O ALMOÇO DE INÍCIO IGUAL AO FIM É "SEM ALMOÇO", gravado nulo (o gerador já o lia assim) --, e o teto das 71 voltas, com o
mesmo número no front (`frontend/src/telas/formularioDaAgenda.ts`) e na leitura (`UsuarioJson::daAgenda`, o `noTeto`). E o
tempo da lista (`Arrays::usuariosTempoAtendimento`), a ordem de 1 a 15 (o `<select>` da tela antiga), as cores em
`#RRGGBB` e as férias com as duas datas, o início até o fim. A recusa é NO CADASTRO, e não no gerador: ele responde ao
aplicativo, que é contrato congelado (D5). Na tela antiga, a frase do campo vai ao campo, e a do dia ao topo, com o nome do
dia, escapada (o topo imprime cru).

**Os 15 de hoje ficam como estão**, e nada muda para eles até alguém salvar a agenda: aí a recusa diz o que ajustar, nas duas
telas. Nenhum dado foi reescrito.

**Provado:** `RegraDaAgendaTest` (sem banco: cada recusa do dia uma a uma, o almoço igual como vazio, o teto com o fechamento
que caberia, o tempo, a ordem, as cores, as férias, as chaves do corpo, o formulário da tela antiga na língua do corpo, e as
três constantes lidas no arquivo do front) e `UsuarioAgendaPelosControllersTest` (as duas portas: a recusa não grava nada,
nem as horas, nem a vigência, nem as ligações; a tela antiga recusa pela mesma regra e grava o almoço igual nulo), nos dois
bancos; mutantes, cada recusa tirada e o almoço igual de volta, vermelhos.

**A verificação de 17/10/2026** (a mesma fatia) mudou quatro coisas. O almoço de início igual ao fim é "sem almoço" TAMBÉM NO
DIA FECHADO, como o desenho diz: o domingo sem abertura com 12:00 às 12:00 caía na frase do dia fechado com almoço (0 ativos
assim na cópia; as duas regras, a do servidor e a do front). A cor e a cor da letra seguem obrigatórias em `#RRGGBB` nas duas
portas -- regra além do D-H, porque a coluna guarda 10 e o campo antigo aceitava 100 --: na cópia, 0 dos 2.427 usuários têm a
cor nula; as fixtures as têm, e a tela antiga abre o campo vazio para elas (o `setData` do nulo apaga o padrão `#fafafa`), de
modo que salvar ali pede escolher as duas. O "Copiar horários da unidade" da tela antiga (`usuariosCopiarHorariosUnidade`, no
`mod-configuracoes.js`) punha 12:00 às 13:00 em todo dia, também no dia sem abertura e fora do turno, e a regra nova passou a
recusar o que o atalho preenchia: nas 3 das 32 unidades ativas que abrem no domingo à tarde (21 dos 175 colaboradores ativos
com agenda), o domingo copiado não salvava, e as lojas sem horário nenhum (só as das fixtures, a do banco de contrato
inclusive) davam seis dias fechados com almoço. Agora o almoço só vai quando cabe no turno, e o dia sem horário na loja fica
como estava -- como o domingo já ficava, e como o "copiar" da tela nova, que também deixou de fechar esse dia (29 das 32 lojas
ativas não têm domingo, e 6 colaboradores ativos com agenda, em 4 lojas sem domingo, trabalham nele); o `?cache=` do script
subiu de 164 para 165. **Provado:** `RegraDaAgendaTest` (o dia fechado com o almoço igual, vazio) e
`copiarHorariosDaTelaAntiga.teste.ts` (a função do arquivo servido, sobre um jQuery de mentira: o almoço que não cabe fica
vazio, o dia sem horário na loja fica como estava, e o que sobra na página passa pela regra, dia a dia; e o `?cache=` de pelo
menos 165); mutantes -- o almoço igual de volta ao fim da conferência, o atalho de antes -- vermelhos.

### D520 — As áreas e os serviços da agenda iam por id cru e depois do `commit` das horas: o id que não servia deixava a agenda pela metade — REGISTRADO em 17/10/2026 — CORRIGIDO em 17/10/2026

**O D-I do desenho** (`docs/f3-telas-restantes/tela-usuarios-resto.md`, a seção 2), registrado e consertado na fatia
USUARIOS-GRAVAR-AGENDA. `UsuarioService::editarAgenda` gravava as horas numa transação, fazia o `commit`, e só então apagava e
regravava as áreas e os serviços, pelo id que vinha no POST (`getReference`, sem conferir a unidade, o tipo nem a situação: a
validação contra a lista do formulário estava desligada, `disable_inarray_validator`). O id que não servia derrubava o
`flush` depois do `commit`: as horas novas gravadas, as ligações de antes já apagadas. E nada impedia ligar a agenda a serviço
de outra franquia -- o mesmo buraco do D142.

**Medido no `franquiabv_dev`** (17/10/2026): 245 ligações de serviço de ativos com agenda, nenhuma a serviço inativo, de
outra unidade, de outro tipo, sem área ou fora das áreas marcadas; nenhuma a área inativa.

**O conserto:** tudo numa transação só, com a pessoa travada primeiro (`travarAPessoa`, o `FOR UPDATE` da linha dela: a tela
nova, a antiga e outra aba gravam uma depois da outra), e AS LIGAÇÕES CONFERIDAS CONTRA O BANCO DE AGORA antes de gravar
qualquer coisa: as áreas ativas, e os serviços ativos, `ser`, da unidade DA PESSOA (`UsuarioService::opcoesDaAgenda`, pela
lista nova `ProdutoRepository::servicosDaAgenda`, que é a do `<select>` da tela antiga com a área de cada um). E O SERVIÇO QUE
AS AGENDAS NÃO OFERECERIAM É RECUSADO: o `popularComboboxServicos`, que monta a lista do aplicativo, da agenda do ERP e da nova,
só oferece serviço com área, e, com área marcada, só os das áreas -- os ligados também (o desenho deixava "dizer ou recusar";
a recusa vale nas duas portas, e o formulário novo diz antes de cada serviço por que as agendas não o ofereceriam). A porta
confere antes, pela `RegraDaAgenda`; o serviço confere de novo na transação (`AgendaRecusada`), e é o piso.

**Provado:** `UsuarioAgendaPelosControllersTest`, nos dois bancos -- o serviço de outra unidade, o inativo, o sem área, o de
fora das áreas e a área inativa recusados pelas duas portas sem gravar nada; e o piso: o id que não serve, chegando direto ao
`editarAgenda`, derruba a gravação inteira (o mutante "as ligações e a conferência de volta para depois do `commit`" deixa as
horas gravadas, e fica vermelho).

**A verificação de 17/10/2026** (a mesma fatia) achou o que não tinha caso, e uma mudança da tela antiga sem aviso. O `catch` da
`AgendaRecusada` nas duas portas -- a recusa que o serviço acha no meio, com o serviço desativado entre a conferência da porta e
a gravação -- não era exercitado: arrancado, a API respondia 500. A trava da pessoa (`travarAPessoa`) só era provada por uma
conexão: o `refresh` sem o `FOR UPDATE` passava. E o `<select>` dos serviços da tela antiga seguia oferecendo o serviço sem área
e o de área não marcada, que a regra agora recusa ao salvar: ele passou a dizer a área de cada um (ou que não tem, e que as
agendas não o oferecem), e o rótulo diz a regra (0 ligações assim entre os ativos; na cópia, nenhum serviço ativo sem área
-- o único é o das fixtures). **Provado:** `UsuarioAgendaPelosControllersTest`, nos dois bancos -- o serviço desativado no meio (um dublê do
`UsuarioService` o desativa logo antes de gravar) é 422 com o campo na API e a frase no campo na tela antiga, sem gravar nada;
outra conexão segura a linha da pessoa, e a gravação para no `FOR UPDATE`, e não no `UPDATE` adiante (o instrumento do
`AdesaoComPesquisaTest`); o `<select>` com a área de cada serviço. Mutantes -- os dois `catch` arrancados, o `refresh` sem a
trava, o `<select>` de antes -- vermelhos. E o caso dos dois editores da F4.1 (`UsuarioHorarioVigenciaTest`) deixou de separar
o banco do objeto, porque a agenda agora relê a pessoa com a trava antes do flush: o mutante "a vigência tira o retrato do
objeto" passava. O caso irmão, pela porta do cadastro (que não relê), o pega.

### D521 — A recusa da tela antiga que volta sem redirecionar aparece de novo na página seguinte: o `flashMessenger` a guarda para a próxima — REGISTRADO em 18/10/2026 — PARCIAL em 18/10/2026: a edição e o cadastro de usuários (e a agenda, da fatia 14); falta o resto do ERP — PARCIAL em 18/10/2026: também a senha da ficha e a de "Meus dados", na fatia 17; falta o resto do ERP — CORRIGIDO em 08/11/2026: as doze ações que voltavam a página sem redirecionar dizem a frase só nela, e um portão acusa a próxima

**Achado na fatia USUARIOS-DADOS** (18/10/2026), conferindo o que a fatia 14 notou e não mediu. As telas antigas dizem a
recusa com `setMensagemErro` (`CrudController`, que chama o `addMessage` do `flashMessenger`) e mostram as mensagens com
`getCurrentMessages()`. O `addMessage` guarda a mensagem num `Container` da sessão com um salto de validade
(`setExpirationHops(1)`, `vendor/laminas/laminas-mvc-plugin-flashmessenger/src/FlashMessenger.php`): ela vale para esta
página e para a seguinte. É assim que o "Editado com sucesso." de antes de um redirecionamento chega à página seguinte; mas a
recusa da página que volta SEM redirecionar aparece agora -- e de novo na próxima: o "E-mail já cadastrado no sistema." da
edição que voltou com a recusa aparecia outra vez em cima do "Editado com sucesso." do salvar seguinte, ou na lista.

**Consertado na edição e no cadastro de usuários** (`UsuariosController::editarAction` e `novoAction`, o molde do
`editarAgendaAction` da fatia 14): a recusa -- o grupo, o próprio estado, a reativação de rede, o login e o e-mail de outra
pessoa -- vai direto às mensagens da página que volta (`recusaNaPagina`, escapada: o topo, `partials/notificacoes`, imprime
cru), e não ao `flashMessenger`. Caso: `UsuarioDadosPelaApiTest::testATelaAntigaMostraARecusaNaPaginaESemGuardarParaAProxima`
(o e-mail e o login, na edição e no cadastro: a página tem a frase, e o `flashMessenger` desta requisição está vazio). Mutante:
a recusa do e-mail da edição de volta ao `setMensagemErro`, vermelho. Os casos que liam a recusa no `flashMessenger`
(`UsuarioAtivacaoTest`, `LoginSemCaixaTest`) passaram a lê-la na página que volta, e o sucesso, que redireciona, continua nele.

**O que falta (lido por grep, não medido no navegador):** o resto do ERP. Um grep de 18/10/2026 pelas chamadas de mensagem
(`setMensagem*`, `add*Message`) sem um `redirect(` nas três linhas seguintes dá até 87 chamadas em 65 ações de 28 controllers --
com falsos positivos, como o `ativarRemoverAction`, que redireciona depois do encadeamento --; no próprio
`UsuariosController`, o `editarSenhaAction` (o "Senha atualizada com sucesso." sem redirecionar) e o `meusDadosSenhaAction`
(o "A senha informada não confere com a atual."). O conserto é o molde desta fatia, ação a ação, ou um redirecionamento depois
de cada gravação; e um portão que acuse a mensagem do `flashMessenger` numa página que não redireciona. Não é escolha do dono:
a mensagem dita duas vezes é defeito.

**A verificação do mesmo dia** achou que o caso só cobria quatro ramos (o e-mail e o login, na edição e no cadastro): a recusa
do grupo, a do próprio estado e a da reativação de rede podiam voltar ao `setMensagemErro` sem nada vermelho, porque o
`UsuarioAtivacaoTest` e o `LoginSemCaixaTest` liam as mensagens da página, que junta as do `flashMessenger` às dela. Agora os
dois conferem que o despacho não pôs recusa nenhuma no `flashMessenger`, e o caso novo ganhou o grupo da edição e o do
cadastro -- pela lista do administrador, que oferece o grupo de rede, com o controller do gerente: o piso que a lista dele
esconde. O `UsuarioAtivacaoTest` esvazia o recipiente do `flashMessenger` antes de cada despacho: o botão da lista, que
redireciona, deixa a recusa dele lá, e o primeiro `addMessage` de um controller novo MOVE o que está no recipiente para "as
da página anterior" -- a mesma frase, posta de novo, deixava o recipiente igual, e o mutante da reativação de rede ficou verde
na primeira rodada, comparando o antes com o depois. Mutantes: cada um dos quatro ramos de volta ao `setMensagemErro`,
vermelhos.

**A fatia 17 (18/10/2026, USUARIOS-CADASTRO-E-SENHA)** fechou as duas do próprio `UsuariosController` que o grep tinha achado: a aba
"Senha" da ficha (`editarSenhaAction`) passou a REDIRECIONAR depois de gravar -- o "Senha atualizada com sucesso." vai à página
seguinte, uma vez, e o F5 não manda a senha de novo --, e "Meus dados" (`meusDadosSenhaAction`) diz o "A senha informada não
confere com a atual." direto na página que volta (`recusaNaPagina`). Casos: `UsuarioCadastroESenhaPelaApiTest`
(`testAFichaAntigaConfereACruaERedirecionaComOAviso`, `testMeusDadosDizARecusaNaPagina`); o `SenhaTrocadaTest` passou a esperar o
302 da ficha. Mutantes: a ficha sem o redirecionamento e "Meus dados" de volta ao `setMensagemErro`, vermelhos. O resto do ERP segue.

**A fatia 120 (08/11/2026, ERP-ANTIGO-ESCAPE-E-MENSAGENS) fechou o resto do ERP.** O grep de 18/10/2026 (até 87 chamadas em
65 ações) foi trocado por uma leitura pelos tokens: cada `->setMensagem*(` e `->add*Message(` dos controllers, e o caminho
dali para frente até o `return` que o termina (os ramos irmãos fora; o `return` de dentro de um bloco aberto depois da chamada
conta como condicional). Em 08/11/2026: 175 chamadas fora das do próprio `CrudController`; 123 antes de um redirecionamento,
29 em 23 ações que respondem JSON ao script da tela, e **23 que não terminavam num redirecionamento**: 20 em 12 ações que
voltavam a página, o ajudante dos cupons e os dois ajudantes que respondem JSON (a finalização do sorteio e a venda fechada). As que respondem JSON foram lidas uma a uma no script que as chama (`mod-*.js` e `mod-autenticacao.js`): todas
recarregam a página, ou levam a outra, no sucesso -- a mensagem aparece lá, uma vez, que é o certo.

**O molde ficou no `CrudController`**: `dizerNaPagina($frase, $tipo = 'danger')` guarda a frase só nesta página, escapada (o
topo, `partials/notificacoes`, imprime cru), e `mensagensDaPagina()` dá à view as do `flashMessenger` e as desta página. Os
`recusaNaPagina` dos usuários (as fatias 15 e 17) ficaram como estavam: são o mesmo molde, privado. As 12 ações passaram a
ele: a lista dos clientes novos da agenda (o limite de sete dias), o cadastro e a edição de clientes (o e-mail e o telefone de
outro), a devolução de crédito (o valor, o saldo, a credencial, a autorização e o caixa que fechou no meio), a versão e o Push
do aplicativo (o sucesso e o erro: a página volta sem redirecionar), o cadastro e a edição de pesquisas, o molde novo de
privilégio, a saída do estoque acima do saldo, os textos da unidade (`mensageria`, o sucesso) e Vendas Realizadas (o
colaborador e a forma na mesma consulta; a frase dizia "possóvel" e agora diz "possível"). O ajudante dos cupons
(`marcarNoFormulario`) mandava a frase sem campo ao recipiente `error` do `flashMessenger`, que nenhuma tela lê: ela sumia
calada, e agora vai ao topo da página (não achei caminho que a alcance hoje -- todo campo da regra está no formulário --, e
por isso não há caso). O primeiro "Não há caixa aberto" da devolução, que redireciona ao caixa, continua no `flashMessenger`.

**O portão** `scripts/conferir-mensagem-sem-redirecionar.php` (no `conferir-tudo.sh` e no `ci.yml`) faz a leitura acima e fica
vermelho com a mensagem numa ação cujo caminho termina numa página, no 404, num arranjo ou no fim do método; a resposta JSON
só passa na lista `RECARREGAM`, com o script que recarrega escrito em cada entrada, e a entrada que não guarda mais mensagem
fica vermelha até sair da lista. Hoje: 154 chamadas, saída 0. **Falsificado:** cada ação de volta ao `setMensagem*` (saída 1);
o ajudante dos cupons de volta ao `addErrorMessage` (saída 1); a chamada só num comentário (saída 0: ele lê os tokens); uma
entrada tirada da `RECARREGAM` (saída 1) e uma inventada (saída 1).

**Provado** nos dois bancos: `D521MensagemSoNaPaginaQueVoltaTest` (novo: os clientes novos, os textos da unidade e a frase da
página escapada) e uma afirmação a mais -- a página tem a frase, e o `flashMessenger` está vazio depois do despacho
(`mensagensGuardadas()`, novo no `ControllerEmProcesso`, cujo `mensagens()` passou a devolver o que a página mostra) -- no
`D417TelefoneNaAlteracaoDoErpTest` (a edição, e um caso novo do cadastro), no `D179DevolucaoRegistradaTest` (o valor, e um caso
novo da credencial), no `DevolucaoDeCreditoSobTravaTest` (o saldo), no `AppConfiguracoesPelosControllersTest` (a versão e o
Push), no `PesquisasPelosControllersTest`, no `PrivilegiosDoErpTest`, no `EstoqueSobTravaTest` e no
`D639GerenteComCaixaEmVendasRealizadasTest` (caso novo). Mutantes, um de cada vez, devolvidos pelo sha1: o molde mandando ao
`flashMessenger`, o molde sem o escape, e cada ação de volta ao `setMensagem*` -- vermelhos (a tabela no roadmap). Sem caso: a
recusa "Usuário não autorizado." da devolução (o portão a vigia) e o ajudante dos cupons. **Não visto no navegador.**

**A verificação da fatia 120 (08/11/2026) achou quatro furos, consertados.** (1) O `mensagens()` do `ControllerEmProcesso`
lia `mensagensDaPagina()` no controller por reflexão, e não a variável `mensagens` que a ação passa à view -- a única que o
topo imprime: a ação que dissesse a frase e passasse `getCurrentMessages()` à view perdia a recusa da tela com o caso verde, e
o portão também verde. Agora o arnês lê a variável da `ViewModel` quando ela a traz (o redirecionamento, o JSON e a view sem a
variável seguem pelo controller), e o portão ganhou a regra "a frase precisa chegar à view": a ação que diz a frase (direto,
ou pelo ajudante do arquivo que a diz) e não chama `mensagensDaPagina()` fica vermelha, o ajudante que nenhuma ação chama
também, e o `dizerNaPagina` cujo caminho termina num redirecionamento ou num JSON, onde a frase some (hoje 21 frases da
página, saída 0). (2) e (3) Três ramos tinham só o portão, e este corpo dizia o contrário: o caixa que fecha entre a página e
a trava (`DEVOLUCAO_SEM_CAIXA` no POST) e o e-mail recusado no cadastro e na edição de clientes. Ganharam caso, e a recusa
"Usuário não autorizado." também (o barbeiro autorizando com a credencial dele): `D179DevolucaoRegistradaTest`
(`testOErpDizOCaixaQueFechouNoMeioSoNaPagina`, com um serviço que fecha o caixa logo antes do servico de verdade, e
`testOErpDizQueQuemNaoEGerenteNaoAutorizaSoNaPagina`) e `D417TelefoneNaAlteracaoDoErpTest`
(`testOCadastroDaPaginaDizOEmailRecusadoSoNela` e `testAEdicaoDaPaginaDizOEmailRecusadoSoNela`, pelo "E-mail inválido": o
"E-mail já cadastrado" pede um domínio que resolva no DNS, e o caso não usa rede). (4) A catraca da cobertura por par desceu
de 108 para 107 por um caso do D203 que só afirmava "lista não vazia"; ele confere agora os ids contra o banco e a ordem.
Mutantes, um de cada vez, devolvidos pelo sha1, vermelhos nos dois bancos: a view de nove ações com `getCurrentMessages()`
(estoque, edição e cadastro de clientes, devolução, versão e Push, pesquisa nova e editada, privilégio novo -- caso e portão),
os quatro ramos de volta ao `setMensagem*` (caso e portão), e o histórico sem o filtro do cliente (dois casos do D203). Sem
caso, com o portão: só a frase do ajudante dos cupons (nenhum caminho a alcança hoje); a view dos cupons com
`getCurrentMessages()` fica vermelha no portão.

### D522 — A edição da tela antiga gravava o que a tela nova recusa: o e-mail sem forma, o nome com emoji, a data que não existe — REGISTRADO em 18/10/2026 — PARCIAL em 18/10/2026: o nome, o e-mail e a contratação da edição; falta o telefone e o cadastro novo — PARCIAL em 18/10/2026: o cadastro novo também, na fatia 17; falta o telefone da edição — CORRIGIDO em 07/11/2026: o telefone da edição antiga, pelas partes e só quando muda (a fatia 117)

**Achado na verificação da fatia USUARIOS-DADOS** (18/10/2026). A regra pura nova, `RegraDosDadosDoUsuario`, só valia no
`PUT /adm/api/usuarios/:id`; o `editarAction` da tela antiga seguia só com o `UsuarioDadosFilter`, que no nome, no e-mail, no
DDI, no telefone e na contratação só pede que não sejam vazios. Duas portas, duas regras (a crítica do D-C do desenho): o
e-mail "fulano" gravava pela antiga e era 422 pela nova -- e o "Esqueci a senha" não chega a ele --, o nome com emoji dava erro
de banco no MySQL (a coluna é `utf8mb3`), e o 31/02/2020 gravava 02/03/2020 (o setter rola a data). No `franquiabv_dev` de
18/10/2026, nenhum dos 371 ativos tem e-mail sem forma: o buraco é de quem digita amanhã.

**Consertado na edição** (o molde do `editarAgendaAction` da fatia 14): `RegraDosDadosDoUsuario::doFormularioDoErp` põe na
língua do corpo o que a tela antiga MUDOU -- o nome aparado, o e-mail sem a caixa e a contratação em AAAA-MM-DD, comparados com
o `estado` da pessoa --, e o `editarAction` passa pela mesma `conferir` antes de tudo; a frase vai ao campo
(`marcarDadosNoFormulario`, com a data em dd/mm/aaaa), o topo diz que nada foi salvo, e nada vai ao `flashMessenger` (o D521).
Só o que mudou: o cadastro antigo que a regra recusaria não trava quem só troca outro campo. Caso:
`UsuarioDadosPelaApiTest::testATelaAntigaPassaPelaMesmaRegraNoQueMudou` (as três recusas no campo, nada gravado, e o e-mail
gravado sem forma não trava a troca do nome). Mutantes: a edição sem a conferência, e a conferência de tudo e não só do que
mudou, vermelhos.

**O que falta:**
- **o telefone da tela antiga.** A ida e volta dela (`Usuario::getDdi`, que parte no primeiro espaço) põe o DDD no campo DDI do
  telefone gravado sem o `+DDI ` da frente -- os 366 ativos da cópia anonimizada --, e o salvar regrava os dois mesmo sem
  ninguém tocá-los ("+(11) 90000-0001"). Conferir ali recusaria o salvar de quem nem mexeu no telefone. O conserto é a tela
  antiga mostrar o telefone pelas duas partes de `RegraDosDadosDoUsuario::partesDoTelefone` (a que a nova já usa) e só então
  passar pela regra -- não medido no navegador;
- **o cadastro novo** (`novoAction`), que a F5 do desenho traz à tela nova: a mesma regra, no cadastro inteiro.

**O cadastro novo** (18/10/2026, a fatia 17, USUARIOS-CADASTRO-E-SENHA): o `novoAction` passa pela `RegraDoCadastroDoUsuario`, a do
`POST /adm/api/usuarios` (`doFormularioDoErp` põe o formulário na língua do corpo), antes de tudo -- o e-mail sem forma, o nome com
emoji, o telefone sem o DDD, a data que não existe e o login com o sufixo de quem está inativo (o D528) são recusados no campo
(`marcarDadosNoFormulario`, com o mapa do cadastro), o topo diz "O cadastro não foi feito", e nada vai ao `flashMessenger`. No
cadastro o telefone entra: não há o que foi gravado antes para a ida e volta estragar. Os dados pessoais seguem só com o filtro da
tela antiga. Caso: `UsuarioCadastroESenhaPelaApiTest::testOCadastroDaTelaAntigaPassaPelaMesmaRegra`. Mutante: o cadastro antigo
sem a regra, vermelho. **Fica:** o telefone da edição, como acima.

**O telefone da edição, corrigido em 07/11/2026 (a fatia 117 da fila, USUARIOS-FICHA-ANTIGA).** A ficha antiga mostra o DDI
e o número de `RegraDosDadosDoUsuario::partesDoTelefone` -- o telefone gravado sem o `+DDI ` da frente abre com o DDI vazio --, e
`doFormularioDoErp` põe o telefone no corpo só quando o DDI aparado ou os DÍGITOS do número mudam: a máscara da tela (`geral.js`,
`.telefone`) reescreve o número ao abrir a página, e os mesmos dígitos noutra forma não são mudança. Mudado, ele passa pela regra do
`PUT` (a frase vai ao `ddi` ou ao `telefone`, `NO_FORMULARIO`); não mudado, o `editarAction` tira as duas chaves do que grava, e o
telefone fica como está -- até esta data o salvar o regravava "+(47) 90000-0001". No filtro da ficha (`UsuarioDadosFilter('editar')`)
o DDI e o telefone deixaram de ser obrigatórios: a regra decide, e o vazio mudado ela recusa; no cadastro seguem obrigatórios. Medido
no `franquiabv_dev` (só contagens): 371 ativos, 366 com o telefone sem o DDI, todos na forma da máscara, e 5 sem telefone -- a ficha
desses 5 só salvava com o telefone preenchido, e agora salva sem ele (o "só o que mudou" do resto deste débito). Caso:
`UsuarioDadosPelaApiTest::testAFichaAntigaMostraASPartesDoTelefoneESoORegravaQuandoMuda` (a ficha com e sem o DDI, o salvar sem
tocar no telefone nas duas formas, as três recusas sem gravar nada e o telefone mudado gravado "+55 ..."), nos dois bancos.
Mutantes, cada um vermelho nos dois bancos: a ficha pelo `getDdi`; a regravação de volta; o telefone fora da regra; a comparação
pelo texto, e não pelos dígitos; e o DDI obrigatório no filtro da ficha. Não visto num navegador (a máscara foi lida, não
exercitada).

**A verificação da fatia 117 (07/11/2026) achou três buracos na prova, e um texto da tela.** (1) Nenhum caso trocava SÓ o DDI: a
pergunta pelo DDI em `doFormularioDoErp` podia sair sem vermelho, e quem escrevia "55" no DDI vazio, sem mexer no número, ouvia
"Editado com sucesso" com o telefone ainda sem o DDI. Caso: `testNaFichaAntigaSoODdiMudadoTambemGravaOTelefone` (o DDI escrito onde
não havia, e um trocado por outro, gravados). (2) O telefone opcional na ficha não tinha caso: `testNaFichaAntigaOTelefoneVazioSoERecusadoAQuemOMuda`
abre a ficha de quem não tem telefone (os dois campos vazios), salva sem ele (o telefone segue nulo) e, de controle, recusa no campo
quem apaga o número já gravado, sem gravar nada. (3) O espelho da ficha no `UsuarioCadastroESenhaPelaApiTest` (`postDaFichaAntiga`)
ainda montava o telefone pelo `getDdi`: passou às partes, e `testOEspelhoDaFichaMandaOTelefoneComoOGetOMostra` confere que ele manda o
que o GET mostra, com o telefone sem o DDI salvo como estava. (4) A ficha rotulava "*DDI" e "*Telefone" depois que o filtro dela os
deixou opcionais: o asterisco ficou só no cadastro (`UsuarioDados`, pelo `$usuario` do construtor). Mutantes novos, cada um vermelho nos
dois bancos: a pergunta pelo DDI arrancada; o telefone obrigatório de volta no filtro da ficha; o asterisco de volta na ficha; e o
espelho de volta ao `getDdi`.


### D523 — Dar ou tirar a marca "Acesso dashboard" não tirava a pessoa do sistema, e a própria pessoa mudava a sua — REGISTRADO em 18/10/2026 — CORRIGIDO em 18/10/2026

**Achado na verificação da fatia USUARIOS-DADOS** (18/10/2026). O formulário novo chamava o `visivel_dashboard` de "No painel",
sob "Onde aparece", com a dica "Se entra nos números do painel da unidade.". Não é isso: é a marca "*Acesso dashboard" da tela
antiga, que abre o Profissional Parceiro, com os próprios números, a quem não é administrador nem gerente
(`RegraDoProfissionalParceiro::escopo`; sem ela, o servidor recusa com `SEM_A_MARCA`), e nenhuma consulta de números a lê -- o
filtro do painel antigo saiu com o Dash. Colaboradores. O gerente que tirasse a marca para "tirar o barbeiro dos números"
tirava dele o acesso; o que a desse para "pôr nos números" concedia uma tela. No `franquiabv_dev` de 18/10/2026, 252 dos 371
ativos não são administradores nem gerentes, e 188 deles têm a marca.

E, sendo acesso, ela fica na sessão desde a entrada (`SessaoAcl::setAcl`): quem a perdia seguia abrindo o Profissional
Parceiro até entrar de novo -- pela senha, ou pelo cookie de 12 horas --, e quem a ganhava só a via depois. A resposta 102 (b)
do dono ("a pessoa sai do sistema quando a permissão muda") só cobria as linhas de privilégio e a troca de grupo (o D291). E a
própria pessoa mudava a sua, pelas duas portas: a regra 1 ("ninguém altera os próprios privilégios") não alcançava o campo.

**Consertado**, nas duas portas:
- o campo é "Acesso ao Profissional Parceiro", no Acesso, com a dica do que faz (ou de que o grupo de gerente já o abre), e a
  leitura também (`AlterarDados.tsx`, `Usuarios.tsx`);
- MUDÁ-LA TIRA DO SISTEMA QUEM ELA ALCANÇA -- quem não é administrador nem gerente --, na mesma transação da gravação
  (`UsuarioService::atualizarDados`, `aMarcaDaAcesso`, o `tirarDoSistema` da troca de grupo), e o formulário diz antes de
  salvar e depois (`formularioDoUsuario.ts`, `aMarcaDaAcesso` com a mesma regra);
- NINGUÉM MUDA A PRÓPRIA (a regra 1, como o grupo): a API responde 403 `proprio` com `campo: visivelDashboard`, a tela antiga diz
  na página (`RegraDosDadosDoUsuario::FRASE_DA_PROPRIA_MARCA`), e a tela nova nem oferece o campo na própria ficha.

Caso: `UsuarioDadosPelaApiTest::testAMarcaDoProfissionalParceiroEAcesso` (a API dá e a tela antiga tira, e a pessoa sai nas
duas; o e-mail sozinho não derruba; no grupo de gerente ninguém sai; a própria, 403 na API e a frase na antiga, nada gravado);
no front, os casos do aviso e da própria ficha (`UsuariosLigado.dados.teste.tsx`, `formularioDoUsuario.teste.ts`). Os POSTs da
tela antiga nos casos do `UsuarioAtivacaoTest` e do `PrivilegioDerrubaASessaoTest` mandavam a marca em "0" contra o 1 das
fixtures (a padrão da coluna), e a trocavam calados a cada salvar: passaram a "1". Mutantes: o serviço sem o `tirarDoSistema`
da marca, o `aMarcaDaAcesso` sem o gerente, a API sem a recusa da própria, a antiga sem ela, e o aviso do salvar sem a marca,
vermelhos.

### D524 — Quem altera usuários altera a própria cota-parte, e a janela das extras grava fora da trava da pessoa — REGISTRADO em 18/10/2026 — CORRIGIDO em 18/10/2026

**O D-F do desenho do resto de Usuários** (`docs/f3-telas-restantes/tela-usuarios-resto.md`, a pergunta 3), registrado e
consertado na fatia 16 da fila das telas que faltam (USUARIOS-COTA-PARTE). Quem escreve em usuários -- os 107 gerentes da cópia,
além dos 10 administradores -- alterava o próprio percentual de cota-parte e as próprias extras, pela tela antiga
(`comissaoAction`, `comissaoExtraSalvarAction`, `comissaoExtraRemoverAction`): o `podeMexerEm` deixa a própria pessoa passar,
e nenhuma das três perguntava. É dinheiro que sai da venda para quem decide o percentual: 8 dos 107 têm percentual, 4 deles
tiveram cota-parte em 2026 (R$ 326.068,70 em 11.496 itens até 16/08), e 2 dos 8 são os únicos da unidade que editam usuários
(o [C13] e o [C14] do desenho). O dono respondeu a 219 do lote em 15/10/2026 com a opção (a): "Ninguém altera a própria, nem o
administrador".

E, achado ao montar o `PUT` da tela nova, a janela das extras gravava e apagava as linhas sem travar a pessoa: com a tela nova
gravando a cota-parte inteira com o `antes` (o molde do D293), a extra que a janela pusesse no meio seria apagada pelo `PUT`, ou
ganharia a gêmea do par (o D414).

**Consertado:**
- NINGUÉM ALTERA A PRÓPRIA, NEM O ADMINISTRADOR, nas duas portas e com a regra num lugar só
  (`RegraDaCotaParte::recusaDaPropria`, pelo `ehASiMesmo` -- a pessoa assumida e também quem entrou, na personificação): o
  `PUT /adm/api/usuarios/:id/cota-parte` responde 403 `proprio`, com a frase (`FRASE_DA_PROPRIA`); a tela antiga diz a frase na
  página (fora do `flashMessenger`, o molde do D521), não mostra o salvar, o "Nova", o editar nem o tirar da própria, e a janela
  recusa salvar e tirar com a mesma frase. A tela nova nem oferece o "alterar" na própria (o `proprio` do `GET`);
- A PORTA DA COTA-PARTE TRAVA A PESSOA (`UsuarioService::gravarCotaParte`, o `FOR UPDATE` de `travarAPessoa`), e a janela das
  extras também (`comissaoExtraSalvar`, com o par conferido de novo na trava -- `CotaParteRecusada` --, e `comissaoExtraRemover`);
  a base da tela antiga grava pela mesma porta.

Casos: `UsuarioCotaPartePelaApiTest` (nos dois bancos) -- `testNinguemAlteraAPropriaNemOAdministrador` (a pessoa, o administrador
que é a pessoa, quem entrou na personificação, e o administrador que entrou: 403, nada gravado; o colega, 200),
`testATelaAntigaNaoAlteraAPropria` (a página, a janela que salva, a que edita e a que tira, e a recusa fora do `flashMessenger`),
`testATravaDaPessoaEDeVerdadeNasPortas` (outra conexão segura a linha: o `PUT`, a base da tela antiga pelo serviço e pela
página, a extra nova e a tirada da janela param no `FOR UPDATE`) e `testOParDaJanelaEConferidoDeNovoComAPessoaTravada`; o
`ensaiar-usuarios-adm-api.php`, 34 (a própria, 403, nos dois bancos de contrato). Mutantes: a própria aceita na API, a própria
sem quem entrou, o `GET` sem o `proprio`, a página, a janela que salva e a que tira aceitando a própria, a recusa no
`flashMessenger`, as três travas tiradas uma a uma, o par fora da trava e a página gravando sem a porta -- todos vermelhos.

**Na verificação da fatia (18/10/2026), mais quatro buracos do mesmo débito, consertados:**
- A JANELA LIA A EXTRA ANTES DA TRAVA, E NÃO A PERGUNTAVA DE NOVO DEPOIS: o editar de uma extra que o `PUT` da tela nova tirou
  enquanto a janela esperava a vez virava um `UPDATE` sobre linha nenhuma -- o Doctrine não reclama -- e a janela dizia "salvo".
  Agora `comissaoExtraSalvar` pergunta ao banco, com a pessoa travada, se a linha ainda é da pessoa (`extraAindaDaPessoa`), e
  recusa com a frase (`RegraDaCotaParte::FRASE_DA_EXTRA_QUE_SAIU`, "recarregue a página"), sem gravar. O percentual mudado no
  meio não pede releitura: o do pedido é inteiro e o da coluna volta `float`, e o Doctrine compara com `===` -- o número do
  operador sempre se grava, como a janela sempre gravou (o caso o prende);
- o `try`/`catch` do par criado no meio, no `comissaoExtraSalvarAction`, não tinha caso: sem ele, a corrida dava 500;
- a recusa da própria na tela antiga não tinha caso com personificação (o `ehASiMesmo`, e não só o `usuarioId`);
- e o que a página antiga deixa de mostrar à própria (o salvar, o "Nova", o editar e o tirar) só era conferido pela variável
  `proprio`: o caso agora desenha a página.

Casos novos, no mesmo `UsuarioCotaPartePelaApiTest`: `testAJanelaReleAExtraComAPessoaTravada`,
`testOParCriadoNoMeioVoltaAJanelaComAFraseESemAGemea`, `testAPaginaAntigaDizDesdeQuandoValeESoOfereceOsBotoesAQuemNaoEAPropria`, e
`testATelaAntigaNaoAlteraAPropria` com quem entrou na personificação (a pessoa e o administrador). Mutantes da verificação: a
janela sem perguntar a linha na trava, o `try`/`catch` tirado, as três ações da tela antiga com o `usuarioId` no lugar do
`ehASiMesmo`, e o salvar, o "Nova" e os botões da tabela de volta à própria -- todos vermelhos.

### D525 — A soma da base com a extra não tem teto: 100% e 100% viram 200% do item — REGISTRADO em 18/10/2026 — DECIDIDO em 18/10/2026 pela resposta 239 (b): a soma não passa de 100% — CORRIGIDO em 19/10/2026

**Achado na verificação da fatia 16 da fila das telas que faltam (USUARIOS-COTA-PARTE).** A cota-parte que a comanda grava em
cada item é o valor do item vezes a base da pessoa MAIS a extra dela naquele produto (`VendaProdutoService::calcularComissao`:
`($valorTotal + $valorSessaoPacote) * (($comissaoServico + $comissaoExtraSer) / 100)`, e o mesmo com a de produto). As duas telas
conferem cada número sozinho -- a base de 0 a 100 (`RegraDaCotaParte::percentualDaBase`; a lista da tela antiga, de 1 a 100) e a
extra de 1 a 100 (`percentualDaExtra`) --, e nenhuma confere a soma: um gerente que escreve em usuários põe 100% de base e 100% de
extra, e o item seguinte daquele serviço grava ao barbeiro o dobro do que o cliente pagou. A tela antiga sempre deixou; a fatia 16
manteve, porque o teto é regra de negócio e o desenho só fixa cada percentual.

**Medido** no `franquiabv_dev` (18/10/2026, só contagens, sem dado pessoal): das 23 extras que o cálculo lê, 1 soma mais de 100%
com a base -- 37% mais 70%, de pessoa inativa, num serviço inativo; entre pessoa e produto ativos, a maior soma é 68%. A maior
base de um ativo é 70% nos serviços e 35% nos produtos.

**O que se fez, sem decidir:** nada no cálculo nem nas regras; a pergunta foi ao lote (TELAS-RESTANTES [vendas], 18/10/2026). Se
o dono quiser o teto, ele mora na `RegraDaCotaParte` (o `PUT` e a janela antiga, pela mesma regra) e no espelho do front, e a
recusa diz o campo que estoura; o que já está gravado acima do teto (1 linha, de inativo) não se reescreve -- a extra lida que
volta igual não se confere.

**Decidido (18/10/2026) e feito (19/10/2026, a fatia 17b da fila das telas que faltam, RESPOSTA-239).** O dono respondeu (b), "A
soma não passa de 100%" -- a recomendação: as duas telas recusam a extra, ou a base, que faria alguma soma passar de 100% do item, e
dizem qual; o que já está gravado acima de 100% fica como está até alguém mexer nele. A regra mora num ponto só,
`RegraDaCotaParte::conferirASoma` (pura), e as três escritas a chamam COM A PESSOA TRAVADA, contra o banco de agora: o `PUT
/adm/api/usuarios/:id/cota-parte` e o salvar da base da tela antiga (`comissaoAction`), pela porta `UsuarioService::gravarCotaParte`,
e a janela das extras (`UsuarioService::comissaoExtraSalvar`, com a extra da janela no lugar da que ela edita, ou no fim). Duas
gravações ao mesmo tempo -- a base pela tela antiga e uma extra pela janela, por exemplo -- passam uma depois da outra, e a segunda
confere a soma com o que a primeira gravou.

- **A soma é a do cálculo** (`VendaProdutoService::calcularComissao`, `RegraDaCotaParte::PARTE_DO_TIPO`): a base de serviços com a
  extra de serviço ou de Lava VIP; a de produtos com a de produto da barbearia ou do empório. A extra de pacote, que o cálculo não
  lê, não soma. A EXTRA DE PRODUTO INATIVO SOMA: o item de comanda aberta refeito depois (`VendaProdutoService::alterar`) a lê, e o
  produto pode voltar -- e a linha de 107% da cópia é justamente de um serviço inativo.
- **Só se recusa a soma que o salvar cria ou aumenta**: a extra nova (e a que a janela antiga leva a outro produto, que é outra
  soma), o percentual maior, a base maior. A que já estava acima de 100% e volta igual, ou desce, passa -- também quando a base sobe
  e a extra desce o mesmo tanto. 100% exatos passam.
- **A frase diz qual**, com o item e a conta (`RegraDaCotaParte::FRASE_DA_SOMA`): "Corte: 70% de base mais 40% de extra dão 110%; a
  soma não passa de 100%." -- no campo que subiu: o percentual da extra nova ou do que subiu; senão, a base, com a maior soma que ela
  estoura (baixá-la até ali resolve todas). Na API, 422 com o campo (`extras.<i>.percentual`, `base.servico`, `base.produto`); na
  página antiga, no campo da base, com o topo dizendo que nada foi salvo, nada no `flashMessenger` (o D521) e a base escolhida no
  formulário; na janela, DEBAIXO DO PERCENTUAL -- o servidor diz o campo (`campo: 'comissao'`) e o `usuarioComissaoExtraSalvar` do
  `mod-configuracoes.js` (servido com `?cache=166`) a escreve ali, até o operador abrir a janela ou salvar de novo; as outras recusas
  da janela seguem no `toastr` de 5 s, onde a frase longa se perdia --, ESCAPADA, porque a janela a escreve como HTML e a frase traz
  o nome do produto. O formulário novo (`formularioDaCotaParte.ts`, `conferirASoma`) recusa antes do pedido, com a mesma frase e o
  foco no campo, SÓ COM OS NÚMEROS QUE SE GRAVAM (a base de 0 a 100 e a extra de 1 a 100; o servidor recusa a forma antes de chegar à
  soma), e a frase sai quando o operador mexe em qualquer um dos dois lados da conta (`marcasDaSomaQueAMudancaAlcanca`: a base, o
  percentual, o "tirar"); a nota das extras diz que a soma não passa de 100% do item.
- **Nenhum cálculo de comissão mudou**: o `VendaService`, as comandas, a Cota-parte do menu e o PDF ficam como estavam; a resposta
  decide só o que as telas aceitam gravar daqui em diante.

**Medido de novo** no `franquiabv_dev` (19/10/2026, só contagens, sem dado pessoal): 23 extras que o cálculo lê (11 de serviço e
12 de produto do bar; as 12 de pacote não contam); 1 soma acima de 100% -- 107%, pessoa e serviço inativos --, nenhuma igual a 100%;
entre pessoa e produto ativos, a maior é 68% (12 combinações); entre pessoa inativa e produto ativo, 75% (7); nenhum percentual
fracionário, na base ou na extra; nenhuma linha das fixtures na cópia. Ninguém ativo perde nada com o teto.

**Provado** em `RegraDaCotaParteTest` (sem banco: a parte de cada tipo, a nova, a base com a maior soma, o que já estava acima, a
extra levada a outro produto, a frase) e em `UsuarioCotaPartePelaApiTest` (os dois bancos: o `PUT` com o 422 no campo que subiu e
nada gravado; a linha de 107% que fica até alguém mexer, pelo `PUT` e pela página antiga; o pacote que não soma; a página e a
janela antigas; a frase da janela escapada; e a frase, o teto e o mapa dos tipos do front iguais aos do servidor); no front, em
`formularioDaCotaParte.teste.ts` e `UsuariosLigado.cota.teste.tsx`.

**A verificação (19/10/2026)** confirmou 9 apontamentos, tratados assim:

- **A trava não tinha prova.** O que este débito afirma -- duas gravações ao mesmo tempo, e a segunda confere a soma com o que a
  primeira gravou -- valia no código, e nenhum caso o provava: conferir a soma da janela antes da trava, ou com a base lida antes
  dela, deixava a suíte verde. `testASomaEConferidaComAPessoaRelidaNaTrava` chama as portas PELO SERVIÇO, com o objeto lido antes
  da gravação de um segundo `EntityManager`: a janela com a pomada a 45% depois de outro pôr a base de produtos em 60% (105%,
  recusada), e a base da tela antiga que volta a 37% depois de outro a baixar para 30% com a extra de 70% (de 100% para 107%,
  recusada; a correção do outro fica).
- **O espelho do front somava com a base fora da faixa** (101 a 999) e punha na extra uma conta que o servidor nunca manda -- ele
  recusa a forma antes da soma. Agora a base e a extra que não se gravam ficam só com a recusa delas.
- **A frase da soma ficava velha** quando o operador corrigia pelo outro lado da conta (baixava a extra, ou a tirava, com a frase
  na base): a marca, o `aria-invalid` e o alerta só saíam no próximo salvar. Agora saem com a mudança.
- **A janela antiga mostrava a frase num aviso de 5 s**; agora fica debaixo do percentual (acima).
- **A página antiga guardava a base escolhida, sem prova**: o caso confere o valor no formulário e o `selected` na página desenhada.
- **A nota "A soma não passa de 100% do item." não tinha caso**: o caso de abrir o formulário a confere.
- **Uma porta que não é desta fatia:** a tela antiga de produtos troca o tipo do produto, e a extra de um produto que vira insumo
  deixa de contar e volta a contar quando ele volta, sem a soma ser conferida. Registrada à parte, com a pergunta ao dono (o D531).
- **O D526 no mesmo arquivo:** a mudança dele (a 241 levada à página) estava no `git status` antes desta fatia, de outro trabalho;
  ficou intacta, e vai num commit à parte.

A prova da verificação: `testASomaEConferidaComAPessoaRelidaNaTrava` (novo) e mais asserções em
`testATelaAntigaRecusaASomaNaPaginaENaJanela` (os dois bancos); `cotaParteDaTelaAntiga.teste.ts` (novo: a função da janela, do
arquivo servido, sobre um jQuery de mentira, e o piso do `?cache=`); 3 casos novos em `formularioDaCotaParte.teste.ts` e 2 em
`UsuariosLigado.cota.teste.tsx`. 19 mutantes da correção, um por vez e devolvidos byte a byte, todos vermelhos -- entre eles a trava
da janela depois da soma e a base de antes lida antes da trava, que só o caso novo pega.

### D526 — Quem é cadastrado pela tela nova não tem nascimento nem endereço, e a ficha da tela antiga os obriga ao salvar — REGISTRADO em 18/10/2026 — DECIDIDO em 19/10/2026 pela resposta 241 (b) — CORRIGIDO em 19/10/2026

**Achado na fatia 17 da fila das telas que faltam (USUARIOS-CADASTRO-E-SENHA).** O `POST /adm/api/usuarios` cadastra sem os dados
pessoais -- o nascimento, o endereço, o CPF, o CNPJ e o INSS --, como a fila pede ("Fora: os dados pessoais"; a pergunta 6 de
`docs/f3-telas-restantes/tela-usuarios-resto.md`, a LGPD, nunca foi ao lote). As colunas aceitam nulo, e a pessoa nasce sem eles. A
ficha da tela antiga, porém, obriga o nascimento, o CEP, o estado, a cidade, o bairro, a rua e o número ao salvar qualquer coisa
nela (`UsuarioDadosFilter`, o `editar`): quem foi cadastrado pela nova e tem o login trocado na antiga -- o login só se troca lá --
precisa que alguém preencha também o nascimento e o endereço.

**Medido** no `franquiabv_dev` (18/10/2026, só contagens): dos 366 ativos, 157 já estão sem algum dos campos de endereço que a
ficha antiga obriga, e nenhum sem o nascimento; 95 cadastros em 2026 (até a cópia de 16/08).

**O que se fez, sem decidir:** o cadastro novo fica sem os dados pessoais, e a ficha antiga como está -- nada se perde. A pergunta
foi ao lote (TELAS-RESTANTES [cadastro], 18/10/2026), com a recomendação do desenho: a ficha antiga deixa de obrigá-los; e à
página como a 241 só em 19/10/2026 -- no commit da fatia 17 ela tinha ficado só no lote, e a página não a mostrava.

**Decidido pelo dono em 19/10/2026: a resposta 241 (b), "O cadastro novo também os pede"** -- não era a recomendação (a). **Feito no
mesmo dia (a fatia 18b da fila das telas que faltam, RESPOSTAS-240-241-242).** O `POST /adm/api/usuarios` passou a pedir, obrigatórios
como a ficha antiga, o nascimento (`dataNascimento`, uma data que existe, em AAAA-MM-DD) e o endereço (`endereco`: o CEP de 8 dígitos,
com ou sem o traço; o estado pela sigla, entre as 27 UFs; a cidade, o bairro, a rua e o número pela `RegraDoTexto`, até 100; o
complemento pode faltar, como lá), cada parte com a chave dela (`endereco.cep`...) -- `RegraDoCadastroDoUsuario::conferirOsPessoais`.
Grava na forma da tela antiga (`paraOServico`): o CEP em `00000-000`, a sigla em maiúsculas em `estado`, o nascimento em `d/m/Y` pelo
setter da entidade; o complemento que não veio fica nulo. O CPF, o CNPJ e o INSS seguem de fora (422, "Os documentos ficam, por
enquanto, na tela antiga"). A ficha antiga só pede que não sejam vazios, para todo país, e a tela nova pede também a forma -- a lista
de estados da antiga é a da tabela `estados` (no banco de fixtures, só uma sigla inventada; na cópia, quatro), e por isso o cadastro
da tela antiga não passa os dados pessoais pela regra nova (`conferirDoErp`, sem os `PESSOAIS`): segue com o filtro dele. A
resposta do `POST` não traz o endereço, como a leitura de hoje; o nascimento, sim, como já trazia. Na tela, o formulário do cadastro
(`CadastrarUsuario.tsx`, o espelho em `formularioDoCadastro.ts`) ganhou a data de nascimento e o grupo "Endereço" (o CEP, o estado
entre as 27 UFs, a cidade, o bairro, a rua, o número e o complemento), todos sem o preenchimento do navegador, com a recusa no campo
e o foco no primeiro; o aviso de antes de cadastrar deixou de dizer que o nascimento e o endereço ficam na tela antiga (só os
documentos). As prévias de Usuários (`CadastrarUsuarioNovo`, `CadastroRecusadoNoFormulario`) mostram os campos novos.

Casos: `UsuarioCadastroESenhaPelaApiTest` -- `testCadastraOsCamposDoDesenhoComOMoldeEAVigencia` (grava o nascimento e o endereço
na forma da tela antiga, e o endereço não volta), `testARecusaDaRegraNaoCadastraNada` (a data que não existe, a de outra forma, o
endereço que não é objeto, o CEP curto, a UF inventada, a cidade em branco, o emoji, o teto, o número que falta, o sinal de tag no
complemento, a parte que ninguém conhece, o `cep` fora do endereço), `testQuemNasceuPelaTelaNovaSalvaNaFichaAntigaSemPreencherNada`
(o POST que a ficha antiga mostra salva -- e o controle, sem o nascimento e o endereço, ouve "Campo obrigatório" nos sete) e
`testOCadastroDaTelaAntigaNaLinguaDoCorpo`; no front, `formularioDoCadastro.teste.ts` e `UsuariosLigado.cadastro.teste.tsx`. **Medido**
no `franquiabv_dev` (19/10/2026, só contagens): dos 371 ativos, 162 seguem sem algum dos campos de endereço que a ficha antiga
obriga -- os de antes, que esta fatia não toca.

**A verificação da fatia 18b (19/10/2026) achou o resto, consertado no mesmo dia.** "Salva na ficha antiga sem ninguém preencher
nada" só valia para as UFs da tabela `estados`: a ficha monta o `select` do estado dela, que desde o D74 é o cache da busca de CEP --
quatro UFs no `franquiabv_dev` do MySQL (SP, SC, RJ e a ZY das fixtures), só a ZY nas fixtures; 28 linhas no do PostgreSQL. Com a
sigla gravada fora dela, o `FormSelect` não marca opção nenhuma, o navegador manda o "Selecione" vazio, e o salvar recusa o estado
com "Campo obrigatório" -- a ficha só salvava escolhendo outra UF, a errada. O caso `testQuemNasceuPelaTelaNovaSalvaNaFichaAntigaSemPreencherNada`
não via: montava o POST com o valor do elemento, e não com o que a página desenha. **Medido** no `franquiabv_dev` do MySQL (só
contagens): 51 ativos já tinham o estado fora da tabela (duas UFs), e 158 dentro; no do PostgreSQL, nenhum fora. Na produção, pelo D74,
o dicionário não foi limpo, e tinha as 27 (não medido lá); e o passo 3 do corte as repõe (o D430). **O conserto, no molde do D416 e do D536** (o
valor que a pessoa já tem fica na lista): `UsuarioDados::estadosComODaPessoa` acrescenta ao `select` a sigla gravada que a tabela
não tem, com o nome da UF -- nada se escolhe por ninguém, e a lista da tabela não muda. Fica com o D430 a busca de CEP da ficha, que
não acha a opção da UF que a tabela curta não tem (só no desenvolvimento). **E as frases:** o que falta no nascimento e no endereço
diz a frase da ficha, "Campo obrigatório" (`RegraDoCadastroDoUsuario::FRASE_DA_FICHA`, e o espelho do front), e a forma que a ficha
não confere -- a data que existe, de 1900 a 2099; o CEP de 8 dígitos; a sigla -- a frase dela. O complemento só de espaços é o que
não veio, como no front. **O país:** a ficha antiga também é só brasileira no CEP e no estado -- a máscara `00000-000` do `geral.js`
vale para todo campo `.cep` e o estado é um `select` de UFs, numa coluna de 2 letras; só o telefone muda com o país da unidade --, e
nenhuma unidade ativa da cópia é de fora: a regra nova não mudou. Casos: `UsuarioCadastroESenhaPelaApiTest` -- o da ficha antiga com
o POST que o navegador manda (o `select` sem a opção vai vazio; a premissa: "SC" fora da tabela das fixtures),
`testAFichaOfereceOEstadoQueAPessoaJaTem`, `testOComplementoQueVeioEGravadoEOSoDeEspacosNao` e a lista do
`testARecusaDaRegraNaoCadastraNada` (o vazio de cada parte, e os anos 1899 e 2100); no front, `formularioDoCadastro.teste.ts` e
`UsuariosLigado.cadastro.teste.tsx`.

### D527 — As portas da senha não pedem a mesma coisa: a ficha conferia a senha aparada e sem os sinais de tag e gravava a crua, e "Meus dados" e o link aceitam um caractere — REGISTRADO em 18/10/2026 — PARCIAL em 18/10/2026: a ficha, na fatia 17; falta "Meus dados" e o link, que são do dono — PARCIAL em 18/10/2026: o filtro de "Meus dados" também, na verificação da fatia 17; falta o mínimo de "Meus dados" e o do link, que é do dono — DECIDIDO em 19/10/2026 pela resposta 240 (a) — CORRIGIDO em 19/10/2026 — FEITO em 06/11/2026 pela resposta 319 (b): as duas páginas da redefinição também no `/novo`, com a mesma regra (a fatia 99)

**Achado na fatia 17 da fila das telas que faltam (USUARIOS-CADASTRO-E-SENHA)**, montando a senha de outra pessoa pela tela nova
"com a mesma regra da tela antiga, num ponto só". Há quatro portas onde uma senha é escolhida, e três regras:

- **o cadastro** (`UsuarioDadosFilter`, desde o D348): de 6 a 20 caracteres e a confirmação igual, sobre a senha como foi
  digitada -- e é ela que se grava;
- **a aba "Senha" da ficha** (`UsuarioSenhaFilter`, o `editarSenhaAction`): de 6 a 20 e a confirmação igual, mas conferidos
  sobre a senha FILTRADA (`StripTags`, `StringTrim`), enquanto a ação grava o POST cru -- "  abcd  " (oito caracteres) era
  "curta", e "abc<x>def" não "correspondia" a si mesma (o `Identical` comparava a confirmação filtrada com a senha crua): a
  pessoa não conseguia a senha que escolheu. O mesmo defeito do D348, na outra porta;
- **"Meus dados"** (`MeusDadosSenhaFilter`): só que não seja vazia, também sobre a filtrada -- a própria pessoa escolhe "1", e
  o mesmo desencontro da ficha: a ação confere a atual e grava a nova cruas, e o `Identical` comparava a confirmação filtrada com a
  senha crua;
- **o link do "Esqueci a senha"** (`AutenticacaoController::validarFormSenhaConfirmar`, e não o `SenhaConfirmarFilter`, que nenhum
  controller usa -- corrigido na verificação da fatia 17, que leu a ação): sem filtro nenhum, a senha crua, a confirmação igual pelo
  `!=` do PHP, e o mínimo de 6 BYTES (`strlen`), e não de 6 caracteres, sem teto -- "12345" não serve, "éêã" (três letras, seis
  bytes) serve, e uma de cem caracteres também.

**Consertado na ficha** (a fatia 17): a regra mora numa classe pura, `RegraDaSenha` (os números, as frases da tela antiga e a
conferência sobre a crua), que o `POST /adm/api/usuarios`, o `PUT /adm/api/usuarios/:id/senha`, o `UsuarioDadosFilter` e o
`UsuarioSenhaFilter` leem -- este sem os filtros, como o do cadastro desde o D348. O hash continua o da entidade
(`Usuario::setSenha`, com o `salt` do D78), pela porta do serviço. Caso: `UsuarioCadastroESenhaPelaApiTest`
(`testAFichaAntigaConfereACruaERedirecionaComOAviso`: a com os espaços e a com o sinal de tag gravam e conferem;
`testOsFiltrosDaTelaAntigaLeemARegraDaSenha`: os dois filtros e a API dizem o mesmo). Mutante: o filtro da ficha com o `StripTags`
e o `StringTrim` de volta, vermelho.

**Consertado no filtro de "Meus dados", sem decisão** (18/10/2026, a verificação da fatia 17): o desencontro entre o que se confere
e o que se grava é o defeito do D348, e não uma regra -- o `MeusDadosSenhaFilter` perdeu o `StripTags` e o `StringTrim` na atual, na
nova e na confirmação, como o da ficha; a ação já conferia e gravava o POST cru. Caso:
`UsuarioCadastroESenhaPelaApiTest::testMeusDadosConfereACruaERecusaONulo` (a com o sinal de tag e a com os espaços trocam e conferem;
o nulo volta no campo, o D529; um caractere ainda serve). Mutante: o filtro de volta, vermelho.

**O que falta, e é do dono:** o mínimo. "Meus dados" aceita um caractere; o link pede 6 bytes, sem teto; o cadastro e a ficha, de 6
a 20 caracteres. Igualar muda o que a própria pessoa pode escolher, e foi ao lote (TELAS-RESTANTES [seguranca], 18/10/2026), com a
recomendação de 6 a 20 em todas -- o texto da pergunta foi acertado na verificação, que achou o link diferente do que ela dizia. O
caso `SenhaTrocadaTest::testOLinkRecusaONuloEPedeSeisBytes` prende o de hoje do link. Quantas senhas curtas existem não foi medido:
contar exigiria abrir as senhas guardadas.

**Decidido pelo dono em 19/10/2026: a resposta 240 (a), "De 6 a 20 caracteres em todas as portas".** **Feito no mesmo dia (a fatia
18b da fila das telas que faltam, RESPOSTAS-240-241-242).** "Meus dados" (`MeusDadosSenhaFilter`) lê os números e as frases da
`RegraDaSenha` -- "Tamanho mínimo 6 caracteres", "Tamanho máximo 20 caracteres" -- só na senha nova: a atual não passa pelo tamanho,
e quem já tem uma senha curta, ou longa, continua entrando com ela e a troca por ali. O link do "Esqueci a senha"
(`AutenticacaoController::validarFormSenhaConfirmar`) chama a `RegraDaSenha::conferir`: de 6 a 20 caracteres, e não bytes; a
confirmação pela comparação estrita da regra, e não pelo `!=` do PHP -- "1000000" e "1e6" eram "iguais", e a gravada era a
primeira --; e o `senha[]` montado à mão é a senha que falta, e não o 500 do `strlen`. A frase da página diz "A senha não foi
redefinida:" com as da regra; o campo que falta segue com a frase de antes. As quatro portas, uma regra. O ensaio
`ensaiar-sessao-do-erp.php` (o caso 17) trocava a senha do contrato -- 22 caracteres -- por "Meus dados" e a punha de volta pela
mesma tela: a senha trocada passou a ter 18, e a volta pela tela é a recusa da regra (a página volta, 200), com a senha reposta pelo
roteiro da montagem (`definir-senha-contrato.php`, que só roda no banco de fixtures). Casos: `SenhaTrocadaTest` --
`testOLinkPedeARegraDasOutrasPortas` (no lugar do que prendia os 6 bytes: o nulo, cinco caracteres, três com acento, vinte e um, o
"1e6", o `senha[]`; seis com acento e vinte servem) e `testMeusDadosPedeARegraSoParaANova` (a de cinco e a de vinte e um voltam com a
frase no campo; a atual de cinco troca pela de vinte) --; e `UsuarioCadastroESenhaPelaApiTest::testMeusDadosConfereACruaERecusaONulo`
(um caractere não serve mais; a atual de um caractere, sim).

**A verificação da fatia 18b (19/10/2026)**, consertada no mesmo dia: (1) no link, a recusa nova -- o teto de 20 é novo nesta porta --
saía pelo aviso do `toastr`, que some em 5 s no canto da tela, e a página só dizia "crie uma senha com com letras e números". A página
(`senha-confirmar.phtml`) passou a dizer "de 6 a 20 caracteres", com os números da `RegraDaSenha`, e a recusa fica escrita nela
(`#recusa-da-nova-senha`), linha a linha e como texto, até o próximo envio, com o foco na nova senha (`mod-autenticacao.js`, pedido
pelo `?cache=168` nas três páginas da senha). O `maxlength` dos campos fica em 100: o de 20 cortaria calado a senha colada. (2) Em
"Meus dados", a nova vazia ouvia duas frases, "Informe a senha" e "Tamanho mínimo 6 caracteres": o `NotEmpty` não cortava a cadeia.
Passou a cortar ali, na aba "Senha" da ficha e no cadastro antigo, que tinham o mesmo par desde a fatia 17. Casos:
`SenhaTrocadaTest::testANovaVaziaDizUmaFraseSoNasTresPortasAntigas` e `testAPaginaDoLinkDizOTamanhoETemOLugarDaRecusa` (a página
desenhada pelo `ViewRenderer`); no front, `senhaDoLinkDaTelaAntiga.teste.ts` (o arquivo servido, inteiro, sobre um jQuery de
mentira).

**06/11/2026:** a resposta 319 (b) do dono (gravada na página do lote em 07/11/2026 01:12 UTC) vira a fatia 99
(REDEFINIR-SENHA-NO-NOVO).

**Feito na fatia 99 (06/11/2026), REDEFINIR-SENHA-NO-NOVO:** "Esqueci minha senha" do formulário de entrada do `/novo`
abre a página de pedir o link DENTRO da tela nova, e o link do e-mail abre a troca da senha na tela nova onde o ambiente
serve o `/novo` (a chave da fatia 98, `EnderecoDaTelaNova::configurado()`; desligada, o link é o antigo, byte a byte). As
duas páginas mandam o MESMO POST das antigas -- a regra desta entrada (`RegraDaSenha`, de 6 a 20 caracteres) vale igual,
dita antes do envio com os números dela, e a recusa do servidor (com `<br/>`) é mostrada linha a linha como texto. O
servidor mudou em duas coisas: o link do e-mail (`EnderecoDaTelaNova::linkDaRedefinicao`) e a frase do sucesso também no
`mensagem` da resposta JSON, com o texto do flash (um dono só). Casos: `RedefinicaoDaSenhaNaTelaNovaTest` (o link com a chave
ligada e desligada, a frase na resposta, o GET 200/404 sem oráculo novo),
`SenhaTrocadaTest::testATrocaPelaTelaNovaDizAFraseNaRespostaEOD347Segue` (o D347 pelo POST da tela nova), e no front
`RedefinicaoDaSenha.teste.tsx` e `formularioDaRedefinicao.teste.ts`. O desenho: a seção 8 de
`docs/f3-telas-restantes/tela-entrada.md`. A enumeração de contas pela frase do pedido ficou como estava: é o D733.

### D528 — O login novo podia terminar no sufixo de quem está inativo ("_old"), e a reativação o tirava — REGISTRADO em 18/10/2026 — PARCIAL em 18/10/2026: o cadastro, nas duas telas; falta a troca do login na ficha da tela antiga — CORRIGIDO em 07/11/2026: a troca do login na ficha antiga também (a fatia 117)

**Achado na fatia 17 da fila das telas que faltam (USUARIOS-CADASTRO-E-SENHA)**, lendo a `RegraDoUsuario` para a regra do login
do cadastro novo. O sufixo `_old` (ou `_old2`...) é a marca da desativação (`loginAoDesativar`, `loginAoReativar`). Um login
escolhido com ele -- "ana_old", ativo -- confunde a regra: desativado, não ganha sufixo nem libera o login (já "tem" o sufixo);
reativado, vira "ana" se estiver livre, e a pessoa deixa de entrar com o login que escolheu. As duas telas aceitavam.

**Consertado no cadastro**, nas duas portas: `RegraDoCadastroDoUsuario` recusa o login terminado no sufixo
(`RegraDoUsuario::FRASE_DO_LOGIN_COM_O_SUFIXO`), e o `novoAction` da tela antiga passa pela mesma regra (o resto do D522). Medido
no `franquiabv_dev` (18/10/2026): 0 logins, ativos ou não, terminam no sufixo -- a anonimização reescreveu os logins; quantos há
em produção, só ela diz. Caso: `UsuarioCadastroESenhaPelaApiTest::testARecusaDaRegraNaoCadastraNada` e
`testOCadastroDaTelaAntigaPassaPelaMesmaRegra`. Mutante: a regra sem o sufixo, vermelho nos dois.

**O que falta:** a troca do login na ficha da tela antiga (`editarAction`), que ainda aceita o sufixo -- o login não está no `PUT`
da tela nova, e a edição antiga só passa pela regra no nome, no e-mail e na contratação (o D522).

**A troca do login na ficha, corrigida em 07/11/2026 (a fatia 117 da fila, USUARIOS-FICHA-ANTIGA).** O `editarAction` pergunta a
`RegraDoUsuario::fraseDoLoginTrocado`: o login que termina no sufixo é recusado com a `FRASE_DO_LOGIN_COM_O_SUFIXO`, no campo, e só
quando o login muda, comparado sem a caixa (como o `setLogin` o grava) -- quem está inativo salva a ficha com o sufixo que a
desativação pôs. No `franquiabv_dev`, 0 ativos com o sufixo (a contagem de 18/10/2026 repetida). Caso:
`UsuarioDadosPelaApiTest::testOLoginTrocadoNaFichaAntigaNaoTerminaNoSufixo` (três formas do sufixo recusadas sem gravar; o inativo
salvo com o sufixo, em duas caixas; e o login trocado sem ele), nos dois bancos. Mutantes: a recusa arrancada, e a recusa sem a
pergunta "mudou?", vermelhos nos dois.

**E o texto do login (a verificação da fatia 117, 07/11/2026).** A troca só conferia o sufixo, e o comentário prometia "a mesma
recusa do cadastro": o login com tabulação, com emoji ou acima de 100 caracteres ia ao `setLogin` (o de 101 dava 500 na coluna de
100). O `fraseDoLoginTrocado` passou a conferir, quando o login muda, o mesmo que o cadastro (`RegraDoCadastroDoUsuario::conferirAs`)
e na mesma ordem: o texto pela `RegraDoTexto` com o `TETO_DO_LOGIN`, depois o sufixo. O login gravado antes fica enquanto ninguém o
troca. Caso: `UsuarioDadosPelaApiTest::testOLoginTrocadoNaFichaAntigaPassaPeloTextoDoCadastro` (os três recusados no campo, com a
frase do cadastro, sem gravar nada), nos dois bancos. Mutantes: o texto arrancado, e a pergunta "mudou?" arrancada da forma nova,
vermelhos nos dois.


### D529 — A senha com o byte nulo dava 500, com a senha crua na pilha, em todas as portas — REGISTRADO em 18/10/2026 — CORRIGIDO em 18/10/2026

**Achado na verificação da fatia 17 da fila das telas que faltam (USUARIOS-CADASTRO-E-SENHA).** A `RegraDaSenha` conferia o tipo, o
vazio, o UTF-8 (o nulo é UTF-8 válido), o tamanho e a confirmação, e deixava passar `"ab\0cdefgh"`. O `Usuario::setSenha` chama o
`password_hash(PASSWORD_BCRYPT)`, que no PHP 8.1 lança `ValueError` com o nulo (medido no contêiner, sem imprimir hash; o
`password_verify` não lança, e só diz falso). Nenhuma ação capturava: o pedido montado à mão dava 500 -- na API, a página de erro em
HTML, que a tela nova lê como "a sessão do ERP caiu" --, e a pilha levava a senha crua como argumento (`zend.exception_ignore_args=0`
no contêiner; só aparece com o `display_exceptions` ligado). Nada era gravado. O mesmo nas seis portas: o `POST` e o `PUT` da API
nova, o cadastro e a aba "Senha" da tela antiga, "Meus dados" e o link do "Esqueci a senha". O navegador não deixa digitar o nulo.

**O conserto:** a `RegraDaSenha` recusa o nulo antes do tamanho (`FRASE_DO_NULO`, no campo), e as portas que não passam por ela leem
`RegraDaSenha::temONulo` -- um `Callback` no `UsuarioSenhaFilter`, no `UsuarioDadosFilter` e no `MeusDadosSenhaFilter`, e uma linha no
`AutenticacaoController::validarFormSenhaConfirmar`. Nada mais muda: nem o mínimo, nem o que já se aceitava. O espelho do front
(`formularioDoCadastro.ts`) diz a mesma frase.

**Provado:** `UsuarioCadastroESenhaPelaApiTest` -- o `POST` e o `PUT` com o nulo, 422 no campo, nada gravado; a ficha antiga e "Meus
dados", a frase no campo e o hash como estava; os filtros e a regra dizendo o mesmo --, `SenhaTrocadaTest::testOLinkRecusaONuloEPedeSeisBytes`
(a senha fica, e o link continua valendo) e o espelho no `formularioDoCadastro.teste.ts`. Mutantes: a regra sem o nulo; cada filtro
sem o `Callback`; o link sem a linha -- todos vermelhos.

### D530 — Na lista de unidades do cadastro, duas unidades diferentes podem ter o mesmo rótulo — REGISTRADO em 18/10/2026 — PARCIAL em 07/11/2026: a lista dos usuários diz o nome e o lugar; faltam os filtros dos relatórios, do Parceiro e das outras listas novas (a fatia 117)

**Achado no navegador, na verificação da fatia 17 da fila das telas que faltam (USUARIOS-CADASTRO-E-SENHA).** O seletor de unidade
do cadastro novo lê o `GET /adm/api/usuarios/unidades`, que é a mesma consulta da lista suspensa do ERP
(`UnidadeRepository::popularCombobox`): o rótulo é o país, o estado, a cidade e o bairro, e não o nome. Duas unidades no mesmo
bairro saem iguais, e quem cadastra não sabe qual escolhe -- a pessoa nasce na unidade da opção, e a ficha mostra depois o nome dela.
No banco de contrato, "Unidade Sessao Teste" e "Unidade WhatsApp Sem Chave" saem as duas como "Brasil - SC - Itajai - Centro". A tela
antiga faz o mesmo no cadastro e no filtro, e o filtro da lista nova também; só o administrador vê mais de uma unidade.

**Medido** no `franquiabv_dev` (18/10/2026, só contagens): 36 unidades ativas, 34 rótulos diferentes -- duas duplas que não se
distinguem. Nada foi mudado: o rótulo é o da tela antiga, e mudar a consulta muda as listas suspensas do ERP inteiro. O conserto
pequeno é a tela nova dizer o nome junto do lugar, só nas telas novas.

**Corrigido nas telas novas em 07/11/2026 (a fatia 117 da fila, USUARIOS-FICHA-ANTIGA), o conserto pequeno.**
`GET /adm/api/usuarios/unidades` devolve o `nome` do cadastro da unidade (`UnidadeRepository::nomesDoCadastro`, o do cabeçalho do
painel) e o `lugar` -- o rótulo de sempre, do `popularCombobox`, que não mudou: as listas suspensas do ERP ficam como estão. O filtro e
o cadastro da tela nova mostram "nome (lugar)" (`rotuloDaUnidade`, em `frontend/src/api/usuarios.ts`; sem nome, só o lugar). Medido no
`franquiabv_dev` (só contagens): as 36 ativas têm 36 nomes diferentes e 34 lugares -- o nome distingue as duas duplas. A tela antiga
(o cadastro e o filtro dela) segue com o rótulo; ela sai quando a nova a substituir. Casos:
`UsuarioDadosPelaApiTest::testAsUnidadesDosUsuariosDizemONomeEOLugar` (o administrador e o gerente, a ordem da lista do ERP e a
chave no tipo `UnidadeDaLista`), nos dois bancos; `api/usuarios.teste.ts` e os casos do filtro e do cadastro em
`UsuariosLigado.teste.tsx` e `UsuariosLigado.cadastro.teste.tsx`, com duas unidades no mesmo bairro. Mutantes: o servidor sem o nome,
vermelho nos dois bancos; o rótulo sem o lugar, a lista sem o rótulo e o nome vazio, vermelhos no vitest.

**PARCIAL, e não CORRIGIDO (a verificação da fatia 117, 07/11/2026).** O conserto valeu só para a lista dos usuários, e o D617
diz que o rótulo repetido dos filtros dos relatórios "em si é o D530". **O que falta**, as listas da tela nova que ainda devolvem
`{id, nome}` com o `nome` = o lugar do `popularCombobox`, e onde as duas duplas da cópia (Itajaí-Centro e Florianópolis-Centro)
seguem iguais: o filtro de todo relatório novo (`RelatorioAdmController::unidadesDoFiltro` e `GET /adm/api/relatorios/<rota>/unidades`,
o caminho que o D617 apontava); o do Profissional Parceiro (`ProfissionalParceiroController::unidadesAction`, cujo comentário passou
a dizer que a forma dele já não é a dos usuários); e as outras listas novas que levam o mesmo rótulo -- os cupons, o painel da
unidade, os convênios, os cursos e as conclusões, as mensagens e as notificações (a lista inteira sai do grep por `popularCombobox`
em `module/*/src/Controller/Adm`). O molde para cada uma é o desta fatia: o `nome` do cadastro (`UnidadeRepository::nomesDoCadastro`)
e o `lugar` à parte, e o `rotuloDaUnidade` na tela; cada tela tem os seus dublês, e por isso não entrou aqui. A tela antiga fica com
o rótulo, como acima.


### D531 — A troca de tipo do produto na tela antiga muda a soma da cota-parte sem conferi-la: a extra de um insumo que volta a produto revive acima de 100% — REGISTRADO em 19/10/2026 — DECIDIDO em 19/10/2026 pela resposta 242 (b) — CORRIGIDO em 19/10/2026

**Achado na verificação da fatia 17b da fila das telas que faltam (RESPOSTA-239; o D525).** A soma da base com a extra não passa de
100% do item nas três portas que gravam a cota-parte (`RegraDaCotaParte::conferirASoma`): o `PUT /adm/api/usuarios/:id/cota-parte`,
a página antiga da cota-parte e a janela das extras. A soma depende também do TIPO do produto da extra (`PARTE_DO_TIPO`): a de
serviço e a de Lava VIP somam com a base de serviços, a de produto da barbearia e a do empório com a de produtos, e a de insumo e a
de pacote não somam -- o cálculo não as lê. E o tipo se troca por outra porta: a edição de produto da tela antiga
(`ProdutosController::editarAction`, `ProdutoService::atualizar`, que grava o `tipo` do POST) oferece Barbearia, Empório e Insumo.
O caminho, passo a passo: a pessoa tem a extra da pomada a 40% e a base de produtos a 50% (90%); a pomada vira insumo, e a extra
deixa de contar; a base sobe para 70% pela cota-parte, e passa, porque a extra do insumo não soma; a pomada volta a produto da
barbearia, e a soma é 110%, sem recusa nenhuma. O select do tipo não confere a lista (`disable_inarray_validator`), e um POST
montado à mão leva um produto para serviço, ou o contrário: a extra passa a somar com a outra base. O catálogo novo não troca o
tipo depois de criado (`RegraDoCatalogo::conferir`: "O tipo não muda depois de criado.").

**Medido** no `franquiabv_dev` (19/10/2026, só contagens, sem dado pessoal): nenhuma extra em insumo (as extras: 12 de produto da
barbearia, ativos; 11 de serviço, 7 ativos e 4 inativos; 12 de pacote, inativos); 4 produtos de insumo na cópia inteira; e nenhuma
soma passaria de 100% se o produto de cada extra trocasse de parte. Hoje, nada aconteceu.

**O que se fez, sem decidir:** nada. A resposta 239 (b) fala das telas da cota-parte, e conferir a soma no cadastro de produto é
outra porta, com outra frase e outra pessoa do outro lado (quem cadastra produto não é quem altera usuários). A pergunta foi ao
lote (TELAS-RESTANTES [vendas], 19/10/2026) e à página como a 242.

**Decidido pelo dono em 19/10/2026: a resposta 242 (b), "A tela de produtos também confere"** -- não era a recomendação (a). **Feito
no mesmo dia (a fatia 18b da fila das telas que faltam, RESPOSTAS-240-241-242).** As portas que trocam o tipo, pelo grep do `tipo`
nos serviços e nos hidratadores: o editar da tela antiga de produtos (`ProdutoService::atualizar`, `CAMPOS_DA_EDICAO`) e o da de
serviços (`ServicoService::atualizar`); o novo das duas cria, e não troca; o catálogo novo recusa ("O tipo não muda depois de
criado"). **Na de produtos**, a troca que faria a cota-parte de alguém passar de 100% naquele produto é recusada no campo do tipo,
dizendo quantas pessoas e que se baixe a extra delas antes, em Usuários (`RegraDaCotaParte::aTrocaDoTipoMudaASoma`,
`quantasPassamComATroca` -- a soma da 239 (b), pela `conferirASoma`, com a extra como nova: com outra parte da base, a soma é outra --
e `fraseDaTrocaDoTipo`); nada se grava, nem o resto do formulário. Só confere quando a parte da base muda (o insumo, ou o pacote,
que vira produto; o produto que viraria serviço): entre a barbearia e o empório a soma é a mesma, e a troca passa até com a soma já
acima de 100%; a que vira insumo para de somar. As pessoas contam ativas ou não, como no `PUT` da cota-parte, e os 100% exatos
passam. A conferência é NA TRANSAÇÃO do editar, com as travas na ordem das escritas da cota-parte: as pessoas com extra no produto
(`FOR UPDATE`, em ordem de id), o produto (`PESSIMISTIC_WRITE`) e a leitura da base e da extra que trava (`FOR SHARE`, a de agora
também no MySQL). **O tipo da lista**: o `Select` das duas telas não conferia (`disable_inarray_validator`), e o POST montado à mão
levava um produto a serviço ou a pacote, e um serviço a produto; os filtros (`ProdutoFilter`, `ServicoFilter`) passaram a pedir um
dos tipos da tela, com a frase no campo, e os quatro controllers (o novo e o editar das duas) gravam o tipo conferido, e não o do
POST cru. Entre a Lava VIP e o serviço a soma é a mesma (a base de serviços), e a de serviços não precisa da conferência.

**Medido** no `franquiabv_dev` do MySQL e do PostgreSQL (19/10/2026, só contagens): nenhuma troca de tipo seria recusada hoje --
nenhuma extra em insumo, e nenhuma soma passaria de 100% se um produto com extra virasse serviço; nenhum produto com tipo fora das
listas. Casos: `TrocaDoTipoDoProdutoTest` (a recusa no plural e no singular, nada gravado, baixar a extra resolve, os 100 exatos, a
troca sem mudar a soma, o tipo fora da lista e o conferido, os filtros, a ordem das travas), `RegraDaCotaParteTest` (as três funções
puras) e `EtiquetasPeloControllerTest::testOServicoSoTrocaParaUmTipoDaListaEGravaOConferido`.

**A verificação da fatia 18b (19/10/2026)**, consertada no mesmo dia: o portão que decidia se a troca muda a soma lia o tipo que o
controller carregou ANTES da transação, e o tipo relido com a trava só valia depois dele. O produto lido como empório, gravado insumo
por outro pedido no meio e salvo como barbearia passava sem conferência -- e a troca de verdade era a de insumo para produto. Agora o
portão sem trava olha só o tipo novo (o insumo, que não soma, não trava nada), e o que soma trava as pessoas e o produto e decide pelo
tipo relido; o salvar da barbearia que fica barbearia trava o produto, que ele grava de todo jeito, e as pessoas com extra nele, mas
não lê a soma. Casos novos em `TrocaDoTipoDoProdutoTest`: `testATrocaSeDecidePeloTipoRelidoComATrava` (o tipo trocado no banco
depois de o produto estar na memória), `testAPessoaInativaComExtraNoProdutoConta`, a página recusada que volta com o tipo e o nome
digitados, e as travas do salvar que não muda a soma e do que vai a insumo.

### D532 — Só o administrador altera etiqueta, e a tela antiga dizia "Excluída" do que só desativava — REGISTRADO em 19/10/2026 — CORRIGIDO em 19/10/2026

**Achado pelo desenho das Etiquetas** (`docs/f3-telas-restantes/tela-tags.md`, o DT-B), e consertado na fatia 18 da fila das
telas que faltam (ETIQUETAS-ERP, a S1b do desenho). O GET que alternava, a primeira metade do DT-B, já tinha saído em
04/10/2026 com o D199 (o desativar pela caixa de `remover-por-post.js` e o ativar por formulário, com o estado pedido). Ficavam:

- **a guarda:** a etiqueta não tem unidade e vale na rede inteira, e o Gerenciar lista os serviços de todas as unidades; quem
  tivesse `configuracoes/produtosTags/escrever` alterava tudo isso. Medido no desenho (M10): nenhuma linha concede a
  funcionalidade 50, por usuário ou por grupo -- só os administradores chegavam lá, e a guarda não tira nada de ninguém;
- **a frase:** "Excluída com sucesso." e "Deseja realmente remover?" para o que só foi desativado.

**O conserto** (o molde do D255 dos convênios e do D356 dos avisos): o `ProdutosTagsController` recusa quem não é
administrador no novo, no editar, no desativar e ativar, no Gerenciar e no lote, no GET e no POST, com a frase "Só o
administrador altera etiqueta: ela vale na rede inteira." (o lote responde a frase no JSON, sem gravar). A lista mostra o
"Nova", o "Gerenciar", o editar e o desativar só ao administrador; quem só lê, ou escreve sem ser administrador, vê a lista. A
frase diz "Desativada com sucesso.", e a caixa, "Deseja realmente desativar esta etiqueta?". O estado é gravado pelo
`ProdutoTagService::definirEstado`, com a linha travada e relida, e não mais pelo `alterStatus` de um `getReference`. O que a
desativação faz com os serviços que têm a etiqueta continua o de hoje: eles ficam com ela (a P3 do desenho).

**Provado** nos dois bancos (`EtiquetasPeloControllerTest`, em processo, cada recusa conferida no banco; `TelasDasEtiquetasDoErpTest`,
a lista renderizada para os dois; o `D199AtivarRemoverPorPostTest` com a frase nova da caixa). Mutantes, um de cada vez: a
guarda arrancada em cada uma das cinco ações, o alternar no lugar do estado pedido, a frase "Excluída" de volta e os dois
`!empty($administrador)` da lista -- vermelhos.

### D533 — O estado da etiqueta tinha mais duas portas, no formulário: o editar desfazia a desativação, e o novo oferecia "Inativa" e a ignorava — REGISTRADO em 19/10/2026 — CORRIGIDO em 19/10/2026

**Achado pelo desenho das Etiquetas** (o DT-C). O formulário antigo tinha o campo "Status": o editar o regravava com o valor
lido ao abrir (`ProdutoTagService::CAMPOS_DA_EDICAO` com o `status`), e salvar uma tela aberta antes desfazia, calado, a
desativação feita em outra aba -- o D259 dos convênios. O novo o escondia (`display-none`) e o construtor da entidade forçava
`status = true` depois de hidratar o POST inteiro (o `id` do campo oculto inclusive).

**O conserto:** o campo saiu do formulário (`Form/ProdutoTag.php`) e das duas views; o editar grava só o nome
(`CAMPOS_DA_EDICAO = ['nome']`), e o novo manda ao serviço só o nome conferido (`RegraDaEtiqueta::doFormularioDoErp`). O estado
muda só pelo desativar e pelo ativar da lista (o D532).

**Provado** nos dois bancos (`EtiquetasPeloControllerTest`): o editar com `status=1` sobre a etiqueta desativada por fora não a
reativa, e o nome muda; o novo com `status=0` e `id` no corpo nasce ativo e com o id do banco; as duas views não têm mais o
campo (`TelasDasEtiquetasDoErpTest`). Mutante: o `status` de volta à edição, com o POST cru -- vermelho.

### D534 — O servidor não limitava o nome da etiqueta, não barrava emoji e aceitava o nome repetido — REGISTRADO em 19/10/2026 — CORRIGIDO em 19/10/2026

**Achado pelo desenho das Etiquetas** (o DT-D). O filtro do nome (`ProdutoTagFilter`) tinha só o `StripTags`, o `StringTrim` e
o `NotEmpty`: o teto de 100 estava só no HTML, a tabela é `utf8mb3` e o MySQL roda estrito -- acima de 100 caracteres, ou com
emoji, a resposta era 500. O `StripTags` cortava calado o que vinha depois de um `<`. O nome repetido passava, e partiria a
contagem da rede em dois. E o nome cresce ao ser gravado: `ProdutoTag::setNome` o passa por `IdealizeUtil::normalizarNome`, que
põe um espaço depois de cada ponto ("A.B" vira "A. B"), e o de 100 conferido chegava ao banco com mais.

**O conserto** (a seção 2.3 do desenho; as decisões 2 e 3 dele, que se desfazem sem perder nada): a regra pura nova,
`module/ISConfiguracao/src/Regra/RegraDaEtiqueta.php`, que a API da fatia 19 vai usar também. O nome é o texto de uma linha da
`RegraDoTexto` (de 1 a 100, sem emoji, sem `<` nem `>`, sem controle, com as frases do nome), e o teto é contado no nome como
fica gravado, e só nele -- na verificação de 19/10/2026 ele deixou de contar também o digitado: "Corte", 92 espaços e "Barba"
(102 digitados, "Corte Barba" gravado) eram recusados, contra a letra da seção 2.3 do desenho. O nome é único sem diferença de maiúscula nem de acento ("Barba", "BARBA" e "Bárba" são o mesmo), contra as
outras etiquetas, ativas e inativas; a própria não conta no renomear. O formulário antigo tem a mesma regra, com as mesmas
frases, sem o `StripTags`; a unicidade é conferida pelo `ProdutoTagService` (`cadastrar` e `atualizar`) dentro de uma
transação, com as linhas da tabela travadas (`FOR UPDATE`), e a frase volta ao campo, sem gravar nada.

**A trava lê duas vezes, e o porquê:** no PostgreSQL (READ COMMITTED), o comando que esperou a trava não vê a linha que o outro
inseriu enquanto ele esperava; o comando seguinte, na mesma transação, vê. A primeira leitura espera, a segunda lê.
**Provado na verificação de 19/10/2026, com outro processo** (o instrumento do D321, `OutroProcessoSeguraALinha`), fora da
transação do `setUp`: o outro trava as linhas, insere "Corrida Teste" e confirma depois de 1,5 s; o cadastro de "corrida
teste" espera a trava e recusa, nos dois bancos. Dentro da transação do `setUp` o Doctrine não exigia a do serviço, e tirá-la
passava verde -- e dava 500 em toda escrita das etiquetas fora dos testes. Não são dois pedidos HTTP: é o banco esperando de
verdade.

**Medido** no `franquiabv_dev` (19/10/2026, só contagens): 8 etiquetas, todas ativas, 8 nomes distintos, o maior com 19
caracteres, nenhum com ponto -- ninguém fica preso.

**Provado:** `RegraDaEtiquetaTest` (puro: cada fronteira, e o formulário de verdade dizendo o mesmo que a regra) e
`EtiquetasPeloControllerTest` (o repetido no novo e no editar, com outra caixa e com acento, a inativa contando; o `<`, o emoji,
o nome que cresce e o de 101, no novo e no editar; a própria com outra caixa grava), nos dois bancos. Mutantes: o teto sem a
normalização, a unicidade arrancada da regra e do editar, a própria contada no editar, a chave com acento e sem a
normalização, e o filtro antigo do nome -- vermelhos; na verificação, o teto contado no digitado (o da `RegraDoTexto` e o
`StringLength` do formulário), a transação arrancada do `ProdutoTagService`, a trava dos nomes arrancada e a leitura única (no
PostgreSQL), também vermelhos. **Um sobreviveu, equivalente:** a chave sem a minúscula -- a normalização já põe cada palavra em maiúscula inicial, e os dois lados da comparação passam por ela.

### D535 — A aplicação em lote do Gerenciar não conferia nada, e cada linha tinha um link morto — REGISTRADO em 19/10/2026 — CORRIGIDO em 19/10/2026

**Achado pelo desenho das Etiquetas** (o DT-F). O `gerenciarSalvar` passava os ids só por `is_numeric`: produto e pacote de
qualquer unidade recebiam a etiqueta; a etiqueta não era conferida (a inativa passava, e a inexistente batia na chave
estrangeira com 500); era um `flush` por item, sem transação, e um id inexistente no meio dava 500 com os anteriores já
gravados. O formulário tinha o token, mas a página não o desenhava, a ação não o lia e o JavaScript não o mandava. A lista
punha os 9.509 itens da cópia sob o título "Serviço", e o link de cada linha abria `produtos/editar`, que recusa serviço: toda
linha de serviço dava 404. E a caixa "selecionar todas" também se chama `tag_item`, sem valor: ia no lote como "on", e o
`is_numeric` a pulava.

**O conserto:** só o administrador (o D532); o token conferido ("O formulário expirou: recarregue a página e aplique de
novo."); os ids todos ids, sem repetição, ou o lote é recusado inteiro; e o `ProdutoTagService::aplicar`, numa transação,
relê a etiqueta com a trava (a desativada por outra aba no meio é recusada), confere que cada id é um serviço (`ser` ou
`lavavip`, `RegraDaEtiqueta::TIPOS_QUE_LEVAM`) que exista, de qualquer unidade, e grava todos ou nenhum. A lista mostra só os
serviços (`ProdutoRepository::listagemProdutosTagsGerenciar`), o link abre `servicos/editar`, a página desenha o token, e o
`mod-configuracoes.js` o manda e deixa a caixa "selecionar todas" de fora (o `?cache=` subiu para 167). Tirar a etiqueta
continua recusado (a P2 do desenho).

**Medido** no `franquiabv_dev`: o Gerenciar passa de 9.509 itens a 3.499 serviços (1.587 ativos); 4 itens que não são serviço
têm etiqueta, e ficam como estão.

**Provado** nos dois bancos (`EtiquetasPeloControllerTest`, cada recusa conferida no banco: o produto e o pacote no meio, o id
que não existe no meio, a caixa "todas", o id que não é número, a lista vazia, a etiqueta inativa, a que não existe, o texto no
lugar dela, sem token e com o token errado; e a metade que grava: o serviço, o Lava VIP e o serviço de outra unidade, o
repetido contando uma vez; e a etiqueta desativada entre ler e aplicar), `TelasDasEtiquetasDoErpTest` (só serviços, o link do
serviço, o token na página) e, no front, `etiquetasDaTelaAntiga.teste.ts` (o arquivo servido manda o token e não manda a caixa
"todas"; o piso do cache). Mutantes: a guarda de administrador, o token, a etiqueta inativa aceita, a trava sem reler, o item a
item, o id ruim pulado, os tipos da lista, o link ao produto, o token fora da página, as duas mudanças do JS e o cache de volta
a 166 -- vermelhos.

**Na verificação de 19/10/2026**, três coisas mudaram:

- **O token do lote não vence em 5 minutos.** O prazo de fábrica do `Csrf` é 300 s, e o Gerenciar lista até 1.500 serviços por
  página: quem demorava a marcar recebia "O formulário expirou", e recarregar desmarcava tudo. O token do
  `ProdutoTagGerenciarAplicar` passou a valer a sessão, com o sal próprio (`etiquetas_gerenciar_aplicar`), no molde do
  `AgendaRemarcar`.
- **A recusa fica na janela,** debaixo da etiqueta, até aplicar de novo, e não num `toastr` de 5 s (o molde da cota-parte, o
  D525); vai por `text`. O `?cache=` continua 167: o 167 ainda não foi publicado.
- **As travas têm caso:** outra conexão segura a etiqueta, ou o serviço, e o `aplicar` para na leitura com trava, e não no
  `UPDATE` adiante; o `definirEstado` também.

Provado: `EtiquetasPeloControllerTest` (seis minutos depois de abrir, o Aplicar aplica; as duas travas do lote e a do
desativar) e `etiquetasDaTelaAntiga.teste.ts` (a recusa na janela, os botões de volta, nenhum `toastr`, o texto que não vira
HTML, a janela que a limpa ao abrir e ao aplicar, e o lugar dela no arquivo servido). Mutantes: o prazo de fábrica, o prazo de
300 s com o sal próprio, as três travas arrancadas, a recusa de volta ao `toastr`, por `html`, sem limpar e sem o lugar na
página -- vermelhos.

### D536 — O formulário antigo de serviço não guardava a etiqueta inativa, e aceitava qualquer id — REGISTRADO em 19/10/2026 — CORRIGIDO em 19/10/2026

**Achado pelo desenho das Etiquetas** (o DT-G). O formulário de serviço oferecia só as etiquetas ativas e exigia uma: quem
editasse um serviço cuja etiqueta foi desativada era obrigado a trocá-la ao salvar qualquer campo -- a API já a mantinha (o
D241). E o `select` tinha o `disable_inarray_validator`: um POST forjado gravava a etiqueta inativa, e a que não existe dava 500
na chave estrangeira. Latente: nenhuma etiqueta está inativa na cópia.

**O conserto:** o editar oferece as ativas e a que o serviço já tem, com "(inativa)" no rótulo
(`RegraDaEtiqueta::opcoesDoServico`); e o formulário (`Form/Servico.php`) aceita só um id da lista que oferece, por um `InArray`
estrito -- e não o do `Select`, que compara com `==` e deixaria passar "90001700.0" (o molde do D-ACL-6, no D361). O novo
oferece e aceita só as ativas. A área de atuação tem o mesmo defeito, e não é do desenho: é o D537.

**Provado** nos dois bancos (`EtiquetasPeloControllerTest`): o serviço com a etiqueta inativa a vê escolhida, com o rótulo, e
salva sem trocá-la; a outra inativa não é oferecida; a etiqueta que não existe, a outra inativa e o número disfarçado são
recusados no campo, sem gravar, no editar; o novo recusa a inativa; a ativa grava. Mutantes: a atual fora das opções, o
`InArray` arrancado e o `InArray` não estrito -- vermelhos.

**Na verificação de 19/10/2026, a outra metade:** o `InArray` confere o valor já filtrado (o `StripTags` e o `StringTrim` do
`ServicoFilter`), e o `ServicosController` mandava ao `ServicoService` o POST cru -- "<b>90001700</b>" passava pela lista como
"90001700" e chegava ao banco como texto, 500 nos dois bancos (e "1<b>0" passaria como "10" e gravaria a etiqueta 1, quando
houver id de dois algarismos). O novo e o editar gravam a etiqueta e a área do `getData()`, como já faziam com o nome (o D344).
E o "Selecione" dizia duas frases no campo ("Campo obrigatório" e "Escolha uma das etiquetas da lista."): o `NotEmpty` da
etiqueta ganhou o `break_chain_on_failure`. Provado (`EtiquetasPeloControllerTest`): a etiqueta e a área com marcação gravam
as conferidas, no editar e no novo; o vazio diz uma frase só. Mutantes: a etiqueta e a área cruas, no novo e no editar, e o
vazio sem a quebra -- vermelhos.

### D537 — O formulário antigo de serviço não guarda a área de atuação inativa, e aceita qualquer id de área — REGISTRADO em 19/10/2026 — CORRIGIDO em 07/11/2026

**Achado na verificação da fatia ETIQUETAS-ERP** (o mesmo molde do D536, na área). O `select` da área no `Form/Servico.php`
tem o `disable_inarray_validator`, e o `ServicoFilter` não confere a área contra lista nenhuma: um POST forjado grava qualquer
área, inclusive a inativa, e a que não existe bate na chave estrangeira com 500. E o formulário oferece só as áreas ativas
(`AreaAtuacaoRepository::popularCombobox`) e exige uma: quem edita um serviço cuja área foi desativada é obrigado a trocá-la ao
salvar qualquer campo.

**Não é latente**, ao contrário do D536: no `franquiabv_dev` (19/10/2026, só contagens), 2 das 9 áreas estão inativas, e 551
serviços -- 193 deles ativos -- têm uma delas.

**Fica de fora da fatia** porque não é do desenho das Etiquetas, que só trata a etiqueta. Desde a verificação, a área gravada
já é a conferida (o `getData()`, no D536): a marcação não chega mais ao banco. **O conserto proposto** é o do D536: oferecer as
ativas e a que o serviço já tem, com "(inativa)" no rótulo, e aceitar só essa lista por um `InArray` estrito; ele não perde
dado, e vai com a fatia que tocar o formulário de serviço.

**Fechado na fatia 119 (CATALOGO-CADASTRO-SEM-POST-CRU, 07/11/2026), no molde do D536.** O editar oferece as áreas ativas e a que o
serviço já tem, com "(inativa)" no rótulo (`RegraDoCatalogo::opcoesDaArea`, a regra pura); o novo, só as ativas; e o
`Form/Servico.php` aceita só a lista que ofereceu, por um `InArray` estrito no filtro ("Escolha uma das áreas de atuação da
lista."), com o `NotEmpty` da área quebrando a corrente, como o da etiqueta. **O `disable_inarray_validator` fica** (a triagem
pedia tirá-lo): medido, sem ele o `Select` põe o `InArray` dele, não estrito, antes do `NotEmpty`, e o "Selecione" diz também
"The input was not found in the haystack" -- o mesmo motivo da etiqueta. **Provado** nos dois bancos
(`D537AreaDoServicoNaTelaAntigaTest`, 5 casos): o serviço com área inativa a vê escolhida, com o rótulo, e salva outro campo sem
trocá-la; a outra inativa não é oferecida; a área que não existe, a outra inativa, "3.0" e "03" são recusadas no campo com uma
frase só, sem gravar; o novo recusa a inativa e grava a ativa; o "Selecione" diz uma frase só. Mutantes, vermelhos nos dois
bancos: o `InArray` arrancado (500 na chave estrangeira), o `InArray` não estrito ("3.0" grava no MySQL, 500 no PostgreSQL), o
editar sem a área atual, o `NotEmpty` sem a quebra e a regra sem a atual. **Não visto:** o `select2` com o rótulo "(inativa)"
num navegador.

### D538 — A ficha antiga abria em "Selecione" as marcas de PDV e de dashboard de quem tem "Não", e recusava salvar — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**Achado no navegador, na verificação da fatia 18b da fila das telas que faltam (RESPOSTAS-240-241-242).** Cadastrada pela tela nova
com "Não" no PDV e no dashboard, a pessoa abria na ficha da tela antiga com as duas marcas em "Selecione", e o "Salvar" voltava com
"Campo obrigatório" nas duas -- a resposta 241 (b) diz que quem nasce pela tela nova salva na ficha sem preencher nada. A causa não
é da fatia: o `UsuarioDados::setFormData` põe o `toArray()` da entidade, que dá as duas marcas como booleano, e o falso não é opção
do `select` ("1" e "0"); as duas linhas que as punham como inteiro estão comentadas desde a importação (15/08/2026). Vale para
qualquer pessoa com "Não": na cópia (`franquiabv_dev`, só contagens), 111 dos 371 ativos têm "Não" no PDV e 122 no dashboard -- na
produção, a ficha deles só salva se alguém escolher o "Não" de novo.

**O conserto:** o `setFormData` põe as duas como inteiro, como as linhas comentadas já faziam. Caso:
`UsuarioCadastroESenhaPelaApiTest::testQuemTemNaoNoPdvENoDashboardSalvaNaFichaAntiga` (a pessoa nasce pela API com "Não" nas duas, a
ficha mostra "0" e salva, 302, com as marcas gravadas em 0), verde nos dois bancos. Mutante: as duas linhas arrancadas, vermelho ("a
ficha mostra o \"Não\" escolhido"). Visto no navegador depois do conserto: "Editado com sucesso."


### D539 — O portão das rotas sem login não via a família nova da /adm/api que ficou fora da lista de liberação — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**Achado pelo ensaio HTTP da fatia 19 da fila das telas que faltam (ETIQUETAS-TELA).** A `/adm/api/etiquetas` nasceu com as seis
rotas no `module.config.php`, e não na `module/ISConfiguracao/config/rotas-publicas.php`. Sem a linha, quem responde ao pedido sem
sessão é o listener do ERP, com a página de login, 200 em `text/html` -- o disfarce do D146 --, e não a ação, com o 401 JSON da
convenção. O caso 1 do `scripts/ensaiar-etiquetas-adm-api.php` ("sem sessão, a lista") deu 200 e ficou vermelho. Os casos em processo
não viam: eles despacham o controller direto, sem o listener. E o `scripts/conferir-rotas-sem-login.php` saiu 0: ele congela a
superfície anônima, e as seis rotas novas entraram caladas em "exigem sessão" (de 22 para 28).

**O conserto:** a família `adm-api-etiquetas` na lista de liberação, com "ACL por acao -- conferir-acl-da-api.php", e as seis rotas
no bloco congelado do portão. E o portão ganhou a quarta conferência: toda rota `adm-api-*` que o listener do ERP responde é
vermelha, com a família e o que fazer. Hoje são zero. Falsificado: com a linha da família arrancada e as seis rotas tiradas do bloco
congelado -- o estado da primeira rodada, que saía 0 --, o portão sai 1 e lista as seis. Mutante no ensaio: a linha da família
arrancada, e o caso 1 volta a 200.

**Desde 07/11/2026 (a fatia 115; o resto do D146), o mutante no ensaio não vale mais como está escrito.** O pedido sem sessão que
só aceita JSON -- o `Accept: application/json` dos `ensaiar-*-adm-api.php` -- recebe da guarda do ERP o 401 `nao_autenticado`
(`Permissions\RecusaSemSessao`), e não a página de login com 200. Com a linha da família arrancada, o caso "sem sessão" do ensaio
que só confere o status (o `ensaiar-notificacoes-adm-api.php`, por exemplo) passa verde; o que confere a mensagem fica vermelho
por acaso (a frase da guarda é outra). O detector é o portão: medido em 07/11/2026, com a linha `adm-api-notificacoes` arrancada,
o `conferir-rotas-sem-login.php` sai 1 e lista as doze rotas dela, com a família. A linha continua necessária: sem ela a ação nunca roda sem
sessão (o `retomar` da tela nova é uma dessas), e a recusa é a da guarda, não a da ação.

### D540 — A descrição do banner não tinha teto no servidor, aceitava emoji e era conferida só no vazio — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**O Dβ de `docs/f3-telas-restantes/tela-banners.md`, numerado no commit da fatia 21 da fila das telas que faltam
(BANNERS-SERVIDOR).** O teto de 100 era só o `maxLength` do HTML (`Form/AppBanner.php`); o `salvar` conferia apenas o vazio. Com o
MySQL estrito e a coluna `utf8mb3`, 101 caracteres ou um emoji eram erro do banco: 500. Lido no desenho, não exercitado antes.

**O conserto:** a `RegraDoBanner::conferirDescricao`, que é a `RegraDoTexto` com o teto de 100 -- o vazio, o teto em caracteres
depois do `trim`, o emoji, o `<` e o `>`, e o caractere de controle --, nos dois caminhos: a tela antiga (com a frase no aviso,
escapada) e a API nova (422 no campo `descricao`). A descrição é gravada aparada. A lista antiga já escapava a descrição desde o
D203 (`5ac901e`).

**Provado** nos dois bancos: as fronteiras na regra (`RegraDoBannerTest`), e 101 caracteres e o emoji recusados com a frase pela
tela antiga, sem gravar (`TelasDosBannersDoErpTest`). Mutantes: o teto em 101 e a descrição conferida só no vazio -- vermelhos.

### D541 — O tipo da imagem do banner era conferido pelo nome que o navegador mandava — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**O Dε do desenho dos Banners, numerado no commit da fatia 21.** O `salvar` só olhava a extensão do nome, em quatro grafias:
qualquer conteúdo com nome `.jpg` ia para `public/banners`, e dali para o telefone de cada cliente. Não era execução -- o nginx não
executa PHP nesse diretório, e o nome gravado é do servidor --, mas uma imagem quebrada, ou um arquivo que não é imagem, servido a
todos. E o teto era 20 MB, lido de um `filesize` que dava falso com o temporário vazio.

**O conserto:** a `RegraDoBanner::medirImagem`: o `error` do envio lido; até 5 MB, pelo peso do arquivo no disco (a decisão técnica
3 do desenho; desfaz-se numa constante); e JPEG PELO CONTEÚDO, por `getimagesize`, com largura e altura. A extensão do nome gravado
sai do conteúdo (`.jpeg`). Só JPEG, como os 10 banners de hoje: PNG ou WebP dependem de saber se o aplicativo instalado os mostra.

**Provado** nos dois bancos: o PNG e o texto com nome `.jpg` recusados, o JPEG com nome `.png` aceito, cada código do `error` com a
sua frase, e o teto pelo peso real (`RegraDoBannerTest`); o PNG com nome `.jpg` sem gravar linha nem arquivo, pela tela antiga e
pela API; e, pelo HTTP, o PNG, o texto e o arquivo de 6 MB recusados no campo `imagem` (`ensaiar-banners-adm-api.php`, casos 8 a
10). Mutantes: aceitar o PNG, medir sem o conteúdo, ignorar o `error`, tirar o teto -- vermelhos.

### D542 — Remover um banner que não existe dava 500, e o remover aceitava GET — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**O Dζ do desenho dos Banners, numerado no commit da fatia 21.** O `AppBannerService::delete` usava `getReference`, que não
consulta o banco, e o `getArquivo()` do proxy lançava `EntityNotFoundException`: 500. E a ação aceitava GET (o D199). Nenhuma tela
a chama.

**O conserto:** o `delete` usa `find()` com o id conferido (`RegraDoBanner::id`, até o maior do `int4`), e o id que não existe é
404; a ação recusa o GET como primeiro comando; e a linha sai antes do arquivo -- um arquivo sem linha é lixo, uma linha sem
arquivo é um banner quebrado no telefone de cada cliente. Apagar de vez continua sem botão: é a pergunta 1 do desenho, e a tela nova
só tira do aplicativo.

**Provado** nos dois bancos (`TelasDosBannersDoErpTest::testORemoverSoPorPostEO404`). Mutantes: o remover aceitando GET e o
`getReference` de volta -- vermelhos.

### D543 — A ordem dos banners no aplicativo não tinha desempate — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**O Dθ do desenho dos Banners, numerado no commit da fatia 21.** `AppBannerRepository::selecionarBannersAtivos` ordenava só pela
descrição: duas descrições iguais saíam na ordem que o banco escolhesse, e o MySQL e o PostgreSQL podem escolher diferente. Hoje as 5
ativas são distintas, e a ordem coincide nos dois.

**O conserto:** `ORDER BY ban.descricao, ban.id`. A ordem de hoje não muda, e o formato que o aplicativo recebe também não
(`tests/Contrato/snapshots/banners-selecionar.json`). A posição que a `/adm/api/banners` mostra sai desta mesma consulta; a lista da
API ordena por `status DESC, descricao, id`.

**Provado** nos dois bancos, com duas descrições iguais inseridas na ordem contrária à do id (`TelasDosBannersDoErpTest::
testAsDescricoesIguaisSaemPeloId`): o aplicativo, a lista antiga e a lista da API saem pelo id. Mutantes: o aplicativo sem o
desempate e a lista da API sem o desempate -- vermelhos no PostgreSQL, onde a linha nova vai para o fim da tabela; no MySQL a
chave primária agrupada já devolve pelo id, e o mutante sobrevive lá, como o débito previa.

### D544 — Nos banners, escreve quem tem o privilégio, sem ser administrador, e a fila pede que só o administrador escreva no que é da rede — REGISTRADO em 20/10/2026; a pergunta foi ao lote — DECIDIDO em 20/10/2026 (243 a)

**Achado na verificação da fatia 21 da fila das telas que faltam (BANNERS-SERVIDOR).** A seção 4.3 da fila
(`telas-fila-corrigida.md`, "A regra pura por recurso") diz que "só o administrador escreve" no que é da rede, também em banners
e versão, e o item 8 de `docs/f3-telas-restantes/telas-consolidado.md` recomenda a mesma regra nas dez telas da rede -- o
privilégio **e** ser administrador. O desenho dos banners decidiu o contrário para seguir ("O que decidi para seguir", em
`tela-banners.md`): **basta o privilégio**, como na tela antiga, porque `configuracoes/appConfiguracoes` é uma das funcionalidades
de rede, que só um administrador concede (`RegraDoPrivilegio::DE_REDE`, o D288). O desenho da versão do aplicativo decidiu o
mesmo, pela mesma funcionalidade. O consolidado registra as duas telas como as que destoam, e a escolha não foi ao dono.

**O que a fatia 21 fez:** seguiu o desenho. As escritas da `/adm/api/banners` (o cadastro, a troca da imagem, o `PUT`, a
desativação e a ativação) e as da tela antiga (o salvar, o tirar e o pôr no aplicativo, o remover) pedem o `escrever` de
`configuracoes/appConfiguracoes`, e não conferem se quem pede é administrador.

**Medido** no `franquiabv_dev` (20/10/2026, só contagens): a funcionalidade 43 ("Configurações app configuraçoes") tem **0**
concessões, e as outras de rede que o consolidado cita (23, 24, 50, 52 e 57) também. Hoje só os administradores escrevem banners,
pela regra do ACL que os deixa passar por tudo. Nada muda para ninguém agora: a diferença só aparece se um administrador conceder o
privilégio a quem não é.

**Pergunta ao dono** no lote (20/10/2026; na página como a 243): manter "basta o privilégio" nos banners e na versão, ou "o privilégio e ser
administrador", como nas outras telas da rede. Até a resposta, fica como o desenho: nenhuma linha se perde com nenhuma das duas.

**A tela nova (20/10/2026, a fatia 22 da fila, BANNERS-TELA) seguiu o mesmo:** o Console dá as escritas a quem tem
`configuracoes/appConfiguracoes/escrever` (`PRIVILEGIO_DE_EDITAR_BANNERS` em `frontend/src/telas/Console.tsx`), sem olhar se é
administrador, e não há a frase "só o administrador" das etiquetas e dos convênios. `Console.teste.tsx` ("os banners dentro do
Console") prende isso: com o `escrever` e sem ser administrador, cadastra e tira; administrador só com `ler`, não. Se a resposta for
"o privilégio e ser administrador", a troca é a do molde das etiquetas: o `&& papeis.administrador` no Console, o
`escreveSemSerAdministrador` com a frase do servidor, e a guarda nas sete ações da API e nas da tela antiga.

**A versão do aplicativo (20/10/2026, a fatia 23 da fila, VERSAO-DO-APLICATIVO) seguiu o mesmo:** o `PUT /adm/api/aplicativo/versao`
e as duas portas antigas (a versão e o Push) pedem o `escrever` de `configuracoes/appConfiguracoes`, sem olhar se é administrador, e o
Console dá os campos a quem o tem (`PRIVILEGIO_DE_PUBLICAR_A_VERSAO`); `Console.teste.tsx` ("a versão do aplicativo dentro do
Console") prende isso. A troca, se vier, é a mesma: a guarda no `publicarAction`, nas duas ações antigas e no Console.

**A resposta (20/10/2026, a 243 (a) da página, lida em 21/10/2026): "Basta o privilégio (como está)"** -- quem um
administrador escolher, com o privilégio, altera os banners e a versão; dar o privilégio já é a decisão de um administrador.
Nada muda no código: as fatias 21, 22 e 23 já fazem isso, e os casos citados acima o prendem. Os banners e a versão ficam como
as duas telas da rede em que o privilégio basta, por decisão do dono, e não por descuido.

### D545 — Sem escrita no diretório, gravar a versão ou o Push do aplicativo estourava num TypeError — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**O Dnovo-5 do desenho da Versão do aplicativo** (`docs/f3-telas-restantes/tela-versao-app.md`; não confundir com o Dnovo-5
dos Arquivos, que está no D361), numerado no commit da fatia 23 da fila das telas que faltam (VERSAO-DO-APLICATIVO). (lido)
O `fopen` que falhava devolvia `false`, e o `fwrite(false)` estourava no PHP 8, com 500 e a página de erro. No dev o arquivo é
777, e não se reproduz.

**O conserto:** o `RegistroDoAplicativo` confere cada passo -- o temporário que não abre, o `fwrite` curto, o `chmod`, o
`rename`, o histórico que não abre -- e lança `RegistroDoAplicativoFalhou` com a frase, sem deixar temporário: "Erro ao editar."
nas telas antigas, 500 com "A versão não pôde ser gravada; nada foi alterado." na nova. **Provado** nos dois bancos
(`RegistroDoAplicativoTest::testODiretorioSemEscritaLancaComFrase` e `::testOHistoricoQueNaoAbreDesisteAntesDeTrocar`,
`VersaoDoAplicativoPelaApiTest::testODiscoQueRecusaE500`); o contêiner roda como root, que ignora permissão, e o diretório que
não existe -- ou o caminho que passa por dentro de um arquivo -- faz as vezes. Mutantes: o histórico aberto só depois da troca (o arquivo mudava sem a linha) e o 500 da API trocado por 200 -- vermelhos.

### D546 — Quem tem só o ler da versão do aplicativo vê o item no menu e cai em 404 — REGISTRADO em 20/10/2026 — PARCIAL em 20/10/2026: a tela nova vê com ler — CORRIGIDO em 07/11/2026: a Versão e o Push do menu antigo, e o Push do menu novo, pedem o escrever

**O Dnovo-6 do desenho da Versão do aplicativo**, numerado no commit da fatia 23. (lido; hoje atinge 0 pessoas: nenhuma linha
concede a funcionalidade 43) O menu guarda o item com `ler`, e a ação antiga exige `escrever` (`CrudController::getAcesso`).

**O que a fatia fez:** a tela nova separa os dois -- vê com `configuracoes/appConfiguracoes/ler` e publica com o `escrever`;
quem só lê vê a publicada, sem campos, e a frase "Publicar pede o privilégio de escrever em Aplicativo." (`Console.teste.tsx`,
"a versão do aplicativo dentro do Console"; `VersaoDoAplicativoPelaApiTest::testVerPedeLerEPublicarPedeEscrever`). **O que
falta:** a tela antiga (`/mod-configuracoes/appconfiguracoes/versao`) continua pedindo o `escrever` para abrir, e o menu do ERP
antigo continua oferecendo o item com o `ler`. O menu novo não a abre mais.

#### CORRIGIDO em 07/11/2026 -- o menu pede o que a ação pede (a fatia 109, SESSAO-LE-O-BANCO)

O grupo "Aplicativo" do menu antigo (`layout.phtml`) continua abrindo com o `ler` (o `||` repetido saiu), porque os Banners abrem
com ele (`AppbannersController::indexAction`); a Versão e o Push ficaram dentro de um `if` do `escrever`, que é o que as duas ações
pedem (`AppconfiguracoesController::getAcesso()`). O irmão no menu novo: o Push, que abre a tela antiga em aba nova, passou a pedir
o `escrever` (`menu-do-erp.ts`); a Versão do menu novo abre a tela nova, que vê com o `ler`, e fica. Atinge 0 pessoas: nenhuma linha
concede a funcionalidade.

**Provado** no `SessaoLeOBancoTest::testAVersaoEOPushDoMenuAntigoPedemOEscrever` (quem só lê vê os Banners e não vê a Versão nem o
Push; com o escrever, vê os dois), nos dois bancos, e no `menu-do-erp.teste.ts` (a guarda do Push). Mutantes A1 (a Versão e o Push
de volta ao ler no layout) e A2 (o Push de volta ao ler no menu novo), vermelhos e devolvidos.

### D547 — Salvar a versão do aplicativo depois de cinco minutos falhava calado na tela antiga — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**O Dnovo-7 do desenho da Versão do aplicativo**, numerado no commit da fatia 23. (lido) O token do formulário vencia em 300 s
(o padrão do `Laminas\Validator\Csrf`), e a view imprimia o elemento `security` sem o erro dele: o salvar não gravava e não
dizia nada.

**O conserto:** o token sem prazo e com sal próprio (`module/ISConfiguracao/src/Form/AppVersao.php`, o molde de `AppBanner.php`),
com a frase "O formulário veio de outra página ou de outra sessão. Recarregue a tela e salve de novo.", e o
`formElementErrors` do `security` em `versao.phtml`. **Provado** nos dois bancos (`AppConfiguracoesPelosControllersTest::
testOTokenDaVersaoNaoVenceEmCincoMinutos`, com o controle do token de fábrica vencendo nos mesmos seis minutos, e
`::testOTokenQueNaoServeApareceNaTela`, com a view renderizada). Mutantes: o token de volta aos 300 s e a view sem o erro do `security` -- vermelhos.

### D548 — Ninguém sabia quem mudou a versão do aplicativo, nem quando — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**O Dnovo-8 do desenho da Versão do aplicativo**, numerado no commit da fatia 23. (lido) O serviço antigo não registrava nada,
e não há tabela de log.

**O conserto:** um histórico só de acréscimo, `data/aplicativo/historico.jsonl` (fora de `public/`, que o nginx serve, e no
`.gitignore`): uma linha por gravação, com `em`, `campo` (`versao` ou `push`), `porta` (`tela-nova`, `erp-versao`, `erp-push`),
`gravadoPor` e `emNomeDe` do `autorDaEscrita()` -- na personificação, quem entrou, e a pessoa assumida à parte --, o `antes` e o
`depois`. A gravação que não muda nada não deixa linha. A tela nova mostra a última mudança da versão ("Alterada por ... em
20/10/2026 às 14:03", com "em nome de"), só quando ela é a versão de agora; sem registro, diz que as mudanças de antes não
deixaram rastro. O autor não vai no `app.json`, que é público. **Provado** nos dois bancos (`RegistroDoAplicativoTest::
testCadaGravacaoDeixaUmaLinhaNoHistorico`, os casos do autor em `AppConfiguracoesPelosControllersTest` e em
`VersaoDoAplicativoPelaApiTest`). Mutantes: o histórico sem a linha, o `gravadoPor` com o `emNomeDe` (no gravador e na resposta da API), a porta antiga sem o autor, e a linha que não é a versão de agora valendo como quem publicou -- vermelhos. No corte, o D36 importa o histórico junto com a versão.

### D549 — Testar a versão do aplicativo no servidor de teste trava o deploy seguinte: o public/app.json é versionado — REGISTRADO em 20/10/2026 — CORRIGIDO em 08/11/2026: o app.json da tela não trava o deploy, e o alvo que o muda é recusado antes do freio

**O Dnovo-9 do desenho da Versão do aplicativo**, numerado no commit da fatia 23, que o deixou de fora. (lido) `git ls-files`
lista `public/app.json`, e as conferências de `scripts/deploy-teste.sh` que recusam "mudança versionada neste clone"
(`git status --porcelain --untracked-files=no`, nas linhas que dizem "o servidor não é lugar de editar" e "há mudança versionada
neste clone.") recusam também o arquivo que a tela grava. O risco NÃO é novo: a tela antiga já gravava no mesmo arquivo antes
da fatia 23, que só abriu uma segunda porta para ele. Publicar uma versão no servidor de teste, pela tela antiga ou pela nova,
trava o deploy seguinte até alguém devolver o arquivo.

**Por que ficou de fora:** o deploy de teste não é desta rodada, e o pathspec não tem como ter teste aqui (o desenho já dizia).
E o `atualizar` tira o site do ar ANTES do `merge --ff-only`: com a mudança local ignorada pela conferência, um commit que
tocasse o arquivo pararia o merge depois de o site sair do ar -- o conserto pede olhar essa ordem junto (a verificação da
fatia 23).
**O conserto proposto** (o desenho, Dnovo-9): as duas conferências passam a ignorar exatamente esse caminho, com o pathspec
`':!public/app.json'`; o `checkout` preserva a mudança local enquanto nenhum commit tocar o arquivo, e recusa alto se tocar. As
alternativas descartadas estão no desenho. Até lá: não publique versão no servidor de teste, ou devolva o arquivo antes do deploy.

#### CORRIGIDO em 08/11/2026 -- a fatia 129 da fila dos débitos, DEPLOY-TESTE-E-PUBLICAR (o Dnovo-9)

Conferido antes: as duas conferências seguiam com `git status --porcelain --untracked-files=no` cru. **E havia uma terceira**,
que o registro não contava: a do composer, no `subir_e_ajustar`, que o `_aplicar` do `atualizar` roda com o site já fora --
com o arquivo mudado, ela pararia ali, culpando o composer (o conserto no ajudante que não cobre o irmão). O que mudou em
`scripts/deploy-teste.sh`:

- `mudancas_versionadas` (o `git status --porcelain --untracked-files=no -- . ':(exclude)public/app.json'`) nas três
  conferências; nenhum `git status --porcelain` fica fora dela.
- `recusar_se_o_alvo_toca_o_app_json`, chamada no `atualizar` (depois do `fetch`) e no `voltar`, **antes** do `tirar_do_ar`:
  com o arquivo mudado aqui e o commit alvo também o mudando, recusa e diz como guardar a versão de agora e devolver o
  arquivo (`git checkout -- public/app.json`). Sem mudança local, ou com o alvo sem tocar o arquivo, segue: o `merge
  --ff-only` e o `checkout` levam a mudança junto.

**Provado num repositório git temporário** (`scripts/ensaiar-escolhas-do-deploy-teste.sh`, a parte D, no `conferir-tudo.sh` e
no CI): o arquivo mudado, e também no índice, não é acusado; um arquivo de `module/` mudado é; o alvo que não toca o arquivo passa e
o `merge --ff-only` dele leva a versão da tela; o que toca é recusado, e o merge dele, sem a recusa, para no arquivo (o
defeito reproduzido); sem mudança local, o mesmo alvo passa. E a ordem, lida no roteiro sem os comentários: a recusa antes
do primeiro `tirar_do_ar` no `cmd_atualizar` e no `cmd_voltar`. Mutantes: o pathspec arrancado, a chamada do `cmd_atualizar`
arrancada, e o `git status` cru de volta no `subir_e_ajustar` -- os três vermelhos.

### D550 — A versão do aplicativo podia ser baixada sem aviso — REGISTRADO em 20/10/2026 — PARCIAL em 20/10/2026: a tela nova pergunta

**O Dnovo-10 do desenho da Versão do aplicativo**, numerado no commit da fatia 23. (lido) Nada comparava o valor novo com o
publicado: baixar de 2.0.8 para 2.0.7, ou trocar o major (20 em vez de 2), publicava calado.

**O que a fatia fez:** o `PUT /adm/api/aplicativo/versao` responde o 409 que pergunta (`detalhes {publicada, pedida, motivo}`)
quando a mudança é uma rebaixa ou troca o major (`RegraDaVersao::PEDE_CONFIRMACAO`), conferido dentro da trava, e só grava com
`confirmar: true`; a tela mostra a frase do servidor no âmbar do `.aviso`, e o botão passa a "Publicar 2.0.7 mesmo assim".
**O que falta:** a tela antiga não pergunta -- um formulário antigo não tem como reenviar com `confirmar` (o desenho, seção 1).
E o que pede confirmação é a P1 do desenho ("o que o aplicativo faz com o número"), que a fila deixou de fora: a resposta muda a
constante dos dois lados. **Provado** nos dois bancos (`VersaoDoAplicativoPelaApiTest::testO409QuePerguntaEOReenvioComConfirmar`,
`::testASubidaDoMinorEDoPatchNaoPergunta`) e no front (`VersaoDoAplicativoLigado.teste.tsx`, "a confirmação"). Mutantes:
a lista sem a rebaixa, a lista sem o major, o `confirmar` ignorado, o `confirmar` sempre verdadeiro, a pergunta antes da tela velha, e, no front, a pergunta tratada como tela velha, o `confirmar` de saída e o "mesmo assim" sem ele -- vermelhos.
**Na verificação da fatia 23:** a pergunta passou a dizer o efeito no aplicativo dos clientes -- o número chega a todos na
próxima abertura; na rebaixa, que ainda não se sabe o que o aplicativo faz com um número mais baixo; no major, o pior caso da
P1 como hipótese ("se ele usa o número para exigir atualização, nenhum cliente com a versão 2 abre o aplicativo até
atualizar") -- e entrou num `role="alert"`: quem clicou já estava no botão, e o foco que a tela leva até ele não anunciava nada.
O texto da regra e o da seção 6 do desenho deixaram de chamar a lista de "a opção mais cautelosa": a (a) do desenho confirma
também a subida do minor. **Provado** em `RegraDaVersaoTest::testAComparacaoEOQuePedeConfirmacao` e no front
(`VersaoDoAplicativoLigado.teste.tsx`, "o Enter num campo publica, e a pergunta leva o foco ao botão"). O segundo clique do
duplo que confirmava a pergunta é o D551.

### D551 — O duplo clique ou o Enter segurado confirmavam a rebaixa da versão do aplicativo sem a pergunta ser lida — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**Achado na verificação da fatia 23 da fila das telas que faltam (VERSAO-DO-APLICATIVO).** (lido no código) Na tela nova da
versão, o botão em ouro é o mesmo nó do primeiro clique e do "Publicar 2.0.7 mesmo assim", à direita, e o rótulo cresce para a
esquerda: o ponto do clique continua dentro dele. Publicada a 2.0.8, quem digitava 2.0.7 e dava duplo clique mandava o `PUT`
sem confirmar no primeiro clique; com o 409 que pergunta chegando antes do segundo (a janela depende do servidor, e não foi
medida no navegador), o segundo caía no "mesmo assim" e mandava o `confirmar: true` sem ninguém ler a pergunta. O Enter segurado
num campo fazia o mesmo: o envio implícito se repetia, e o foco que a pergunta leva ao botão recebia a repetição. A regra da
casa (`frontend/src/telas/perguntaNoBotao.ts`) diz que o clique repetido e a tecla segurada não confirmam; o botão não usava
nem o `cliqueRepetido` nem o `semRepeticao`.

**O conserto** (`frontend/src/telas/VersaoDoAplicativo.tsx`): o clique com `detail > 1` no botão não envia
(`cliqueRepetido`), e o Enter segurado não age -- a guarda fica no formulário, e vale para o campo (o envio implícito) e para o
botão (o `keydown` dele sobe até o formulário); as outras teclas seguradas -- apagar, as setas -- valem. O Espaço segurado não
repete: o botão age no `keyup`. **Provado** no front (`VersaoDoAplicativoLigado.teste.tsx`, "o segundo clique do duplo e o
Enter segurado não confirmam a pergunta", com o clique de quem a leu confirmando). Mutantes: o botão sem o `cliqueRepetido` e
o formulário sem a guarda do Enter -- vermelhos; o `semRepeticao` no próprio botão sobreviveu (a guarda do formulário já o
cobre) e saiu.

### D552 — Durante a releitura do 409, o botão da versão do aplicativo seguia ligado, e digitar apagava o aviso de quem publicou — REGISTRADO em 20/10/2026 — CORRIGIDO em 20/10/2026

**Achado na verificação da fatia 23.** (lido no código) Na tela nova da versão, o 409 da tela velha relê a publicada e diz
"Relendo a publicada…"; nessa janela o botão seguia ligado, com o rótulo e a linha "Vai publicar 2.1.0; hoje é 2.0.8" medindo
contra a publicada de ANTES da resposta, e o clique mandava o `antes` velho, levava outro 409 e outra releitura. Não gravava
errado. E quem digitava durante a releitura desfazia o estado: a releitura que chegava não dizia mais quem tinha publicado, e
o cartão trocava de número sem anúncio.

**O conserto:** o botão fica desligado durante a releitura, o envio não sai (`VersaoDoAplicativo.tsx` e
`VersaoDoAplicativoLigado.tsx`), e digitar não desfaz a releitura no ar -- o anúncio "agora é 2.0.9, por …" chega, com o
digitado. **Provado** no front (`VersaoDoAplicativoLigado.teste.tsx`, "durante a releitura do 409 o botão fica desligado";
`VersaoDoAplicativoLigado.contrato.teste.tsx`, "com a releitura do 409 no ar, publicar não pede nada"). Mutantes: o botão sem
o `relendo`, o envio sem a guarda da releitura e o digitar que desfaz a releitura -- vermelhos.

### D553 — A coluna "Nº Vendas" e a planilha dos sorteios contavam cupons, e não vendas ativas — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

Achado pelo desenho de Sorteios (`docs/f3-telas-restantes/tela-sorteios.md`, DS-2), registrado e consertado na fatia 25 da
fila das telas que faltam (SORTEIOS-SORTEAR, a F2 do desenho).

- **O que acontecia:** a coluna "Nº Vendas" da lista antiga era um `COUNT(ven.id)` sobre os cupons
  (`SorteioRepository::listagemIndex`), e a planilha do "Exportar" listava cada cupom sem olhar a venda
  (`selecionarNumerosParticipantes`). No #4 a coluna dizia 3.502, para 3.473 vendas ativas (C2); as 4 vendas com cupom
  em dobro tinham chance dupla no sorteio feito fora do sistema; e as 41 linhas de venda cancelada (27 no #4) eram
  vendas que o "informar o ganhador" recusa depois.
- **O conserto:** uma linha por venda ativa nos dois lugares, a conta do sorteio interno. A planilha sai de
  `SorteioRepository::participantes` (a venda ativa uma vez, com o dia do primeiro cupom e o nome, o e-mail e o telefone
  do cliente, nas colunas de sempre -- a P6 (a), "como hoje, corrigida"), pela mesma `Xls\Sorteio::conteudo` na tela
  antiga e na `GET /adm/api/sorteios/:id/participantes`; o texto do cliente vai como texto. A coluna da lista antiga,
  agora "Vendas ativas", lê a `numerosDaListagem` da tela nova. `selecionarNumerosParticipantes` saiu.
- **Provado** nos dois bancos: `SorteiosSortearPelaApiTest::testAPlanilhaTemUmaLinhaPorVendaAtiva` (o XLSX lido de volta:
  três vendas, sem a cancelada e sem o cupom em dobro repetido, com o dia do primeiro cupom) e
  `TelasDosSorteiosDoErpTest` (a planilha da tela antiga e a coluna com 2, e não os 4 cupons); pelo HTTP, o caso 18 do
  `ensaiar-sorteios-adm-api.php`. Mutantes: a venda cancelada na planilha, a linha por cupom e a coluna voltando a
  contar cupons -- vermelhos.

### D554 — O sortear da tela antiga sem `id` dava 500, e o código da venda em texto também — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

Achado pelo desenho de Sorteios (DS-5), consertado na fatia 25.

- **O que acontecia:** `finalizarInterno` sem `id` no POST fazia `find(null)` (`SorteiosController.php:201` no HEAD de
  então), e o Doctrine lançava: 500. O texto no código da venda e do sorteio ia cru à consulta -- no PostgreSQL, erro de
  tipo (a forma do D279), não medido.
- **O conserto:** o id do sorteio e o da venda passam pela `RegraDoSorteio::id` (só algarismos, de 1 ao maior `int4`) nas
  duas telas; o que não serve é recusa com frase -- "Sorteio inválido." e "Informe o código da venda sorteada: só
  algarismos." na antiga, 404 e 422 no campo `venda` na nova.
- **Provado:** `TelasDosSorteiosDoErpTest::testOPedidoSemIdERecusaENaoErro` (sem `id`, vazio, `12abc`, `0`) e
  `SorteiosSortearPelaApiTest::testAVendaQueNaoServeE422` (o texto, o número quebrado, o arranjo, a falta), nos dois
  bancos; pelo HTTP, o caso 9 do ensaio. Mutantes: o `find` cru de volta e o `(int) '12abc'` aceito -- vermelhos.

### D555 — Sortear, informar o ganhador e cancelar liam o estado e gravavam sem trava — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

Achado pelo desenho de Sorteios (DS-6), consertado na fatia 25.

- **O que acontecia:** as duas finalizações (`SorteioService::finalizarInterno` e `::finalizarTerceiros`) e o cancelar da
  tela antiga liam o estado da memória e gravavam sem transação nem trava. Dois pedidos ao mesmo tempo -- o duplo clique,
  duas abas, e agora a tela nova ao lado da antiga -- podiam marcar dois ganhadores, ou cancelar um sorteio sorteado. Nenhum
  caso medido: cada sorteio feito tem 1 ganhador (C2).
- **O conserto:** `SorteioService::sortear`, `::informarGanhador` e `::cancelar` abrem a transação e travam a linha do
  sorteio com `PESSIMISTIC_WRITE` (o `refresh`, a primeira leitura), e leem o estado e os cupons DEPOIS da trava -- o molde
  do D232 e do `AppBannerService::definirEstado`. As duas telas passam por eles.
- **Provado** nos dois bancos (`SorteioSobTravaTest`, no molde do `DevedorSobTravaTest`): a primeira leitura das três é o
  `SELECT ... FOR UPDATE` do sorteio; outra conexão prende a linha, e as três esperam; o sorteio que outra aba já sorteou
  é recusado sem um segundo ganhador; a venda cancelada depois de o pedido carregar o sorteio não é sorteada; e, fora da
  transação do teste, o sortear grava de verdade e solta a trava. `SorteiosSortearPelaApiTest::testSortearDeNovoE409EOGanhadorEUmSo`.
  Mutantes: a trava retirada, o estado lido da memória e o `commit` retirado -- vermelhos.
- **O que não prova:** a corrida com dois pedidos HTTP ao mesmo tempo; a trava presa por outra conexão é o que se mede no
  lugar dela, como no D228 e no D232.
- **A verificação da fatia (21/10/2026) achou dois escritores da mesma linha fora da trava,** e os dois passaram por ela: o
  GET da lista antiga, que passa o vencido a `aguardandoSorteio` (o D559), e a finalização da venda, que dá o cupom (o
  D560). O caso `testAsVendasSaoLidasDepoisDaTrava` mudou de nome para `testAsVendasSaoLidasDoBancoENaoDaMemoria`: ele
  cancela a venda antes de o sortear começar, e não mede a ordem -- quem a mede é
  `testAPrimeiraLeituraDeCadaEscritaEATravaDoSorteio`.

### D556 — "Todas as vendas participantes estão canceladas", dito também do sorteio sem venda nenhuma — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

Achado pelo desenho de Sorteios (DS-7), consertado na fatia 25. Quando não havia cupom de venda ativa, o sortear da tela
antiga dizia "Nenhuma venda elegivel: todas as vendas participantes estao canceladas" -- também quando não havia venda
nenhuma. Agora o motivo sai da `RegraDoSorteio`: `semCupom` ("Este sorteio não tem nenhum cupom: não há quem sortear.") e
`soVendaCancelada` ("Todas as vendas com cupom neste sorteio estão canceladas: não há quem sortear."), as duas telas com a
mesma frase, a nova com o motivo em `detalhes`. **Provado:** `SorteiosSortearPelaApiTest::testSemQuemSortearE422ComAFraseCerta`,
`TelasDosSorteiosDoErpTest::testAFraseDeNaoHaQuemSortear`, `RegraDoSorteioTest::testCadaMotivoTemASuaFrase` e
`BilheteDoSorteioTest`, nos dois bancos; o caso 8 do ensaio. Mutante: a frase de sempre de volta -- vermelho.

### D557 — O "Exportar" dos sorteios aparecia a quem só lê, e a ação o recusava com 404 — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

Achado pelo desenho de Sorteios (DS-11), consertado na fatia 25. O botão da lista antiga aparecia a todos
(`index.phtml:115-119` no HEAD de então), e a ação exige escrever (`exportarXlsAction`, `getAcesso()`): quem só lia recebia
404. Hoje ninguém tem só leitura (C6). Agora o botão mora no bloco de quem escreve, e a planilha da tela nova pede escrever e
ser administrador (ela leva o contato de todos os participantes). **Provado:**
`TelasDosSorteiosDoErpTest::testAListaContaAsVendasAtivasEOExportarESoDeQuemEscreve` (a lista renderizada: três "Exportar"
a quem escreve, nenhum a quem só lê) e o 403 da planilha a quem escreve sem ser administrador
(`SorteiosSortearPelaApiTest::testSoOAdministradorQueEscreveEscreve`; o caso 4 do ensaio). Mutante: o "Exportar" fora do
bloco de quem escreve -- vermelho.

### D558 — Sortear e informar o ganhador punham um aviso automático na fila, e o dono pediu "nada automático" — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

**A resposta 217 (c) do dono (15/10/2026):** "Nada automático: o contato é feito à mão, pelo que a tela mostra; o aviso deixa
de sair." Até aqui o `finalizarInternoAction` e o `finalizarTerceirosAction` da tela antiga chamavam o
`notificarganhador`, que gravava na `push_app` um WhatsApp da unidade da venda ganhadora ou um push do aplicativo ("Olá, você
foi o ganhador do Sorteio ...! Entraremos em contato em breve!"), e a rotina o mandava e apagava (o DS-10, o D302 visto do
sorteio: nenhum rastro de que os dois ganhadores foram avisados).

- **O conserto:** as duas chamadas saíram, e o método também (era o único chamador; derivado pelo `git grep`). Nada vai à
  fila de mensagens. A mensagem de sucesso da tela antiga diz "O sistema não avisa o ganhador: o contato é feito à mão." --
  com o nome do cliente escapado (era HTML com o nome cru, o achado 8 do desenho, da família do D203). A tela nova mostra o
  nome, o telefone e o e-mail do ganhador a quem sorteou ou informou (só quem escreve e é administrador chega lá), logo
  depois da escrita, e diz isso em texto.
- **Provado:** a `push_app` contada antes e depois, sem linha nova, no sortear e no informar das duas telas
  (`SorteiosSortearPelaApiTest`, `TelasDosSorteiosDoErpTest`, nos dois bancos), e em cada caso do `ensaiar-sorteios-adm-api.php`
  pelo HTTP; a mensagem escapada (`TelasDosSorteiosDoErpTest::testOSortearDaTelaAntigaNaoAvisaEEscapaONome`). Mutantes: uma
  linha na `push_app` ao marcar o ganhador e o nome cru -- vermelhos.
- **Se o WhatsApp voltar** (a P4 do desenho, a opção (b)), o aviso volta por decisão nova, e não por este código.
- **A verificação da fatia (21/10/2026):** a mensagem da tela antiga dizia que o contato é feito à mão sem mostrar o contato
  -- era abrir a venda para achá-lo. Agora ela traz o telefone e o e-mail do ganhador, escapados como o nome, e diz "não
  informado" quando falta; quem a lê é quem acabou de sortear, com o privilégio de escrever (a decisão 4 do desenho).
  **Provado:** `TelasDosSorteiosDoErpTest::testAMensagemDaTelaAntigaMostraOContatoDoGanhador`, nos dois bancos. Mutantes: o
  contato fora da mensagem e o e-mail sem escapar -- vermelhos.

### D559 — Abrir a lista antiga de sorteios podia desfazer um sorteio recém-feito: o sorteado voltava a "falta sortear", com o ganhador — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

Achado pela verificação da fatia 25 (SORTEIOS-SORTEAR), consertado nela. Antes da fatia, o sortear só aceitava
`aguardandoSorteio` e o GET da lista antiga só mexia no `ativo`: os dois nunca disputavam a mesma linha. Com o D334, o
sortear passou a aceitar o `ativo` vencido -- e a disputa ficou possível.

- **O que acontecia:** a lista antiga (`SorteiosController::indexAction` chama `SorteioService::validarVencimento`) lia os
  `ativo` vencidos SEM TRAVA e gravava `aguardandoSorteio` em cada um, por um `UPDATE ... WHERE id = ?`, sem condição no
  estado. Com o sortear da tela nova no meio -- ele trava a linha, grava o ganhador e `finalizado`, e solta --, o `UPDATE` da
  lista esperava a trava e gravava por cima, no MySQL e no PostgreSQL em READ COMMITTED. O sorteado ficava `aguardandoSorteio`
  com o ganhador: a tela nova oferecia sortear de novo, o aplicativo mostrava "Aguardando Sorteio", e o segundo sortear
  marcava outro cupom sem desmarcar o primeiro -- dois clientes ouvindo do aplicativo que ganharam. O mesmo com o cancelado.
  Nenhum caso medido (lido no código; a corrida não foi rodada).
- **O conserto:** `SorteioService::passarAAguardando`, sob a mesma trava das escritas (`sobTrava`, a linha relida com
  `PESSIMISTIC_WRITE`): só o que AINDA é `ativo` e venceu troca. `validarVencimento` passa cada vencido por ele.
- **Provado** nos dois bancos (`SorteioSobTravaTest`): `testOVencimentoDaListaAntigaEsperaATravaDoSorteio` (outra conexão
  prende a linha, e a troca espera, com `FOR UPDATE`) e `testOVencimentoDaListaAntigaNaoDesfazOSorteado` (a memória diz
  `ativo`, o banco diz `finalizado` com o ganhador: a troca não grava; o controle troca o `ativo` de verdade). Mutantes: a
  releitura condicional arrancada e a trava arrancada -- vermelhos.
- **O que não prova:** a corrida com dois pedidos HTTP; a trava presa por outra conexão é o que se mede no lugar dela.

**Anotado em 07/11/2026 (a fatia 112, o D334; achado pela verificação dela): o escritor saiu, e com ele o conserto e as
provas acima.** O GET da lista antiga não grava mais: o `SorteiosController::indexAction` não chama o `validarVencimento`, e
o `SorteioService::validarVencimento`, o `SorteioService::passarAAguardando` e o `SorteioRepository::validarVencimento`
foram apagados (ninguém mais os chamava). Os dois casos citados no **Provado** --
`SorteioSobTravaTest::testOVencimentoDaListaAntigaEsperaATravaDoSorteio` e `::testOVencimentoDaListaAntigaNaoDesfazOSorteado`
-- saíram com eles: não há mais a troca que esperava a trava. A disputa deste débito não tem mais um dos lados; o `ativo`
vencido continua `ativo` no banco até ser sorteado ou cancelado, e a situação "Aguardando Sorteio" é calculada pela
`RegraDoSorteio`. O que prova agora que a lista não escreve é o
`TelasDosSorteiosDoErpTest::testNenhumGetDaListaAntigaGrava` (o retrato da tabela antes e depois de abrir a lista sem filtro
e com cada filtro, nos dois bancos), com o mutante da escrita de volta no GET vermelho -- o corpo do D334, parágrafo de
07/11/2026. A marca CORRIGIDO continua.

### D560 — O cancelar sob trava não esperava a venda que dá o cupom: o sorteio cancelado podia ficar com cupom de venda ativa — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

Achado pela verificação da fatia 25 (SORTEIOS-SORTEAR), consertado nela. Anterior à fatia (o cancelar de antes nem tinha
trava), mas o D555 apresentava a trava como garantia do estado e dos cupons, e ela não cobria este escritor.

- **O que acontecia:** a finalização da venda (`VendaPagamentoService::salvarSorteios`) lia os sorteios no ar sem trava
  (`selecionarSorteiosAtivos`) e gravava o cupom depois. Entre as duas coisas cabia o cancelar inteiro: ele travava a linha,
  contava zero cupom de venda ativa, gravava `cancelado` e soltava -- e o cupom entrava num sorteio cancelado, o que o D299
  diz que não existe. A chave estrangeira só confere que o sorteio existe. A janela é curta; o cancelado some do aplicativo.
- **O conserto:** o cupom entra por `SorteioService::darCupom`, sob a trava de LEITURA da linha do sorteio (`PESSIMISTIC_READ`,
  o `FOR SHARE`), relida: o cancelar no meio faz a releitura esperar o `commit` dele, e o cupom não entra; o cupom primeiro
  faz o cancelar (`FOR UPDATE`) esperar o `commit` da venda, e contá-lo. A trava é compartilhada: duas vendas ao mesmo tempo
  não se esperam. O cupom só entra no que a `RegraDoSorteio` diz `recebendo` -- a mesma janela da consulta da lista.
- **Provado** nos dois bancos (`SorteioSobTravaTest`): `testOCupomDaVendaEsperaATravaDoSorteio` e
  `testAFinalizacaoDaVendaDaOCupomSobATrava` (o `salvarSorteios` de verdade, por reflexão: espera a trava, com `FOR SHARE`)
  e `testOCupomNaoEntraNoSorteioCanceladoDepoisDaLista` (a memória diz no ar, o banco diz `cancelado`: nenhum cupom; o
  controle dá o cupom). Mutantes: a trava de leitura tirada, a releitura tirada e o `salvarSorteios` de volta ao `INSERT`
  cru -- vermelhos.

### D561 — Os sorteios só davam um cupom por venda, e o dono pediu o cupom por valor e por serviço — REGISTRADO em 21/10/2026 — PARCIAL em 21/10/2026: as três regras pela tela nova; a produção ganha as colunas no passo 0 e a chave no passo 3; quatro perguntas no lote — PREPARADO em 21/10/2026 (244 a 248)

**O pedido do dono, nesta fatia (21/10/2026, a fatia 26 da fila das telas que faltam, SORTEIOS-CADASTRO):** "implemente a
opção de gerar um cupom por valor, por serviço", com as duas escolhas dele -- **"o cadastro escolhe a regra"** e **"trocar a
regra do banco"**. Até aqui toda venda finalizada de cliente do programa de fidelidade ganhava UM cupom em cada sorteio no
ar, e o banco não tinha como guardar dois cupons da mesma venda (o par era o defeito do D370).

- **A regra, escolhida no cadastro** (`sorteios.regra_cupom` e `sorteios.valor_cupom`): `venda`, um cupom por venda -- a de
  sempre, o padrão, e a de TODOS os sorteios que existem (os quatro reais e as fixtures ficam nela: o contrato 74 de 74 e os
  retratos `*sorteios*` iguais); `valor`, um cupom a cada R$ X do valor da venda (o X no cadastro, de R$ 1,00 a R$ 99.999,99);
  `servico`, um cupom por serviço lançado na venda, pela quantidade (os produtos não contam). A regra e o valor não mudam
  depois do primeiro cupom, como as datas (a decisão 5 do desenho, estendida). Pura: `RegraDoSorteio::cuponsDaVenda`.
- **O valor da venda é a soma dos itens dela, depois dos descontos** (`SorteioService::valorDosItens`): o `vendas.valor_total`
  sem o que o "Quitar" somou a ele -- com o total, a dívida quitada dava cupom duas vezes (o D564, da verificação). O total é
  o que o caixa soma no dia (`VendaRepository::totalVendaPorAnoMesDiaUnidade`) e o consumo do cliente
  (`::selecionarConsumoPorCliente`), e é incremental sobre os itens: sem o "Quitar", a soma é ele. Não o `valor_recebido`, que
  leva o troco e fica abaixo do total no fiado. A sessão de pacote já pago entra com 0 (o item dela vale 0 na venda; o pacote
  contou na venda em que foi comprado). A forma de pagamento não entra: a permuta e o crédito do programa de fidelidade
  contam, e a pontuação os desconta -- a escolha foi ao lote.
- **O serviço é o item do tipo `ser`, pela quantidade inteira:** também a sessão de pacote já pago (é um serviço lançado na
  venda). O Lava VIP (`lavavip`), que o catálogo de Serviços do ERP lista, não conta até o dono dizer (foi ao lote; 7 na
  cópia). O item tirado da venda é apagado da tabela -- não há item cancelado. Na cópia, nenhum dos 3.001.613 itens de
  serviço tem quantidade quebrada, e 7.651 são de pacote (0,25%).
- **O banco grava um NÚMERO por cupom** (`sorteios_vendas.numero`, 1, 2, 3...; o 1 em todo cupom de antes), e a chave única
  do passo 3 é (`sorteio`, `venda`, `numero`) -- o D370. A finalização não repete os cupons: `SorteioService::darCupom` dá só
  os números que faltam, sob a trava de leitura do sorteio (o D560), e lê a regra da linha relida sob ela.
- **O que muda de ponta a ponta:** o sorteio interno sorteia entre os CUPONS de vendas ativas, cada cupom uma chance
  (`SorteioRepository::cuponsParaSortear`; marca o cupom sorteado, `SorteioVendaRepository::selecionarCupom`); os números do
  sorteio aberto ganham os "cupons que contam" (`cuponsQueContam`: cada (venda, número) de venda ativa uma vez; as três
  partes -- os que contam, o em dobro e o de venda cancelada -- somam os cupons), a lista e a tabela por unidade os cupons
  delas; a planilha ganha a coluna "Cupons" (as chances de cada venda); e o aplicativo recebe uma linha por cupom
  (`SorteioVendaRepository::selecionarCuponsPorSorteioCliente`, o código da venda repetido por cupom: a soma é a dos cupons; na
  regra de sempre, a mesma lista de antes). "Como funciona" e a pergunta do sortear dizem a regra.
- **As colunas FICAM FORA DO MAPEAMENTO** (o D473, `ISBase\Doctrine\ColunaNoBanco`): a base do teste do BVIP não as tem até
  a próxima cópia exportada. Sem elas, todo sorteio é "um cupom por venda", todo cupom é o 1, e a `POST`/`PUT` recusa a
  outra regra no campo, com a frase de que o banco ainda não as tem (`RegraDoSorteio::SEM_A_REGRA_NO_BANCO`).
- **As portas:** a `POST /adm/api/sorteios` e a `PUT /adm/api/sorteios/:id` (`regra` e `valorPorCupom`, este como decimal
  com ponto, a regra 7 da convenção), só a quem escreve e é administrador; a tela nova (Marketing > Sorteios: o "cadastrar
  sorteio" e o "alterar", com a regra em rádios e o valor com a vírgula de quem digita). A tela antiga cadastra só pela regra
  de sempre, mostra a regra na lista e no editar, sem a alterar, e o novo e o editar dela pedem também ser administrador.
- **O esquema:** `scripts/migracao-sorteios-regra-do-cupom.sql` (as três colunas, `ALGORITHM=INSTANT`, idempotente), no laço
  do passo 0 de `docs/procedimento-de-corte.md` e no do ensaio geral -- só acrescenta (a resposta 83 (a)); nas duas cópias
  locais em 21/10/2026. A chave, no passo 3 (o D370).
- **Provado:** a caracterização da regra pura (`RegraDoSorteioTest`, e as frases e os casos que a tela repete, pelo
  `frontend/src/telas/formularioDoSorteio.casos.json`), da API (`SorteiosCadastroPelaApiTest`), dos cupons pela regra
  (`CuponsPelaRegraDoSorteioTest`: as três regras, o idempotente, o débito quitado que não conta, o sorteio entre os cupons,
  os números, a planilha -- também o XLSX baixado, com 3 e 1 na coluna "Cupons" -- e o aplicativo) e da tela antiga
  (`TelasDosSorteiosDoErpTest`), nos dois bancos; o BANCO SEM AS COLUNAS, fingido pela `ColunaNoBanco` (o molde de
  `CupomSemAsColunasTest`): `CuponsPelaRegraDoSorteioTest::testSemAsColunasTudoEUmCupomPorVenda` e
  `SorteiosCadastroPelaApiTest::testSemAsColunasSoARegraDeSempreSeCadastra`; o ensaio HTTP (`ensaiar-sorteios-adm-api.php`,
  os casos 19 a 33: o cadastro pelas três regras, conferidas nas colunas, e as recusas); o ensaio do passo 3; e o front
  (`formularioDoSorteio.teste.ts`, `situacaoDoSorteio.teste.ts`, `SorteiosLigado.teste.tsx`).
- **Por que PARCIAL:** a produção ganha as colunas no passo 0 e a chave no passo 3; e quatro escolhas são do dono e foram ao
  lote -- a venda abaixo de R$ X (segue a leitura literal, "a cada R$ X": nenhum cupom), a sessão de pacote já pago na regra
  por serviço (segue contando: é um serviço lançado na venda, pela quantidade), a permuta e o crédito do programa de
  fidelidade no valor (seguem contando, como a soma dos itens) e o Lava VIP na regra por serviço (segue de fora: o que não
  inventa cupom).

**As respostas (21/10/2026, a 244 a 248 da página, todas "como está"):** a venda abaixo de R$ X não ganha cupom (244 a); a sessão
de pacote conta como serviço (245 a); o sorteio pode começar antes do cadastro, com o aviso (246 c, no D371); o valor dos itens
conta inteiro, seja qual for a forma de pagamento (247 a); e o Lava VIP não conta como serviço (248 a). Nada muda no código: a
fatia 26 já fazia isso, e os casos de `CuponsPelaRegraDoSorteioTest` e de `SorteiosCadastroPelaApiTest` prendem cada regra. O que
resta é da janela do corte: as colunas no passo 0 e a chave (sorteio, venda, numero) no passo 3.

### D562 — O cadastro de sorteio gravava o POST cru: o ganhador, o dia do sorteio, o tipo, o estado e o id que viessem, e 101 caracteres ou um emoji eram 500 — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

O DS-4 do desenho de Sorteios (`docs/f3-telas-restantes/tela-sorteios.md`), que ganha número no commit da fatia que o
conserta (a nota do `README.md` da pasta). A fatia 26 (SORTEIOS-CADASTRO) o consertou.

- **O que acontecia:** o `novoAction` da tela antiga gravava `$request->getPost()`, e não o que o formulário filtrou; o
  construtor da entidade enchia TODA chave recebida: o `ganhador` em texto derrubava o `flush`, o `data_sorteio` virava
  "agora", o `tipo_sorteio` aceitava qualquer texto, o `status` escondido do formulário gravava o estado que viesse, e o `id`
  do campo oculto entrava. O filtro só tinha `NotEmpty`: o teto de 100 do nome só existia no navegador (101 caracteres eram
  500 no MySQL estrito), o emoji era 500, o 31/02 virava 03/03 calado (o `createFromFormat` transborda) e o fim antes do
  início passava.
- **O conserto:** `RegraDoSorteio::conferir` -- a mesma regra na `POST`/`PUT` da API e nos dois formulários antigos (o novo
  e o editar, pelo `doFormularioParaOCorpo`): o nome pela regra do texto da fatia 3 (de 1 a 100, sem emoji, sem `<` nem `>`,
  numa linha), a descrição de até 1.000 caracteres, as datas que existem (`checkdate`), o fim no dia do início ou depois e de
  hoje em diante (o sorteio não nasce encerrado), e a chave que a API não escreve (`status`, `ganhador`, `dataSorteio`,
  `tipoSorteio`, `usuarioSorteio`, `id`) é 422 com o nome dela (o molde do D267). O serviço grava só os campos escolhidos
  (`SorteioService::criar`, `RegraDoSorteio::paraOServico`), e o formulário antigo perdeu o `id` oculto e o `status`.
- **Provado** nos dois bancos: `RegraDoSorteioTest` (cada recusa), `SorteiosCadastroPelaApiTest::testAsRecusasDoCadastro` e
  `TelasDosSorteiosDoErpTest::testONovoGravaSoOsCamposEscolhidosEARegraDeSempre` (o POST com `status`, `ganhador`,
  `data_sorteio`, `tipo_sorteio`, `id` e a regra não grava nenhum deles, e o sorteio real #4 fica como estava) e
  `::testONovoRecusaPelaRegra` (101 caracteres e o 31/02 voltam ao formulário, sem gravar).

### D563 — `SorteioService::update` hidratava com uma classe que não existe, e ninguém o chamava — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

O DS-12 do desenho de Sorteios, que ganha número no commit da fatia que o conserta. O método usava `Hydrator\ClassMethods`
do subnamespace do próprio serviço, que não existe (o módulo não tem a pasta `Service/Hydrator`): seria erro fatal no
primeiro chamador. Saiu na fatia 26 (SORTEIOS-CADASTRO), no lugar dele nasceu `SorteioService::alterar` -- sob a trava do
sorteio, pela `RegraDoSorteio`, com o `alteradoEm` que a leitura devolveu (o 409 `mudou`) --, chamado pela `PUT
/adm/api/sorteios/:id` e pelo `editarAction` da tela antiga (o D105). Provado em `SorteiosCadastroPelaApiTest`
(`testSemCupomTudoSeAltera`, `testComCupomSoONomeEADescricao`, `testOSorteadoEOCanceladoSao409`,
`testOVazioOMudouEONadaMudou`) e em `TelasDosSorteiosDoErpTest` (`testOEditarMostraARegraEAlteraSoONome`,
`testOEditarDeQuemTemCupomNaoMudaAsDatas`), nos dois bancos.

### D564 — Na regra por valor, o débito quitado na venda dava cupom de novo: a dívida contava duas vezes — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

Achado na verificação da fatia 26 (SORTEIOS-CADASTRO), antes de a regra por valor (o D561) chegar a qualquer sorteio de
verdade. A primeira versão contava os cupons pelo `vendas.valor_total`, e o "Quitar" (`VendasPagamentosController::pagarDebitosAction`
e o `POST /adm/api/comandas/:id/debitos`) soma ao total o que o cliente devia -- a marca do D380
(`RegraDosDebitosNaVenda::excedente`) é exatamente o total acima da soma dos itens.

- **O que acontecia:** num sorteio de "1 cupom a cada R$ 50,00", a venda A, de R$ 100,00 em serviço, saía fiada e ganhava 2
  cupons ao ser finalizada; no dia seguinte, a venda B, de R$ 20,00, quitava os R$ 100,00 -- o total virava R$ 120,00, e B
  ganhava mais 2. Quatro cupons por R$ 120,00 de consumo; se A fosse de antes do sorteio, a quitação dava cupom a um consumo
  de fora da janela. O desenho e o comentário da regra diziam "o total dos itens depois dos descontos", e o código não fazia
  isso.
- **O conserto:** `SorteioService::valorDosItens` -- a soma dos itens da venda, cada um ao centavo (o total é incremental
  sobre os itens arredondados, o D450), que é o total sem o "Quitar". Sem o "Quitar", é o próprio total: os números de toda
  venda sem débito ficam iguais.
- **Provado** por `CuponsPelaRegraDoSorteioTest::testODebitoQuitadoNaoDaCupomDeNovo`, nos dois bancos (a venda de R$ 120,00 em
  itens com R$ 100,00 de débito somado ganha 2 cupons, e não 4). Mutante: o `valorDosItens` de volta ao `getValorTotal()` --
  vermelho.

### D565 — O editar antigo do sorteio gravava sem conferir o que leu: o formulário velho desfazia calado o que a tela nova gravou — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

Achado na verificação da fatia 26 (SORTEIOS-CADASTRO). O `editarAction` da tela antiga (que nasceu nesta fatia, o D105)
chamava `SorteioService::alterar` sem o instante da leitura, e o serviço pula a conferência sem ele.

- **O que acontecia:** o administrador A abria o editar (nome "X", fim D2); o B, na tela nova, gravava o nome "Y" e o fim D3;
  A mudava só a descrição e salvava. O `soOQueMudou` comparava o POST de A (nome "X", fim D2) com o sorteio de agora (nome
  "Y", fim D3), achava que A mudara o nome e o fim, e gravava "X" e D2 -- sem 409, e as mudanças de B sumiam caladas. O lado
  contrário já estava protegido: o editar antigo grava a `data_alteracao`, e o PUT seguinte de B leva 409 `mudou`.
- **O conserto:** o formulário leva o instante da leitura num campo oculto (`alterado_em`, o ATOM da `data_alteracao`, vazio
  no sorteio nunca alterado), e o editar o passa ao serviço, que confere sob a trava. A recusa (`mudou`) reabre o editar com
  o sorteio de agora e a frase "O sorteio mudou desde que a tela o leu: confira e tente de novo." -- reexibir o POST faria o
  segundo "Salvar" gravar por cima. O formulário sem o campo (a página aberta antes desta versão) não confere, como antes.
- **Provado** por `TelasDosSorteiosDoErpTest::testOFormularioVelhoNaoDesfazOQueOutraTelaGravou`, nos dois bancos. Mutantes: o
  editar sem passar o instante, e a recusa sem reabrir -- vermelhos.

### D566 — O editar antigo do sorteio recusava calado: mudar as datas de quem tem cupom voltava ao formulário sem frase — REGISTRADO em 21/10/2026 — CORRIGIDO em 21/10/2026

**Achado no navegador** (21/10/2026, a verificação da fatia 26 da fila das telas que faltam, SORTEIOS-CADASTRO, contra o banco de
contrato): no `/mod-marketing/sorteios/editar/<id>` de um sorteio com cupom, mudar o fim e salvar voltava ao formulário com as
datas de antes e **nenhuma** mensagem -- a recusa estava certa (nada gravado), mas ninguém a via. A causa: o `editarAction` punha a
frase da recusa do serviço (`datasComCupom` e as outras que não são `mudou`) com `flashMessenger()->addErrorMessage()`, que vai ao
espaço de nomes de erro, e a view só mostra o `partials/notificacoes`, alimentado pelo `getCurrentMessages()` do espaço padrão, no
formato `['danger' => frase]`. A chave da regra sem campo no formulário (`marcarNoFormulario`, no novo e no editar) caía no mesmo
lugar. O caso `testOEditarDeQuemTemCupomNaoMudaAsDatas` conferia o espaço de erro do flashMessenger, e não a página: ficava verde
com a tela muda.

**O conserto:** as recusas vão à variável `mensagens` da view, no formato do partial e escapadas (`htmlspecialchars`), sem passar
pelo flashMessenger -- então não reaparecem na página seguinte. **Provado:** o caso passou a desenhar a view e a procurar a frase e
o `alert-danger` (nos dois bancos); o mutante que devolve o `addErrorMessage` fica vermelho; e, no navegador, a frase "Este sorteio
já tem cupons: as datas não mudam, só o nome e a descrição." aparece na página e não volta na lista.

### D567 — O "Inativo" do formulário de Arquivos não desativava, e a tela dizia "Editado com sucesso" — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

O Dnovo-2 de `docs/f3-telas-restantes/tela-arquivos.md` (seção 2). O `ArquivoService::salvar` só gravava o estado
quando `!empty($data['status'])`, nos três caminhos (o link, a edição e o envio de vários) -- e `'0'` é vazio em PHP.
Escolher "Inativo" no formulário e salvar não mudava nada, e a resposta era sucesso. O construtor já liga o arquivo,
então o cadastro nascia ativo de qualquer jeito.

**O conserto** (a fatia 27 da fila das telas que faltam, ARQUIVOS-SERVIDOR): o estado do formulário passa pela
`RegraDoArquivo::estadoDoFormulario` -- `'1'` ativa, `'0'` desativa, e o resto não mexe --, nos três caminhos. O botão
de ativar e desativar da lista continua o do D199 (por POST, com o estado pedido). **Provado** nos dois bancos:
`ArquivosTravessiaTest::testOEstadoDoFormularioEGravado` (o "0" desativa, o "1" reativa, o "x" não mexe) e
`RegraDoArquivoTest::testOEstadoDoFormulario`; o mutante que devolve o `!empty` fica vermelho.

**Na verificação da fatia (22/10/2026):** aquele caso só passava pela troca, e o `setStatus` tem três lugares. `ArquivosTravessiaTest::testOInativoDoFormularioValeNoLinkENoCadastroNovo` prova os outros -- o link que se edita, o
link novo e o cadastro novo de arquivo nascem ou ficam inativos com o "0" --, e os mutantes do `!empty` no bloco do
link e no lote ficam vermelhos.

### D568 — O salvar de Arquivos dizia sucesso com arquivo perdido, e a troca apagava o antigo antes de gravar — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

O Dnovo-4 de `tela-arquivos.md`. Dois defeitos no `ArquivoService::salvar`:

- **O envio de vários pulava o que não se guardava** e respondia `true`, com um `flush` por linha e sem transação: a
  metade de um lote ficava gravada, e a tela dizia "Inserido com sucesso". O único lote da história (29/08/2024, 101
  arquivos em 7 envios) passou por aqui.
- **A troca do arquivo apagava o antigo ANTES do `flush`:** se a gravação falhasse, a linha ficava apontando para um
  arquivo que não existia mais.

**O conserto:** o lote é tudo ou nada -- primeiro todos os arquivos, e depois todas as linhas num `flush` só (o
Doctrine o faz numa transação). O arquivo que não se guarda para o lote antes de qualquer linha, apaga os já guardados,
e a frase diz qual falhou ("O arquivo "x.pdf" não pôde ser guardado, e nenhum dos 3 foi cadastrado."); a gravação que
falha apaga os guardados e tira as linhas novas do gerenciador (senão o próximo `flush` de quem chama as gravaria). A troca segue a ordem da regra 8 de `docs/convencao-api.md`: guarda o novo, grava a linha,
e só então apaga o antigo; a gravação que falha apaga o novo e deixa o antigo. A recusa do serviço chega à tela antiga
com a frase (`ArquivoService::motivoDaRecusa`), e não vazia. **O nome igual em todos os arquivos do lote continua**:
é a pergunta 3 do desenho, e a fatia 29. **Provado** nos dois bancos (`ArquivosTravessiaTest`): o lote que falha na
segunda guarda (nenhuma linha, o primeiro apagado, a frase com o nome), o lote cuja gravação falha (os dois guardados
apagados), a troca que só apaga o antigo com a linha já no novo (lido no banco na hora da remoção) e a troca que não
grava (o novo sai, a linha continua no antigo). Os mutantes da ordem, da compensação e do pulo calado ficam vermelhos.

**Na verificação da fatia (22/10/2026):** a edição sem arquivo novo não pode tirar o guardado -- a remoção do antigo só
vale com um novo guardado (`$guardado !== null`). `ArquivosTravessiaTest::testAEdicaoSemArquivoNovoNaoTiraOGuardado`
muda só a descrição e confere que nada sai do armazenamento; o mutante sem a condição fica vermelho.

### D569 — As categorias de arquivo eram lidas em todo pedido, até na API do aplicativo e na nova, e copiadas para a sessão sem leitor — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

O Dnovo-6 de `tela-arquivos.md`. O `ISConfiguracao\Module` tinha um ouvinte no DESPACHO de todo pedido que lia as
categorias ativas e, de cada uma, as subcategorias (`ArquivoCategoriaRepository::selecionarMenu`): 1 consulta mais 1
por categoria -- 3 na cópia de desenvolvimento, com as duas categorias ativas --, inclusive na `/api` do aplicativo e na `/adm/api` da tela nova,
que não desenham layout. As subcategorias, ninguém lia (o `layout.phtml` só imprime o nome e o link). E o login -- pela
senha e pelo cookie -- copiava as mesmas categorias para a sessão (`usuario.categoriasArquivos`, na `SessaoAcl`), onde
ninguém as lia; na personificação a cópia ficava nula.

**O conserto:** o ouvinte saiu para `Service\CategoriasDoMenu`, no `render` (prioridade 100, antes da estratégia que
desenha), e só consulta quando a resposta vai ser o layout: o resultado não é uma `Response` (a `/adm/api`, as
entregas de arquivo, os redirecionamentos) e a raiz não é terminal (o `JsonModel` da `/api` e dos `salvar` nasce
terminal; o 404 e os parciais também). Uma consulta, só com o id e o nome. O login não lê nem copia mais as categorias (o segundo argumento do
`setAcl` saiu; quem ainda o passa é ignorado). O `layout.phtml` aceita a variável ausente.

**Medido**, pelo SQL do `Com_select` do MySQL, descontada a consulta do próprio contador, na cópia de desenvolvimento, cinco pedidos de
cada: `GET /adm/api/sessao` sem sessão (o 401), **3 consultas antes e 0 depois**; `GET /api/paises/selecionar` (o
aplicativo), **4 antes e 1 depois** -- a dela. O "antes" é o `Module.php` e o `ArquivoCategoriaRepository.php` do
`HEAD`, postos no lugar só para a medida e devolvidos (o sha1 conferido).
**Provado** nos dois bancos (`TelasDosArquivosDoErpTest`): nenhuma consulta com a `Response`, o `JsonModel` e a raiz
terminal; uma no layout, sem as subcategorias e só com as ativas; o ouvinte está no `render` e não no despacho; e a
sessão montada pelo `setAcl` não tem mais as categorias. **E o layout do ERP antigo continua com o menu:** o
`ensaiar-arquivos-adm-api.php`, com sessão, abre a biblioteca antiga e acha a categoria ativa das fixtures no menu, com
o link, e não a inativa.

### D570 — O formulário de Arquivos cortava o nome e a descrição em 100, e a coluna guarda 255 — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

O Dnovo-7 de `tela-arquivos.md`. `Form/Arquivo.php` dava `maxLength` 100 ao nome e à descrição, contra colunas de 255
(`mysql.sql`, `postgres/schema.sql`): uma descrição, repetida nas 15 linhas da subcategoria "Ferramentas - Comunicação
Assertiva", termina no meio da frase (a consulta C3 do desenho). E o mapeamento da subcategoria dizia 255, contra a
coluna de 100. O servidor não conferia nada: o texto acima da coluna era erro do banco.

**O conserto:** os tetos são os da coluna, num lugar só (`RegraDoArquivo::TETO_DO_NOME`, `TETO_DA_DESCRICAO` e
`TETO_DA_SUBCATEGORIA`): 255, 255 e 100, no formulário e no `salvar` (`RegraDoArquivo::acimaDoTeto`, contado em
caracteres, com a frase de cada campo); o mapeamento da subcategoria passou a 100 (o nulo dela continua o D65). A
descrição cortada de hoje não volta sozinha: o texto inteiro não existe no banco. **Provado** nos dois bancos:
`ArquivosTravessiaTest::testOTetoDeCadaCampoEODaColuna` (a descrição de 200 grava inteira, o nome de 256 e a
subcategoria de 101 são recusados com a frase, e o formulário diz 255, 255 e 100) e `RegraDoArquivoTest`; o
`verificar-schema.php` sai 0 com o mapeamento novo.

### D571 — O "abrir" e o "baixar" do ERP davam 404 ao administrador no arquivo inativo e no de categoria inativa, que a lista do cadastro mostra — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

Achado pela verificação da fatia 27 da fila das telas que faltam (ARQUIVOS-SERVIDOR). A lista do cadastro antigo
(`arquivos/index.phtml`, a coluna dos botões) mostra o "Abrir" e o "Baixar" em toda linha que não é link, sem olhar a
situação, e o `abrirAction` e o `downloadAction` do `ArquivosUsuariosController` respondiam 404 a todos, administrador
incluído: o botão que não abre (o "abrir o inativo" da tabela antiga x nova de `tela-arquivos.md`, e um dos "menores"
da seção 2). A fatia 27 piorou um caso: o arquivo ativo de categoria inativa, que a lista do cadastro continua
mostrando, abria pelo ERP antes e passou a dar 404 também ao administrador. Na produção, sem efeito hoje: nenhum arquivo
inativo, e as duas categorias ativas.

**O conserto:** a regra de quem vê o que quem lê não vê mora num lugar só, `RegraDoArquivo::veOInativo` (escreve em
`configuracoes/arquivosCadastrar` E é administrador, a decisão 1 do desenho) e `RegraDoArquivo::abre`; as duas portas a
usam -- o "abrir"/"baixar" do ERP (`ArquivosUsuariosController::arquivoQueAbre`) e a `/adm/api/arquivos/:id/conteudo`.
Quem só lê continua sem ver o inativo e o de categoria inativa (404). **Provado** nos dois bancos:
`ArquivoApiTest::testOAdministradorQueCadastraAbreOInativoPeloErp` (o administrador que cadastra abre e baixa os dois;
quem escreve sem ser administrador e o administrador sem escrever recebem 404); os mutantes que voltam ao `aQuemLe` na
porta antiga e que trocam o "e" da regra por "ou" ficam vermelhos.

### D572 — Arquivos passava o id e a categoria crus ao banco: no PostgreSQL, o número fora do int4 e o texto davam 500 — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

Achado pela verificação da fatia 27 (ARQUIVOS-SERVIDOR). A rota genérica do ERP aceita qualquer sequência de
algarismos (`constraints id => '\d+'`), e o `abrir`, o `download` (`ArquivosUsuariosController`), o `editar` e o
`ativarRemover` (`ArquivosController`) passavam o id cru ao `find`; o `salvar` passava o `id` e a `categoria` do POST ao
`find` e ao `getReference`. No PostgreSQL o parâmetro é `int4`: `99999999999` é "out of range for type integer"
(medido no `franquiabv_dev` com `PREPARE`/`EXECUTE`), e `categoria=abc`, 22P02 -- 500, e não 404 ou a frase. A
`/adm/api/arquivos/:id/conteudo` já passava o id pela `RegraDoArquivo::id` e respondia 404: as duas portas recusavam
diferente o que não existe. O mesmo efeito do D542 e dos grupos (`ensaiar-recusas-adm-api.php`, o caso 176).

**O conserto:** o id da rota passa pela `RegraDoArquivo::id` nos quatro (`arquivoQueAbre` e `arquivoDaRota`), com 404
para o que não serve; no `salvar`, o id e a categoria que não são número de linha são recusados com a frase ("- O
arquivo que se edita não existe.", "- Categoria"). **Provado** no PostgreSQL, onde o defeito existia:
`ArquivoApiTest::testONumeroQueAColunaNaoGuardaE404NasDuasPortas` (a API, o abrir e o baixar) e
`ArquivosTravessiaTest::testOIdQueAColunaNaoGuardaNoCadastroAntigo` (o editar, o ativar/desativar e o salvar); os
mutantes que devolvem o id cru ficam vermelhos no PostgreSQL. Pelo HTTP: o caso 187 do `ensaiar-recusas-adm-api.php` e
três casos do `ensaiar-arquivos-adm-api.php` (a API, o abrir e o baixar do ERP com `99999999999`, 404).

### D573 — O salvar do cadastro antigo de Arquivos dizia "Inserido com sucesso." também na edição — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

**Achado no navegador** (22/10/2026, a verificação da fatia 27 da fila das telas que faltam, ARQUIVOS-SERVIDOR, contra o banco de
contrato): editar um arquivo em `/mod-configuracoes/arquivos/editar/<id>`, escolher "Inativo" e salvar gravava certo (o D567), e a
página voltava com "Inserido com sucesso.". O `salvarAction` é o mesmo para o novo e para o editar e pedia sempre a frase de
`insert`. Já era assim antes da fatia. **O conserto:** a frase de `edit` quando o pedido traz o `id`. **Provado** por
`ArquivosTravessiaTest::testOEditarDizEditadoEONovoInserido`, nos dois bancos; o mutante que volta a pedir sempre o `insert` fica
vermelho.

### D574 — O "abrir" da biblioteca nova, com a sessão caída, mostra o JSON do 401 na aba nova — REGISTRADO em 22/10/2026 — CORRIGIDO em 08/11/2026

**Achado ao montar a tela** (22/10/2026, a fatia 28 da fila das telas que faltam, ARQUIVOS-BIBLIOTECA). O "abrir" do PDF é um
link para `/adm/api/arquivos/:id/conteudo?disposicao=inline` em aba nova (`Arquivos.tsx`, `api/arquivos.ts`): é o que deixa o
navegador mostrar o PDF com a sessão do ERP. Quando a sessão caiu, ou o arquivo sumiu do disco, a aba nova recebe o corpo de
erro da API -- `{"erro":{"codigo":"nao_autenticado",...}}` -- em texto cru, sem frase nem caminho para entrar de novo. O
"baixar" não tem o defeito: é um pedido da tela, e a recusa aparece nela com o nome do arquivo. **Raro e sem perda:** a tela que
abre o link já precisou da sessão para listar, e a aba de erro se fecha. **Conserto possível, sem decisão do dono:** a entrega
responder o pedido de navegação (`Accept: text/html`, e não JSON) com uma página curta -- "a sessão do ERP caiu: entre de novo"
-- mantendo o JSON para quem pede JSON; ou o "abrir" passar a buscar o arquivo pela tela e abrir o `blob:`, que perde o nome do
arquivo no visualizador. Entra com a fatia 29, que mexe na mesma API.

**CORRIGIDO em 08/11/2026 (a fatia 123, ARQUIVOS-NA-TELA-NOVA).** A primeira saída do registro: a `GET .../:id/conteudo`
(`Adm\ArquivosController::conteudoAction`) responde ao pedido de NAVEGAÇÃO -- o `Accept` que lista `text/html`, sem o
`X-Requested-With` do jQuery (`RegraDoArquivo::pedeAPagina`, a mesma leitura do `RecusaSemSessao::pedeJson`, do outro lado) --
com uma página curta (`RegraDoArquivo::paginaDaRecusa`), com o MESMO código, `text/html`, `nosniff` e `no-store`: a do 401 diz
"A sessão do ERP caiu", "entre de novo no ERP e abra o arquivo outra vez pela biblioteca", e leva a `/autenticacao/entrar` (o
mesmo link do `Erro.tsx`); a do 403, do 404 (o que sumiu do disco, o que não existe) e do 422 traz a frase da API, escapada, e
"feche esta aba e volte à biblioteca". O `Content-Type` do JSON sai: o `addHeaderLine` deixaria os dois. Quem pede JSON (o
"baixar" da tela, `Accept: application/octet-stream, application/json`; os `scripts/ensaiar-*-adm-api.php`, `application/json`),
o `$.ajax` (com o `X-Requested-With`), o `*/*` do `curl` e o pedido sem `Accept` seguem com o corpo de erro de sempre; o sucesso
não muda. O `blob:` não entrou: perderia o nome do arquivo no visualizador. Os casos: `ArquivoApiTest::testARecusaAoPedidoDeNavegacaoEAPaginaCurta`
(o 401 sem sessão, o 404 do que sumiu e o 403, em página; os quatro pedidos de JSON; o sucesso com o tipo do arquivo) e
`RegraDoArquivoTest::testAPaginaDaRecusaSoAoPedidoDeNavegacao` (a regra e o escape), nos dois bancos. Mutantes, devolvidos pelo
sha1, vermelhos nos dois bancos: a ação sem a página (M1), a regra sem olhar o `X-Requested-With` (M2), o tipo do JSON que fica
(M3), a frase sem escape (M4). **A volta da personificação também (a correção da verificação da fatia 123):** o 401
`personificacao_encerrada` é dado antes do controller (`PersonificacaoComOAcessoDeAgora::respostaDaVolta`), e num "abrir" em aba
nova depois de o acesso da pessoa assumida mudar a aba mostrava o mesmo JSON cru. Agora a rota do "abrir"
(`PersonificacaoComOAcessoDeAgora::ROTA_DO_CONTEUDO`, `adm-api-arquivos/um/conteudo/ler`) pedida pela navegação recebe o mesmo
401 em página (`RegraDoArquivo::paginaDaRecusa`, com a frase da volta, `nosniff` e `no-store`); a volta é a mesma; o jQuery, o
só-JSON e a navegação noutra rota da `/adm/api` seguem com o envelope. O caso:
`D514PersonificacaoSegueOAcessoTest::testOAbrirEmAbaNovaRecebeAPaginaDaVolta` (quatro pedidos). E a página do 422 da disposição
e do 404 do que não existe ganhou caso no `testARecusaAoPedidoDeNavegacaoEAPaginaCurta` (antes só o 401, o 404 do que sumiu e o
403 eram pedidos). Mutantes, devolvidos pelo sha1, vermelhos nos dois bancos: a volta sem a página (P1), a página em toda rota da
`/adm/api` (P2), a volta sem olhar o `X-Requested-With` (P3), a página sem o 422 (A1), a frase do 422 lida do campo (A2), a página
sem o 404 do que não existe (A3). Não visto num navegador. CORRIGIDO.

### D575 — O tipo Link fica sem caminho na biblioteca nova — REGISTRADO em 22/10/2026 — DECIDIDO em 22/10/2026

**Achado pela verificação da fatia 28** (22/10/2026, ARQUIVOS-BIBLIOTECA). O cadastro antigo, que fica no ar até a fatia 29,
ainda oferece "Links" (`Form/Arquivo.php:53-66`), e a biblioteca antiga o abre pelo "abrir" (`arquivos-usuarios/index.phtml:91-97`;
só http(s), pelo D296). A API da fatia 27 manda o link com `conteudo: null` e sem o endereço (`ArquivoJson.php:44`), e a
biblioteca nova o mostra com o tipo "Link" e a frase "link: não abre nesta tela" (antes da verificação, "sem conteúdo"), sem
"abrir" nem "baixar". **Hoje são 0 links** (a consulta C1 de `tela-arquivos.md`), e o desenho marca o tipo como "sai (pergunta
5)"; mas a pergunta 5 ainda não foi feita ao dono, e um link cadastrado amanhã na tela antiga fica sem caminho para quem lê na
nova. **Sem perda de dado:** a linha e o endereço continuam no banco. **O que fecha:** a pergunta 5 do desenho, com a fatia 29 --
(A) o tipo sai do cadastro, e este débito se fecha sem código; (B) fica, e a API passa a mandar o endereço http(s), que a tela
abre em aba nova.

**Em 22/10/2026** (a fatia 29, ARQUIVOS-CADASTRO): o cadastro novo não oferece o tipo Link -- o `POST` e o `PUT` da
`/adm/api/arquivos` recusam `link` com a frase `RegraDoArquivo::FRASE_DO_LINK`. A tela antiga continua oferecendo.

**DECIDIDO em 22/10/2026, pela resposta 252 (a) do dono** ("o tipo Link sai da tela nova"), lida na verificação da fatia 29: é a
opção (A) acima, e o débito se fecha sem código. O link que alguém cadastrar na tela antiga, enquanto ela estiver no ar, aparece na
biblioteca nova com a frase "link: não abre nesta tela" -- a linha e o endereço ficam no banco (0 links hoje). A coluna `link` fica;
se um dia o dono quiser o link de volta, é acrescentar `link` ao `RegraDoArquivo::conferir` e o endereço ao `ArquivoJson`.

### D576 — O filtro da subcategoria da biblioteca é por igualdade, e a seção junta as subcategorias sem a caixa e sem os espaços — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

**Achado ao consertar a verificação da fatia 28** (22/10/2026, ARQUIVOS-BIBLIOTECA). A biblioteca nova ganhou a escolha da
subcategoria na aba (a tela antiga tinha o campo, e o desenho a faz navegação), e ela pede `?subcategoria=<nome da seção>`. A
seção vem de `RegraDoArquivo::secoes`, que junta "Ferramentas - X", "ferramentas - x" e "Ferramentas - X " numa só e lhe dá o
primeiro nome; o filtro de `ArquivoRepository::listagemDaApi` é `arq.subcategoria = :subcategoria`. No MySQL a coluna é
`utf8mb3_general_ci`, PAD SPACE (medido no `information_schema`), e a igualdade já ignora a caixa e os espaços do fim -- só o
espaço do começo a separa; no PostgreSQL ela é exata. **Hoje não acontece:** as 7 subcategorias das duas cópias são exatas, sem
variante (medido no PostgreSQL do `franquiabv_dev`: 7 nomes, 101 arquivos em Materiais, e 8 sem subcategoria nos Manuais). Se o
cadastro antigo gravar uma variante antes da fatia 29, a seção diria, por exemplo, 14 arquivos e a escolha listaria 13. **O que
fecha:** a decisão 5 do desenho, na fatia 29 (a subcategoria que só difere de uma existente na caixa vira a existente, e o nome é
aparado), ou o filtro comparar sem a caixa e sem os espaços, nas duas portas, com caso nos dois bancos.

**Corrigido em 22/10/2026** (a fatia 29 da fila das telas que faltam, ARQUIVOS-CADASTRO), pela decisão 5: as DUAS portas que
gravam -- a `/adm/api/arquivos` nova (o cadastro e o `PUT`) e o salvar da tela antiga (`ArquivoService::salvar`) -- fazem a
subcategoria que só difere de uma existente da mesma categoria na caixa ou nos espaços das pontas virar a existente, como ela
está no banco (`RegraDoArquivo::subcategoriaCanonica`, com `ArquivoRepository::subcategoriasDaCategoria`, fora a do próprio
arquivo, para o único de uma subcategoria poder corrigir a caixa dela). A seção e o filtro por igualdade passam a ver a mesma
coisa em tudo o que se grava daqui em diante; as cópias de hoje não têm variante. Casos nos dois bancos:
`ArquivosPelaApiTest::testASubcategoriaQueSoDifereNaCaixaViraAExistente`, `::testOPutAlteraCampoACampo` e
`::testAPortaAntigaJuntaASubcategoriaEDeixaONome`, e os casos `canonica` de `formularioDoArquivo.casos.json`, nos dois lados;
o mutante que devolve a pedida fica vermelho. O filtro continua por igualdade: uma variante gravada por fora das duas portas (à
mão, no banco) ainda separaria -- não há outra porta.

### D577 — O item do menu e a leitura da tela exigem privilégios distintos no modo de escrita dos arquivos — REGISTRADO em 22/10/2026 — CORRIGIDO em 08/11/2026

**Achado ao montar o cadastro** (22/10/2026, a fatia 29 da fila das telas que faltam, ARQUIVOS-CADASTRO). O item
"Configurações › Cadastro de arquivos" do menu da tela nova aparece a quem tem `configuracoes/arquivosCadastrar/ler`
(`menu-do-erp.ts`), como no ERP antigo; a tela que ele abre é a biblioteca no modo cadastro, e as leituras dela -- a lista e as
categorias da `/adm/api/arquivos` -- pedem `configuracoes/arquivosExibir/ler` (`Adm\ArquivosController`, a funcionalidade do
construtor). Quem tivesse só o primeiro veria o item e um 403 na tela. **Hoje não acontece com ninguém:** nenhum não
administrador tem linha na funcionalidade 31 (a consulta C6 de `tela-arquivos.md`), e o administrador passa por toda ACL. **Sem
perda de dado.** **Conserto possível, sem decisão do dono:** as leituras aceitarem também o `arquivosCadastrar/ler`, ou o item do
menu pedir os dois -- com caso no `Console.teste.tsx` e no `ArquivoApiTest`.

**CORRIGIDO em 08/11/2026 (a fatia 123, ARQUIVOS-NA-TELA-NOVA).** O item pede os dois -- a segunda saída do registro --, e quem
lê a API não muda (a resposta 135 (a) do dono, "como hoje"). A `GuardaDePrivilegio` do `menu-do-erp.ts` ganhou a forma
`{ todos: [...] }` (a lista continua "qualquer um de", o `||` do layout); o `temOPrivilegio` a confere, e o "todos" vazio não
guarda nada. O "Cadastro de arquivos" pede `configuracoes/arquivosCadastrar/ler` E `configuracoes/arquivosExibir/ler`; o
`?tela=` (`podeAbrirPeloEndereco`) segue o mesmo `podeVer`. Os casos: no `menu-do-erp.teste.ts`, o "todos" (cada um, nenhum, o
vazio), o item com os dois, e o endereço; o "todo destino tem exatamente uma guarda" reprova também o "todos" de menos de dois; no
`Console.teste.tsx`, o item no menu com só o do layout, só o da leitura e os dois (o "Assistentes VIP" do mesmo grupo é o
controle). Mutantes: o item de volta ao privilégio só (F3) e o "todos" como "qualquer um" (F4), vermelhos. Os seis vermelhos do
`Console.teste.tsx` com o amplificador são os do D471, os mesmos com o `menu-do-erp.ts` do HEAD. CORRIGIDO.

### D578 — A tela antiga de Arquivos deixava escrever quem tem o privilégio sem ser administrador — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

**Achado pela verificação da fatia 29** (22/10/2026, ARQUIVOS-CADASTRO). A decisão 1 de `tela-arquivos.md` é "só o administrador
escreve", e a API nova a seguia: o `POST`, o `PUT`, a troca e o estado de `/adm/api/arquivos` recusam com 403 e a frase
`RegraDoArquivo::FRASE_DO_ADMINISTRADOR` quem tem `configuracoes/arquivosCadastrar/escrever` sem o papel. A porta antiga, aberta
pelo endereço direto, só conferia o privilégio: o `salvar`, o `novo`, o `editar` e o `ativarRemover` de
`ISConfiguracao\Controller\ArquivosController` deixavam esse mesmo usuário cadastrar, trocar, alterar e desativar os arquivos da
rede -- o 403 da API se contornava pela outra porta. **Hoje ninguém era afetado:** nenhum não administrador tem linha na
funcionalidade 31 (a consulta C6). **Conserto, no molde dos convênios (o D255), que fechou as duas portas:** o `salvar` responde
com a frase, e o `novo`, o `editar` e o `ativarRemover` voltam à lista com ela, sem gravar nada
(`ArquivosTravessiaTest::testSoOAdministradorEscrevePelaPortaAntiga`, nos dois bancos; os quatro mutantes, vermelhos).

### D579 — O nome do arquivo enviado saía sem escape no editar antigo, e a API nova abria mais um escritor para ele — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

**Achado pela verificação da fatia 29.** O nome que vem no multipart vai a `nome_original` só aparado e cortado
(`RegraDoArquivo::nomeOriginal`), pelas duas portas, e `arquivos/editar.phtml:76` o ecoava cru: um PDF de verdade enviado com o
nome `<img src=x onerror=...>.pdf` (dá pelo curl, ou num navegador de macOS ou Linux) rodava o script na sessão do administrador
que abrisse o editar. O ralo estava listado no desenho como parte do **D203**; a fatia 29 abriu um escritor novo para ele sem
fechá-lo. **Hoje 0 linhas** com `<` em `nome_original` na cópia. **Conserto:** a view escapa o nome
(`ArquivosTravessiaTest::testOEditarAntigoEscapaONomeOriginalELevaOInstante`), e as três gravações da porta antiga passam pelo
`RegraDoArquivo::nomeOriginal`, como as da API. O nome continua guardado como veio: é o que o "baixar" põe no `filename*`, já limpo
pelo `RegraDoArquivo::disposicao`.

### D580 — O salvar antigo regravava o formulário inteiro e desfazia calado o que a tela nova gravou — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

**Achado pela verificação da fatia 29.** O editar antigo já vem com o estado preenchido, e o `salvar` grava a categoria, a
subcategoria, o nome, a descrição e o estado do formulário. Com o editar aberto, outra pessoa desativava o arquivo pela tela nova
(ou mudava o nome pelo `PUT`); quem estava no editar mudava só a descrição e salvava, e o arquivo voltava ATIVO, com o nome de
antes, sem aviso para ninguém. Do lado novo, o `PUT` não aceitava o `alteradoEm` (o `conferir` recusava a chave): duas abas
mudando o mesmo campo ficavam com a última escrita. **Conserto, no molde do D293 e do D565 dos sorteios:** o `PUT` aceita o
`alteradoEm` que a lista devolveu, e o `ArquivoService::alterar` o confere com a linha travada -- o arquivo gravado no meio é 409
com `detalhes.motivo = "mudou"`, e a tela nova fecha o formulário, diz e relê; o editar antigo leva o instante num campo oculto
(`alterado_em`), e o `salvar` o confere com a linha travada, recusando com `ArquivoService::FRASE_DO_QUE_MUDOU`. O pedido sem o
instante (a página aberta antes desta versão) não confere, como antes. Casos: `ArquivosPelaApiTest::testOPutConfereOAlteradoEm` e
`ArquivosTravessiaTest::testOSalvarAntigoNaoDesfazOQueOutraEscritaGravou`, nos dois bancos.

### D581 — Duas trocas do mesmo arquivo ao mesmo tempo deixavam um órfão no disco — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

**Achado pela verificação da fatia 29.** A troca do conteúdo (a da API, `trocarConteudo`, e a do editar antigo, `trocar`) lia o
nome físico de antes sem transação nem trava. Duas trocas juntas -- duas abas, ou a tela antiga e a nova -- liam o mesmo antigo X:
uma guardava N1, gravava e apagava X; a outra guardava N2, gravava e tentava apagar X, que já tinha saído. A linha ficava em N2, e
N1 ficava para sempre em `public/arquivos`, sem linha nenhuma; as duas telas diziam "trocado". A troca nunca apagava o arquivo para
onde a linha aponta. **Conserto:** as duas trocas travam a linha (`PESSIMISTIC_WRITE`) antes de ler o antigo, e o novo só é
guardado depois da trava -- a segunda espera a primeira e apaga o que ela gravou. O mesmo vale para o desativar, o `PUT` e o editar
antigo, com o caso que segura a linha noutra conexão
(`ArquivosPelaApiTest::testAsEscritasDeUmArquivoEsperamATravaDeOutraSessao`: o `refresh` sem a trava passava verde no caso do 409).

**Em 22/10/2026, na verificação da fatia 29b, a troca da API passou a guardar o novo ANTES da trava** (o **D585**): a trava só
precisa cobrir a leitura do antigo e a gravação da linha -- é isso que impede o órfão --, e o envio dentro dela fazia a entrega que
grava quem abriu esperar o envio inteiro. A espera ou a gravação que falha tira o novo; o caso da trava presa passou a exigir que
o que a troca guardou saia (`guardados` igual a `removidos`), e não que ela não guarde nada.

### D582 — A tela antiga de Arquivos não conferia o envio pela regra 8: o tipo pelo conteúdo e o tamanho — REGISTRADO em 22/10/2026 — CORRIGIDO em 22/10/2026

**Achado pela verificação da fatia 29.** A API nova confere cada envio pelo `RegraDoArquivo::conferirEnvio` -- o `error` do PHP, o
tamanho até o limite, o `%PDF-` do PDF e o ZIP da planilha. O `salvar` antigo só conferia a extensão: um HTML com nome `.pdf`
entrava, e o arquivo que o PHP cortou pelo tamanho sumia calado do lote (o `enviados` o pulava, e os outros entravam). O risco era
baixo -- só o administrador alcança a porta, e a entrega sai com `nosniff` --, mas a regra 8 valia só numa das portas.
**Conserto:** o `salvarValidadeFormulario` recusa cada envio que o `conferirEnvio` recusaria, com o nome do arquivo e a frase
(`ArquivosTravessiaTest::testAPortaAntigaConfereOEnvioComoAApi`, nos dois bancos).

### D583 — O registro de quem abre os arquivos guarda dado de funcionário sem prazo de guarda, sem regra própria de quem vê e sem aviso aos funcionários — REGISTRADO em 22/10/2026 — ANOTADO em 22/10/2026 — ANOTADO em 23/10/2026: o rastro do WhatsApp da unidade também

**Nasce com a fatia 29b (RESPOSTA-253-QUEM-ABRIU), pela resposta 253 (c) do dono** ("Um registro por pessoa: quem abriu, qual
arquivo e quando; responde o gerente novo leu o manual da marca?"). Desde ela, cada "abrir" e cada "baixar" de um arquivo da
franqueadora que sai -- pela tela antiga e pela nova -- grava uma linha em `arquivos_acessos`: o arquivo, quem entrou, a pessoa
assumida na personificação, a unidade da sessão, abrir ou baixar, e quando. **É dado pessoal de funcionário** (o que cada pessoa
leu, e quando), e o dono mandou marcar as questões da LGPD como débito futuro ("Também marque as questões da LGPD como débito
futuro"). Por isso este débito fica **ANOTADO**, sem pergunta nova e sem decisão tomada aqui. O que fica em aberto:

- **O prazo de guarda.** Nada apaga as linhas: elas crescem enquanto houver quem abra. Nenhum prazo foi decidido, nem o que acontece com as linhas de quem sai da rede (hoje ficam, com o nome vazio quando a pessoa sai do cadastro:
  a tela diz "pessoa nº N").
- **Quem pode ver.** Hoje, só quem escreve no cadastro de arquivos E é administrador (`configuracoes/arquivosCadastrar/escrever`
  e o papel; a `GET /adm/api/arquivos/:id/acessos` e a contagem da lista). Não há recorte por unidade -- o administrador vê a
  rede inteira -- nem um papel próprio para "ver quem leu". O gerente não vê quem da equipe dele leu.
- **O aviso aos funcionários.** Nenhuma tela diz a quem abre um arquivo que a abertura fica registrada com o nome dele.
- **O acesso do titular.** A pessoa não vê o que o registro guarda sobre ela, e não há como pedir a correção ou a exclusão.

**Onde mora:** `ISConfiguracao\Service\RegistroDeQuemAbriu` (grava e lê), `EntregaDoArquivo` (chama), a migração
`scripts/migracao-arquivos-acessos.sql` (no passo 0 do corte) e o desenho `docs/f3-telas-restantes/tela-arquivos.md` (a pergunta
6 e a seção 6). **O que a fatia já fez a favor da LGPD:** só o administrador que cadastra vê; o servidor nem manda a contagem a
quem não vê; nada se registra além do necessário para a resposta 253 (nem o endereço IP, nem o navegador).

**A verificação da fatia 29b (22/10/2026) tirou uma segunda cópia do rastro:** a falha ao gravar a linha ia ao log com a frase do
banco, e a do DBAL traz o SQL e os parâmetros -- o arquivo, quem abriu, em nome de quem, a unidade e a hora (a do PostgreSQL pode
trazer a linha inteira, "Failing row contains ..."). O log passava a guardar o mesmo dado pessoal da tabela, fora dela e fora deste
débito. Agora o log diz só a classe e o SQLSTATE (`RegistroDeQuemAbriu::motivoNoLog`; a frase só vai quando nenhuma exceção da
cadeia veio do banco), com o caso `QuemAbriuOArquivoTest::testAFalhaAoGravarNaoImpedeAEntrega` conferindo que o log não traz o
SQL, os parâmetros, quem abriu nem a unidade, nos dois bancos. O que este débito decidir sobre o prazo de guarda vale também para
o log do servidor, que guarda o número do arquivo e a hora da falha.

**23/10/2026 -- o rastro do WhatsApp da unidade entra neste débito** (a fatia 32, WHATSAPP-RASTRO; o **D591**). Desde ela,
`unidades_whatsapp_eventos` guarda quem gerou o código QR e quem desconectou o WhatsApp de cada unidade, e quando -- quem
entrou e a pessoa assumida na personificação --, pelas duas portas. **É o mesmo tipo de dado de funcionário**, e vale para ele o
que está em aberto aqui, sem pergunta nova: **o prazo de guarda** (nada apaga as linhas); **quem pode ver** (hoje, quem tem
`configuracoes/whatsapp/ler` na unidade -- a `GET /adm/api/whatsapp` devolve os cinco últimos com os nomes --, porque o desenho
da tela os mostra a quem vê a tela; não há papel próprio, e o administrador vê o de cada unidade em que entra); **o aviso aos
funcionários** (nenhuma tela diz que o pedido fica registrado com o nome); e **o acesso do titular**. O que a fatia já fez a
favor da LGPD: nada além do necessário (nem o IP, nem o navegador, nem o texto do provedor); o log da falha ao gravar leva só a
classe e o SQLSTATE (`RastroDoWhatsApp::motivoNoLog`, provado no
`WhatsAppPelaApiTest::testAFalhaAoGravarORastroNaoLancaEOMotivoESoOTratado`).

### D584 — A carga do corte não copiava as três colunas da regra do cupom de sorteio: o `migrar-dados.sh` não foi regerado na fatia 26 — REGISTRADO em 22/10/2026 — PARCIAL em 22/10/2026: a carga regerada copia as colunas; nenhum portão compara o `migrar-dados.sh` com o gerador — CORRIGIDO em 08/11/2026

**Achado pela fatia 29b**, ao regerar a carga para a tabela nova (`arquivos_acessos`): antes da mudança, o
`scripts/gerar-migracao-dados.php` já não reproduzia o `scripts/migrar-dados.sh` commitado -- a diferença eram as três colunas
da regra do cupom de sorteio (`sorteios.regra_cupom`, `sorteios.valor_cupom` e `sorteios_vendas.numero`, o **D561**), que a
fatia 26 (`12a9cc8`) levou ao `mysql.sql`, ao `schema.sql` e ao passo 0, mas não à carga. **O efeito, no corte:** a carga
copiaria os sorteios sem a regra (todos voltariam a "um cupom por venda") e os cupons sem o número -- todo cupom com o padrão,
e a chave única (sorteio, venda, numero) do PostgreSQL recusaria o segundo cupom de uma mesma venda, e a tabela inteira dos
cupons não seria copiada (o `\copy` é um comando só). **Conserto:** o `migrar-dados.sh` foi regerado do `mysql.sql` de hoje, e
traz as três colunas nos `SELECT` e nos `\copy` das duas tabelas, além da `arquivos_acessos`. **O que falta:** nenhum portão
compara o `migrar-dados.sh` com a saída do gerador (o CI compara só o `schema.sql`, `.github/workflows/ci.yml`), e foi assim que
a diferença passou por uma fatia inteira.

**CORRIGIDO em 08/11/2026 (a fatia 128, ROTEIROS-DO-CORTE-E-DA-CARGA).** O portão novo
`scripts/conferir-carga-gerada.php` roda o `scripts/gerar-migracao-dados.php` para a memória (num processo à parte, com o
mesmo PHP) e compara, byte a byte, com o `scripts/migrar-dados.sh`: sai 0 iguais, 1 diferentes (com as linhas do `diff -u`
e o comando que regera), 2 quando o gerador falha. Está no `conferir-tudo.sh` e no `ci.yml` (o job `verificacao`, que não
sobe banco: o gerador lê só o `mysql.sql` e o `module/`). O mapa dos tipos do Doctrine do gerador não depende da ordem dos
arquivos (medido: 729 colunas declaradas, nenhuma com dois tipos), e por isso a saída é a mesma no Mac e no runner. O
mutante -- a coluna `regra_cupom` tirada do `\copy` dos sorteios no `migrar-dados.sh` de verdade -- deixou o portão em 1, e o
arquivo voltou pelo sha1. O CI do GitHub está parado por cobrança: o passo novo não rodou no runner.

### D585 — A entrega que grava quem abriu esperava a trava da linha do arquivo durante a troca do conteúdo — REGISTRADO em 22/10/2026 — PARCIAL em 22/10/2026: a troca da API guarda o novo antes da trava; o editar antigo ainda guarda com a linha travada — CORRIGIDO em 08/11/2026

**Achado pela verificação da fatia 29b (RESPOSTA-253-QUEM-ABRIU).** Desde o registro de quem abriu (o **D583**), cada entrega de um
arquivo grava uma linha em `arquivos_acessos`, cuja chave estrangeira para `arquivos` confere a linha-pai: no InnoDB, com uma
trava compartilhada nela; no PostgreSQL, com um `FOR KEY SHARE`. As duas esperam o `FOR UPDATE`. E a troca do conteúdo (o
**D581**) travava a linha ANTES de guardar o novo -- com o armazenamento S3 (`ArmazenamentoS3`, a pilha do BVIP), o envio do arquivo
inteiro (até o limite do envio do PHP, `RegraDoArquivo::limiteDoEnvio`) acontecia com a linha presa. **O efeito:** quem abria ou baixava o arquivo X enquanto o administrador trocava o conteúdo de
X ficava parado até a troca terminar, já com o conteúdo antigo na resposta; acima do `innodb_lock_wait_timeout`, a gravação
falhava (o log) e o arquivo saía mesmo assim. Nunca um 500, mas a premissa da fatia ("a entrega não fica mais lenta de forma
perceptível") não valia nesse caso. Com o armazenamento local, o de hoje, a espera era a de um `move_uploaded_file`.
**Conserto, na troca da API** (`ArquivoService::trocarConteudo`, a `POST /adm/api/arquivos/:id/arquivo`): o novo é guardado antes da
transação, e a trava cobre só a leitura do antigo e a gravação da linha -- o que impede o órfão do D581 continua igual, e a espera
ou a gravação que falha tira o novo. Os casos: `ArquivosPelaApiTest::testATrocaGuardaONovoForaDaTravaDaLinha` (o armazenamento olha,
na hora de guardar, se a troca já abriu a transação) e o da trava presa, que passou a exigir que o guardado saia. Os mutantes que
devolvem o envio para dentro da trava e que esquecem de tirar o novo ficam vermelhos nos dois bancos. **O que falta:** o editar
da tela antiga (`ArquivoService::trocar`, pelo `salvar`) ainda guarda o novo com a linha travada (pelo `linhaParaEditar`), porque
confere o `alterado_em` antes e as recusas dele voltariam a precisar tirar o que já subiu; a fatia não mexe na tela antiga além de
gravar quem abriu. O risco é o mesmo, só pela porta antiga, e só com o S3.

**CORRIGIDO em 08/11/2026 (a fatia 123, ARQUIVOS-NA-TELA-NOVA).** O editar antigo (`ArquivoService::trocar`, pelo `salvar`)
segue o molde do `trocarConteudo`: confere a extensão e guarda o novo ANTES de abrir a transação; a trava (o `linhaParaEditar`)
cobre só a leitura do antigo, a conferência do `alterado_em` e a gravação da linha. As recusas que vêm depois de guardar -- a
linha que não existe, o formulário que leu outro instante (`FRASE_DO_QUE_MUDOU`), a espera da trava que
falha e a gravação que falha -- tiram o guardado (`tirarOGuardado`): nada fica no disco sem linha, e o órfão do D581 continua
impossível. O preço, aceito: o formulário velho com um arquivo novo sobe o arquivo e o tira (antes, nem subia), e o
armazenamento que falha diz "não pôde ser guardado" antes de o servidor saber que o formulário era velho. Os casos:
`ArquivosPelaApiTest::testOEditarAntigoGuardaONovoForaDaTravaDaLinha` (o armazenamento olha, na hora de guardar, se o editar já
abriu a transação; o antigo sai depois; o id que não existe tira o guardado), o editar com arquivo novo no
`testAsEscritasDeUmArquivoEsperamATravaDeOutraSessao` (parado NA trava, com o guardado tirado) e o
`ArquivosTravessiaTest::testOSalvarAntigoNaoDesfazOQueOutraEscritaGravou` (o guardado sai na recusa do instante), nos dois
bancos. Mutantes, devolvidos pelo sha1, vermelhos nos dois bancos: a transação aberta antes do envio (M5), e cada uma das três
retiradas arrancada (M6, a recusa; M7, a espera da trava; M8, a gravação que falha). Com o S3 de verdade (a pilha do BVIP), não
medido. CORRIGIDO.

### D586 — O "gerar código" do Evolution exigia o código de pareamento, que o Evolution só manda quando recebe o número — REGISTRADO em 23/10/2026 — CORRIGIDO em 23/10/2026: o QR é a imagem, e o código de pareamento é opcional

**Achado pela fatia 31 (WHATSAPP-CONEXAO), lendo o código público do Evolution, sem chamá-lo.** `EvolutionApi::instanciaConectar()`
recusava a resposta do `GET /instance/connect/{instancia}` sem o `pairingCode` OU sem o `code` ("Resposta inesperada da API.
Dados incompletos."). No repositório EvolutionAPI/evolution-api (o ramo `main`, lido em 23/10/2026), o `connectToWhatsapp` devolve
o `qrcode` da instância, e o ramo do `qr` no `connectionUpdate` de `whatsapp.baileys.service.ts` só pede o código de pareamento
quando há número (`if (this.phoneNumber)`), e senão o põe em `null` -- e o `connect` sem `?number=`, que é o que o cliente pede,
apaga o número guardado. **O efeito, se a produção roda essa versão:** todo "gerar código" das 17 unidades ativas no Evolution
vinha como falha, com a imagem do QR dentro da resposta, e a tela antiga dizia "QRCode não disponível". **Não medido**: a
versão do Evolution da produção, e se ele guarda o número de outro jeito, só a produção diz; e ninguém relatou o defeito.
**Conserto** (`module/ISBase/src/Util/EvolutionApi.php`): o que se exige é a imagem (`base64`, texto não vazio), que é o que as
duas telas mostram; o `pairingCode` e o `code` são opcionais, e o que não é texto vira vazio (o `explode` sobre lista lançava
TypeError). A conferência da imagem em si -- PNG ou JPEG em base64 de verdade -- é da `RegraDaConexaoWhatsApp::qrDaImagem`, na API
nova. **Provado** no `WhatsAppPelaApiTest::testOConnectDoEvolutionSemPairingCode`, contra um `php -S` do caso em 127.0.0.1 (a
resposta sem o código de pareamento sai com o QR; o `code` em lista não lança; sem imagem é falha), nos dois bancos; os mutantes
que voltam a exigir o `pairingCode` e o `explode` cru, vermelhos. Nenhum pedido foi ao provedor.

### D587 — O caixa desconectava o WhatsApp da unidade pela tela antiga, contra a resposta 136 (a) — REGISTRADO em 23/10/2026 — CORRIGIDO em 23/10/2026: só gerente ou administrador desconecta, nas duas portas

**Achado pela fatia 31 (WHATSAPP-CONEXAO).** A resposta 136 (a) do dono é "tela nova, e só gerente ou administrador desconecta":
"os 13 caixas continuam conectando, mas deixam de desconectar". A `POST /adm/api/whatsapp/desconexao` nasceu com a regra (403
`papel`), e a tela antiga (`ISConfiguracao\Controller\WhatsAppController::desconectarAction`) pedia só o privilégio e o WhatsApp
ligado no banco: o caixa com `configuracoes/whatsapp` (os 13 "Colaborador caixa" da Q3 do desenho, em 9 unidades) desconectava
por ela -- a regra ficava contornável enquanto a tela antiga existir. **Conserto:** o desconectar da tela antiga confere o papel
(`RegraDaConexaoWhatsApp::desconectaPeloPapel`, o grupo gerente ou administrador) antes da credencial e do provedor, com a frase
da regra, e o botão de desconectar não aparece para quem não pode (`podeDesconectar` na `instancia.phtml`). O "gerar código"
continua para o caixa, nas duas portas. **Provado** no `WhatsAppTelaAntigaTest::testSoGerenteOuAdministradorDesconectaNaTelaAntiga`
(o caixa recusado sem chamar o provedor, a tela conectada sem o botão para ele e com o botão para o gerente, e o administrador
que não é gerente desconecta), nos dois bancos; os mutantes que tiram a guarda e que mostram o botão a todos, vermelhos.

### D588 — O "gerar código" pela API nova nunca criava a sessão do WAHA que ainda não existe — REGISTRADO em 23/10/2026 — CORRIGIDO em 23/10/2026: a consulta em 404 do WAHA leva ao `connect`, que cria a sessão

**Achado pela verificação da fatia 31 (WHATSAPP-CONEXAO), antes do commit dela.** O `POST /adm/api/whatsapp/conexao` consulta o
estado antes do `connect` e respondia 502 a todo `indisponivel`. No WAHA, a sessão que ainda não existe -- a unidade nova, ou a
sessão que o WAHA apagou -- dá 404 na consulta (`GET /api/sessions/{sessao}`), e quem a cria é justamente o `instanciaConectar`
(`WahaApi::garantirSessaoEIniciar`: o `POST /api/sessions` depois do `GET` em 404); nenhum outro código a cria (o `instanciaCriar`
não tem quem o chame). A seção 6 do desenho já avisava a fatia 31. **O efeito, se tivesse ido ao ar:** a unidade nessa situação
ficaria em 502 `instancia_inexistente` para sempre pela tela nova, e o GET não ofereceria o botão; a tela antiga sempre pediu o
`connect` nesse caso. No `franquiabv_dev` há 9 unidades ativas no WAHA com o WhatsApp ligado; quantas não têm a sessão, só a
produção diz. **Conserto:** a regra pura `RegraDaConexaoWhatsApp::oConnectCriaASessao` (o WAHA, o `indisponivel` e o motivo
`instancia_inexistente`): a `recusaDaConexao` deixa gerar nesse caso, e o `podeConectar` do GET oferece o botão. Só o WAHA e só o
404: o `connect` do Evolution numa instância que não existe dá 404 de novo, e a outra falha da consulta segue 502. O desconectar
do 404 segue 502. **Provado** no `WhatsAppPelaApiTest::testOWahaSemASessaoGeraOCodigo` (o WAHA em 404 conecta e devolve o QR; o
Evolution em 404, o 401 e o 500 do WAHA seguem 502 sem o `connect`; o GET oferece o botão só ao WAHA, e só a quem escreve) e no
`RegraDaConexaoWhatsAppTest::testARecusaDaConexao`, nos dois bancos; os mutantes que voltam o 502 antes da recusa, que tiram o
botão do GET, que deixam o Evolution conectar e que deixam qualquer falha do WAHA conectar, vermelhos. Nenhum pedido foi ao
provedor.

### D589 — A tela antiga do WhatsApp decidia o "gerar código" e o desconectar sem a regra da API nova: o desconectar sem trava nem consulta, e o código reiniciando o WAHA que se conectava — REGISTRADO em 23/10/2026 — CORRIGIDO em 23/10/2026: as duas portas perguntam à mesma regra e esperam a mesma trava

**Achado pela verificação da fatia 31 (WHATSAPP-CONEXAO); o comportamento é anterior à fatia.** A API nova decide pelo estado
(`RegraDaConexaoWhatsApp::recusaDaConexao` e `recusaDaDesconexao`) e toma a trava da unidade nas duas escritas. A tela antiga
(`ISConfiguracao/src/Controller/WhatsAppController.php`), que segue de pé até o corte, não:
- o **desconectar** chamava o `logout` direto, sem trava e sem consultar: no meio de um "gerar código" do WAHA (até perto de um
  minuto, pela API nova ou pela própria tela), com dois gerentes ao mesmo tempo, ou num POST direto com a instância esperando a
  leitura ou se conectando -- o botão só aparece no `open`, mas o POST passa;
- o **"gerar código"** pedia o `connect` em todo estado que não fosse `open`: o WAHA em `STARTING` ia ao `restart` e derrubava a
  reconexão em curso (o que a API nova recusa com 409 `iniciando`), e a consulta que falhava também conectava (a API dá 502).
A frase "as duas portas, as duas ações, um pedido por vez" do desenho e do controller da API era falsa para o desconectar antigo.
**Conserto:** o desconectar antigo toma a trava da regra (`RegraDaConexaoWhatsApp::TRAVA_DA_UNIDADE`, o mesmo nome que a API e o
código leem -- era uma cópia escrita em dois lugares), consulta o estado e só desconecta a conectada; a desconectada, a que espera
a leitura e a que se conecta voltam com a frase da regra, e a consulta que falha não desconecta. O código antigo pergunta à
`recusaDaConexao`: o WAHA iniciando e a consulta que falha voltam com a frase, sem o `connect`; o WAHA sem a sessão segue para o
`connect`, que a cria (o D588); o Evolution `connecting` gera, como na API. A frase da trava tomada é a do 409 `unidade_ocupada`.
**Provado** no `WhatsAppTelaAntigaTest` (`testAsDuasAcoesDaTelaAntigaEsperamATrava`, `testOCodigoDaTelaAntigaDecidePelaRegra`,
`testODesconectarDaTelaAntigaSoAConectada`), nos dois bancos; os mutantes que tiram a trava do desconectar, que dão outra trava ao
código, que tiram a consulta do desconectar, que desconectam com a consulta que falha e que voltam o código ao "tudo que não é
`open` conecta", vermelhos. Nenhum pedido foi ao provedor.

### D590 — A tela antiga do WhatsApp punha no `<img>` qualquer texto que o provedor mandasse como QR — REGISTRADO em 23/10/2026 — CORRIGIDO em 23/10/2026: só o QR conferido pela regra da API nova

**Achado pela verificação da fatia 31 (WHATSAPP-CONEXAO); anterior à fatia, e a fatia não o piorou.** A `instancia.phtml` lia o
`dados.base64` do retorno do `connect`, viesse do sucesso ou da falha, e o punha no `<img src>` só com o escape de HTML. Um
provedor que respondesse `"base64": "https://outro-host/x.png"` -- num 4xx com corpo JSON, ou num 200 -- fazia o navegador da
unidade pedir a um host que o provedor escolheu. Não é execução de script (é `img src` com escape), e sim um pedido para fora. A
API nova já conferia o QR (`RegraDaConexaoWhatsApp::qrDaImagem`: só PNG ou JPEG, base64 estrito, o começo do arquivo e o teto).
**Conserto:** o controller da tela antiga passa à view só o `qr` do `connect` que deu certo, conferido pela mesma
`qrDaImagem`; o resto é "QRCode não disponível" com a frase nossa. **Provado** no
`WhatsAppTelaAntigaTest::testATelaAntigaSoDesenhaOQrConferido` (a URL de fora, o SVG, o PNG que não é PNG e a falha com o QR dentro
não desenham `<img>`; o QR do WAHA com a quebra de linha sai sem ela), nos dois bancos; os mutantes que voltam o base64 cru e que
aceitam o QR da falha, vermelhos.

### D591 — Ninguém sabia quem gerou o código do WhatsApp da unidade nem quem o desconectou, nem quando — REGISTRADO em 23/10/2026 — PARCIAL em 23/10/2026: as duas portas gravam o rastro e a API o devolve; falta a tela nova mostrá-lo (a fatia 34) — PARCIAL em 24/10/2026: a tela nova mostra os últimos pedidos; falta vê-la no navegador — CORRIGIDO em 24/10/2026 (vista no navegador)

**O W3 do desenho `docs/f3-telas-restantes/tela-whatsapp.md`, na parte que a F0 não fechou** (o resultado do desconectar entrou
no D305; o rastro, não). Quem lê o código QR liga ao sistema o telefone que manda as mensagens da unidade, e quem desconecta para
tudo -- o código de resgate, o login do aplicativo, os aniversários --, e não havia registro de quem fez nem de quando: nenhuma
tabela, e a única data era o `data_alteracao` de Unidades. Na cópia, 66 pessoas que não são administradoras podiam desconectar
(13 delas caixas, antes da resposta 136 a), e a tela antiga descartava o resultado.

**Conserto (a fatia 32, WHATSAPP-RASTRO; a F3 do desenho, pela resposta 136 (a); a tabela pela 83 (a)):** a tabela nova
`unidades_whatsapp_eventos` (id, unidade, evento -- `codigo_pedido` ou `desconexao` --, resultado -- `sucesso` ou `falha` --,
motivo, gravado_por, em_nome_de, data), que só acrescenta: no `mysql.sql`, no `postgres/schema.sql` pelo gerador (com o RLS por
`inquilino_id`), na carga regerada (`scripts/migrar-dados.sh`), na migração idempotente
`scripts/migracao-unidades-whatsapp-eventos.sql`, no laço do passo 0 do `docs/procedimento-de-corte.md` e no do ensaio geral, e nas
duas cópias locais. `ISConfiguracao\Service\RastroDoWhatsApp` grava e lê, sem entidade (a tabela pode faltar: a base do BVIP, a
produção antes do passo 0; a `ColunaNoBanco` pergunta).

- **O que é evento:** o pedido que passou pelas guardas e chegou ao provedor -- o 200 (`sucesso`) e o 502 (`falha`, com o motivo
  da resposta) da `POST /adm/api/whatsapp/conexao` e `/desconexao`, e o mesmo na tela antiga (`codigo` e `desconectar`: o
  `connect` ou o `logout` chamados, e a consulta que falha antes deles). **Não é evento** (o desenho, a nota da fatia 31): a
  leitura (o GET e a consulta de 5 s), e toda recusa -- 401, 403 (o privilégio, o papel, a origem), 404 e todo 409 (o banco, a
  trava da unidade, o estado que não deixa): nada mudou nem tentou mudar no WhatsApp da unidade.
- **Quem pediu:** `CrudController::autorDaEscrita()` -- quem entrou, e a pessoa assumida só quando difere. Na API, lido antes de
  soltar a sessão.
- **O motivo:** só o tratado (`RastroDoWhatsApp::MOTIVOS_DA_FALHA`, os da regra); o resto vira `erro_do_provedor`. Nunca o texto do
  provedor, a chave, a instância nem o QR. O `connect` que "dá certo" sem imagem nenhuma consulta o estado de novo, nas duas
  portas (na tela antiga, desde a verificação): conectada, `sucesso`; senão, `falha` com o motivo da segunda consulta, ou
  `erro_do_provedor`. O QR torto é `falha` com `erro_do_provedor`.
- **Com a trava da unidade tomada** (desde a verificação): a linha é gravada antes de a trava ser solta, nas duas portas, e a ordem
  das linhas é a ordem em que os pedidos foram ao provedor. A API gravava depois de soltá-la.
- **A falha ao gravar não impede o pedido:** vai ao log, só com a classe e o SQLSTATE; dentro de transação, num ponto de salvamento.
- **A leitura:** a `GET /adm/api/whatsapp` ganha `ultimos` -- os cinco mais novos da unidade da sessão, com o evento, o resultado, o
  motivo e a frase nossa, quando, `por` (id e nome) e `emNomeDe`; `null` onde a tabela não existe. Quem tem `ler` vê, como vê o
  resto da tela (o desenho, "o que entra", o item 7). A LGPD -- o prazo, quem vê, o aviso -- é o **D583**.

**Provado:** `WhatsAppPelaApiTest` (6 casos novos: as escritas que chegam ao provedor gravam, com o motivo da resposta e a data do
relógio da ação; nenhuma recusa nem leitura grava; a personificação; os últimos da unidade, em ordem, cinco, sem os de outra
unidade, com o nome de quem saiu do cadastro vazio; sem a tabela, as escritas saem e o `ultimos` é nulo; a falha ao gravar não
lança, não para a transação e não leva o SQL ao log) e `WhatsAppTelaAntigaTest::testATelaAntigaDeixaORastroComoAApi`, nos dois
bancos; o `ensaiar-whatsapp-adm-api.php` confere, pelo HTTP, que as recusas não gravam e que o GET traz o `ultimos`; os mutantes,
vermelhos (a tabela no roadmap de 23/10/2026).

**Depois da verificação (23/10/2026; cinco lentes, 6 achados confirmados, todos tratados):** a trava, acima; a tela antiga passou a
consultar de novo o `connect` sem imagem e a pegar o que o cliente do provedor LANÇA (`aoProvedor`): a consulta, o `connect` ou o
`logout` que lançavam derrubavam o pedido sem a linha, mesmo com o `logout` já no provedor; agora a tela diz que não conseguiu e a
linha é `falha` com `erro_do_provedor`, como na API. Os casos novos: `WhatsAppTelaAntigaTest` 6 (a personificação, o motivo do
desconectar, a leitura e as recusas que faltavam, a trava, o `connect` sem imagem, o cliente que lança) e `WhatsAppPelaApiTest` 1
(a trava); 14 mutantes, vermelhos (o roadmap de 23/10/2026).

**O que falta (PARCIAL):** a tela nova mostrar os últimos (a fatia 34, WHATSAPP-TELA); a tela antiga não os mostra, nem vai mostrar.

**Pelo HTTP, o `sucesso` e a `falha` (24/10/2026, a fatia 33, WHATSAPP-PROVEDOR-DE-MENTIRA; o D592):** o
`scripts/ensaiar-whatsapp-provedor-de-mentira.php`, contra o provedor de mentira no loopback, confere nos dois bancos de contrato
as linhas que cada escrita deixa -- o código pedido e o desconectar com `sucesso`, a chave recusada, a instância que não existe, o
`connect` torto e o `logout` recusado com `falha` e o motivo --, na ordem, do gerente, sem personificação, e o `ultimos` do GET
seguinte; e que o 409 do estado que não deixa não grava. A marca não muda: falta a tela.

**E pela tela antiga (24/10/2026, a verificação da fatia 33):** o mesmo ensaio pede, pela rede e no mesmo provedor, o gerar código
e o desconectar da tela antiga (o que dá certo e o que falha, nos dois provedores), e confere as linhas delas no mesmo rastro, na
ordem, entre as da API. A paridade das duas portas, que estava provada só em processo, está provada pela rede.

**A tela (24/10/2026, a fatia 34, WHATSAPP-TELA; a F4 do desenho):** Configurações › WhatsApp, na tela nova, mostra os últimos pedidos que o `GET /adm/api/whatsapp` devolve -- quando, quem (com "entrando como" na personificação, e "pessoa nº N" para quem saiu do cadastro) e o que foi feito, com a frase do motivo na falha (`frontend/src/telas/WhatsApp.tsx`, `frontend/src/telas/conexaoDoWhatsApp.ts`). A lista vazia diz "ninguém gerou o código nem desconectou desde que o registro começou", e o `null` diz que o registro não está ligado neste banco, e não "ninguém pediu". Depois de cada escrita a tela consulta de novo, e a linha nova aparece. Provado no `frontend/src/telas/WhatsAppLigado.teste.tsx` (a personificação, o nome vazio, a falha com a frase, o vazio e o nulo); os mutantes que tiram a personificação e que tomam o nulo pelo vazio, vermelhos (o roadmap de 24/10/2026).

**O que falta (PARCIAL):** ver a tela no navegador, contra o banco de contrato e com o provedor de mentira -- a lista depois de gerar o código e de desconectar, a 375 px e nos dois temas. Os testes do front não desenham o CSS (o `App.css` chega vazio no vitest), e o navegador já achou o que os testes não acharam em outras telas.

**Visto no navegador em 24/10/2026**, contra o banco de contrato e com o provedor de mentira da fatia 33 (o `conferir-provedores-no-loopback.php --conteiner` com 0 antes), como um administrador temporário, a 375 px, a 1.280 px e nos dois temas: o gerar código com o QR, o conectado visto pela consulta, o desconectar que pergunta no botão, e os últimos pedidos com quem pediu e quando, gravados na ordem (o código pedido e os dois desconectar, as três linhas do administrador). Dois defeitos achados ali e consertados no mesmo commit: a frase do desconectar saía duas vezes no Evolution (a segunda em verde), e a nota de onde veio o estado encostava no botão.

### D592 — A URL do provedor de WhatsApp era fixa no código, e nada provava o fluxo inteiro sem sair da máquina — REGISTRADO em 24/10/2026 — CORRIGIDO em 24/10/2026: a URL do loopback vem da configuração, e o ensaio prova o fluxo contra o provedor de mentira

**A F5 do desenho `docs/f3-telas-restantes/tela-whatsapp.md`.** O `EvolutionApi` e o `WahaApi` tinham o endereço escrito no
construtor (`https://evolution.linkwise.digital`, `https://waha.linkwise.digital`), e no desenvolvimento esses nomes vão ao loopback
(o D360). O resultado: nenhum teste nem ensaio alcançava o QR, o conectado, o desconectar nem o 502 pelo HTTP -- o
`ensaiar-whatsapp-adm-api.php` para antes da rede de propósito, e os casos em processo usam um cliente de mentira, ou trocam o
endereço por reflexão. O rastro da fatia 32 (o D591) só tinha o `sucesso` e a `falha` provados em processo.

**Conserto (a fatia 33):**

- **O endereço pode vir da configuração**, e o padrão continua o de hoje: o quarto argumento dos dois construtores, lido de
  `whatsapp.url.evolution` e `.waha` num `config/autoload/whatsapp.local.php` próprio (o modelo é o `whatsapp.local.php.dist`;
  nunca o `local.php`), pelas duas portas da tela (a `/adm/api/whatsapp` e a tela antiga). O cron e o envio de mensagens não o
  leem.
- **Só o loopback vale** (`ISBase\Util\EnderecoDoProvedorWhatsApp`, conferido no construtor do cliente): `127.0.0.1`, `::1` ou
  `localhost`, em http ou https, só com a porta. A de fora é ignorada -- o cliente fica com o endereço de sempre -- e o log não leva a
  URL, que pode ter usuário e senha. Nenhuma chave vai a um host qualquer por engano de configuração.
- **O provedor de mentira** (`scripts/provedor-whatsapp-de-mentira.php`, um `php -S`, e o `provedor-whatsapp-de-mentira-processo.php`
  que o sobe e o para, conferindo pelo /proc): os formatos do Evolution e do WAHA que os clientes leem, o QR em PNG de verdade, as
  falhas escolhidas pelo teste (401, 404, 500, a demora, a resposta torta), a chave conferida pelo sha256 e nunca escrita, e 404 sem
  fazer nada a qualquer outro caminho (o envio de mensagem inclusive).
- **O ensaio** `scripts/ensaiar-whatsapp-provedor-de-mentira.php` (no CI, no `medir-telas-no-postgres.sh` e no
  `conferir-lacuna-do-ci.sh`): o GET com cada estado nos dois provedores, o código com o QR, o desconectar, o 502 com o motivo, o
  rastro inteiro e o provedor sem pedido fora das duas portas; recusa rodar se a URL configurada não for do loopback.

**A verificação (24/10/2026; 16 achados confirmados, todos tratados -- o detalhe no roadmap de 24/10/2026 e na seção 6 do
desenho):** o provedor órfão do ensaio morto por fora é recuperado pelo `subir` seguinte (o dono no ambiente; o subido à mão é
recusado com o comando de parar, novo), e o ensaio o sobe antes de abrir o banco -- os órfãos seguravam a conexão e a trava do
ensaio; a cópia do `whatsapp.local.php` leva a marca do dono, e a que sobrou é adotada e apagada; o `finally` para o provedor e
apaga a cópia antes do banco, cada passo no seu `try`; o teste e o ensaio conferem que o cliente aponta para o provedor de mentira
antes de qualquer pedido; o WAHA de mentira reinicia a sessão no logout, como o de verdade (o D593); a tela antiga pela rede; a
demora também no `connect`; a chave procurada no registro antes de ele ser apagado; a recusa do endereço do ensaio medida no teste;
e o cron medido também com a configuração do loopback.

**Provado:** `EnderecoDoProvedorWhatsAppTest` (7 casos) e `ProvedorWhatsAppDeMentiraTest` (11: um nascido da implementação, os
processos órfãos, e quatro da verificação), 18 de 18 nos dois bancos, com os vizinhos; o ensaio, 37 de 37 no MySQL e no PostgreSQL
de contrato, depois do `conferir-provedores-no-loopback.php --conteiner` com 0; a recusa do endereço do ensaio (a URL de fora, com
usuário e senha, com caminho, as duas diferentes, https, sem a porta), medida no teste, sem a URL na mensagem; o ensaio morto no meio
e a rodada seguinte que recupera o provedor e a cópia; os mutantes, vermelhos (as duas tabelas no roadmap de 24/10/2026). No ciclo,
depois da verificação: caracterização 4.539 em cada banco, contrato 74 e 74, os 18 ensaios nos dois bancos sem vermelho, 64
portões e o D50; o front, que a fatia não toca, só com casos de tempo da máquina carregada (os arquivos sozinhos, verdes).

**O que não cobre:** o provedor de verdade (os formatos foram lidos no código dos clientes e na documentação pública dos
provedores, como nas fatias 30 e 31, e não medidos contra eles -- o reinício do WAHA no logout, inclusive); o navegador (a fatia
34); a demora na tela antiga pela rede (os 30 s dela passam do tempo do ensaio; está em processo, no `WhatsAppTelaAntigaTest`); o
ensaio morto pelo KILL no meio de uma escrita no banco (o banco de contrato se remonta); o cron e o envio de mensagens, que seguem
no endereço fixo.

### D593 — Depois de desconectar o WAHA, a API diz `desconectado`, mas a sessão reinicia e volta a pedir a leitura — REGISTRADO em 24/10/2026 — PARCIAL em 24/10/2026: a tela consulta de novo depois do desconectar e mostra o que a consulta diz; falta vê-la no navegador — CORRIGIDO em 24/10/2026 (vista no navegador)

**Achado na verificação da fatia 33 (WHATSAPP-PROVEDOR-DE-MENTIRA).** O `POST /api/sessions/{s}/logout` do WAHA, pela documentação
pública dele, desliga a sessão que não está parada e a começa de novo ("logged out and started from scratch"): ela vai a `STARTING`
e logo a `SCAN_QR_CODE`, e não fica em `STOPPED`. A `POST /adm/api/whatsapp/desconexao` responde `desconectado` em todo logout que
deu certo (`Adm\WhatsAppController`, o fim do `desconectar`), e a consulta seguinte, no WAHA, diz `iniciando` e depois
`aguardando_leitura`. O provedor de mentira passou a fazer o mesmo, e o ensaio o confere.

**O que fazer (a fatia 34, WHATSAPP-TELA):** a tela não toma o `desconectado` do POST como o estado que fica; consulta de novo depois
dele, e mostra o que a consulta disser. Mudar a resposta do POST (consultar o estado depois do logout) é a outra saída, e fica para
a fatia 34 escolher pelo desenho; nenhuma das duas perde dado nem é escolha do dono. Não medido contra o WAHA de verdade.

**A tela (24/10/2026, a fatia 34, WHATSAPP-TELA):** das duas saídas, a que não muda o servidor. Depois de toda escrita que o servidor respondeu -- o 200, o 409 e o 502 --, a tela consulta o estado de novo e mostra o que a consulta diz (`frontend/src/telas/WhatsAppLigado.tsx`); o `desconectado` do POST só vai à frase do que foi feito. No WAHA, com a consulta dizendo `iniciando` ou `aguardando_leitura` depois do desconectar, a tela explica que a sessão recomeça sozinha e que, enquanto ninguém ler um código, nenhuma mensagem sai; e a consulta periódica a acompanha. Provado no `WhatsAppLigado.teste.tsx` e no `WhatsAppLigado.contrato.teste.tsx`; o mutante que não consulta depois da escrita, vermelho.

**O que falta (PARCIAL):** ver no navegador, com o provedor de mentira no WAHA; e, como antes, nada medido contra o WAHA de verdade.


**Visto no navegador em 24/10/2026**, contra o banco de contrato e com o provedor de mentira da fatia 33 (o `conferir-provedores-no-loopback.php --conteiner` com 0 antes), como um administrador temporário, a 375 px, a 1.280 px e nos dois temas: no WAHA de mentira, depois do desconectar, a tela consultou de novo, mostrou "Esperando a leitura do código" e a nota de que a sessão recomeça sozinha; no Evolution, o "desconectado" relido. Dois defeitos achados ali e consertados no mesmo commit: a frase do desconectar saía duas vezes no Evolution (a segunda em verde), e a nota de onde veio o estado encostava no botão.

### D594 — Trocada a unidade noutra aba, a escrita do WhatsApp agia na nova com a tela mostrando a de antes — REGISTRADO em 24/10/2026 — PARCIAL em 24/10/2026: a API recusa a unidade trocada; falta a porta velha — CORRIGIDO em 07/11/2026

**Achado na verificação da fatia 34 (WHATSAPP-TELA).** As duas escritas do WhatsApp da unidade -- gerar o código e desconectar --
agem na unidade que a sessão tem na hora (`Adm\WhatsAppController::prepararAEscrita`; nenhum id vem do pedido), e o pedido não
dizia qual unidade a tela mostrava. O administrador que abria a tela da unidade A (conectada, sem consulta periódica), trocava de
unidade ou personificava alguém da unidade B noutra aba e voltava à primeira, que ainda dizia "WhatsApp de A" com o alcance dos
clientes de A, desconectava B -- e, pela resposta 115 (b) do dono, as mensagens de B daquele tempo se perdem. O QR que o POST de A
devolvesse também ficava na tela sob o título de B. A tela antiga (`ISConfiguracao\Controller\WhatsAppController`) tem o mesmo
desenho. Não reproduzido com duas abas de verdade: lido no código do cliente e do servidor.

**A API e a tela nova (24/10/2026, na verificação da fatia 34):** as duas escritas levam no corpo JSON o id da unidade que a tela
mostra (`unidadeNaTela`; `frontend/src/api/whatsapp.ts`), e o servidor responde 409 `unidade_trocada` se ele não for o da sessão --
depois do 404 e antes do banco, da trava e da rede: nada é construído, nada vai ao provedor e nada vai ao rastro
(`RegraDaConexaoWhatsApp::unidadeTrocada` e `UNIDADE_TROCADA`). O id do corpo só recusa, nunca escolhe a unidade; sem o campo, nada
muda (os ensaios e quem mais chamar a API). A tela relê depois do 409 e mostra a unidade da sessão, com a frase da recusa; e a
consulta que traz outra unidade tira o QR da unidade de antes. Provado no `WhatsAppPelaApiTest::testATelaQueMostraOutraUnidadeNaoEscreve`
e no `RegraDaConexaoWhatsAppTest` (nos dois bancos), e na seção "a unidade da tela" do `WhatsAppLigado.teste.tsx`; o mutante que
arranca a conferência, vermelho.

**O que falta (PARCIAL):** a tela antiga (`/mod-configuracoes/whatsapp/instancia`), que segue alcançável pelo endereço: os formulários
dela não levam a unidade que a página mostra. A saída é a mesma -- um campo escondido com o id, e a mesma regra no controller antigo
--, e entra quando alguém mexer nela, ou some quando ela for desligada.

**A porta velha (07/11/2026, a fatia 108, WHATSAPP-DO-BALCAO):** os dois formulários da tela antiga -- o "Gerar código" e o
"Desconectar" (`module/ISConfiguracao/view/is-configuracao/whats-app/instancia.phtml`) -- levam o id da unidade que a página mostra
num campo escondido (`unidadeNaTela`), e o controller antigo (`ISConfiguracao\Controller\WhatsAppController`, `codigoAction` e
`desconectarAction`) o confere pela mesma regra, `RegraDaConexaoWhatsApp::unidadeTrocada`: outro id, ou um texto torto, volta à
tela com a frase da recusa da API (`UNIDADE_TROCADA`) -- e a tela, relida, mostra a unidade da sessão. A conferência vem depois da
guarda de POST e do 404 (e, no desconectar, do papel), antes da credencial, da trava e da rede: nada vai ao provedor e nada vai ao
rastro. O id só recusa, nunca escolhe a unidade; sem o campo (uma página aberta antes desta mudança), nada muda. **Provado** no
`WhatsAppTelaAntigaTest::testATelaQueMostraOutraUnidadeNaoEscreveNaTelaAntiga`, nos dois bancos: os dois formulários renderizados
com o campo; o código e o desconectar com o id de outra unidade, com um texto torto e com o vazio recusam sem chamar o provedor e
sem rastro; com o id certo, conectam e desconectam. **Mutantes**, um por vez, devolvidos pelo sha1, todos vermelhos: a conferência
arrancada do código (nos dois bancos), a do desconectar (nos dois bancos), o campo arrancado de cada formulário e a variável da
tela arrancada do controller (no MySQL). Não reproduzido com duas abas de verdade num navegador.

### D595 — O "Status" do editar da pesquisa não gravava: quem escolhia "Inativa" lia "editada com sucesso", e ela seguia ativa — REGISTRADO em 24/10/2026 — CORRIGIDO em 24/10/2026: o campo saiu, e o estado muda só pelo ativar e desativar

**O DP-5 de `docs/f3-telas-restantes/tela-pesquisas.md`, consertado na fatia 35 (PESQUISAS-API).** O formulário da
pesquisa (`ISConfiguracao\Form\Pesquisa`) tinha o `<select>` "Status", e as telas do novo e do editar o imprimiam; o
`PesquisaService::editarPesquisa` nunca o leu (o editar dizia "Pesquisa editada com sucesso." e a pesquisa continuava
ativa), e a pesquisa nova nascia ativa qualquer que fosse a escolha. Desde 04/10/2026 a `RegraDaPesquisa::doFormularioDoErp`
já não o levava ao serviço; faltava tirá-lo da tela.

**O conserto:** o campo saiu do formulário (e do `setFormData`) e das duas telas (`pesquisas/novo.phtml` e `editar.phtml`).
O estado muda só pelo ativar e desativar da lista, por POST com o estado pedido (o D199), e, desde esta fatia, pelos dois
endereços da API (`.../ativacao` e `.../desativacao`), os dois pelo `PesquisaService::definirEstado`, com a linha travada. O
"Status" do filtro da lista (`PesquisaIndex`) fica: é a busca, e não a escrita.

**Provado** no `PesquisasPelosControllersTest::testOEditarNaoGuardaAPesquisaNaSessaoEATelaLevaOId` (o formulário do editar
sem o elemento), nos dois bancos.

### D596 — A pergunta e a opção da pesquisa nunca carimbavam a alteração, e a opção excluída sumia da porcentagem sem aviso — REGISTRADO em 24/10/2026 — PARCIAL em 24/10/2026: as escritas carimbam e a API mostra as excluídas com os votos; falta a tela nova mostrá-las (a fatia 36) — PARCIAL em 24/10/2026: a tela nova mostra as excluídas à parte, com os votos; falta vê-la no navegador — CORRIGIDO em 24/10/2026 (vista no navegador)

**O DP-7 de `docs/f3-telas-restantes/tela-pesquisas.md`, a parte sem decisão.** `editarPergunta`, `excluirPergunta`,
`editarResposta` e `excluirResposta` gravavam sem `data_alteracao`: o NULL das linhas de hoje (as consultas C2 e C3 do
desenho) não provava que nunca mudaram -- e os 25.471 votos ficam com o texto que a opção tiver. E a opção excluída saía da
porcentagem da tela antiga sem que a tela dissesse que havia votos fora da conta.

**O conserto (24/10/2026, a fatia 35):** as quatro escritas carimbam, pelo serviço, nas duas portas. A API devolve, em cada
pergunta, as opções ativas com os votos e a porcentagem sobre elas (o N), e as excluídas à parte, com os votos
(`PesquisaJson::completo`: `excluidas` e `votosDasExcluidas`).

**O que falta (PARCIAL):** a tela nova mostrar as excluídas "fora da pesquisa" (a fatia 36, PESQUISAS-TELA). A parte com
decisão -- travar ou avisar quando o texto de uma opção com votos muda -- é a P3 do desenho; segue a recomendação (B),
avisar, e a API devolve os votos de cada opção para a tela dizer antes quantos passam a ter o texto novo.

**Provado** no `PesquisasPelosControllersTest::testAsEscritasDaPerguntaEDaOpcaoCarimbam` (a tela antiga) e no
`PesquisaApiTest::testAPerguntaNasceComAsOpcoesEAOpcaoMudaComOsVotos` e `::testODetalheDizOQueONumeroE` (a API), nos dois
bancos; o mutante que tira o carimbo da opção, vermelho.

**24/10/2026 (a revisão da fatia 35):** o carimbo da exclusão podia deixar de ser o momento dela -- a tela antiga
excluía de novo a linha já excluída e recarimbava, e a segunda de duas abas na API também. Ver o D599: a recusa mora no
serviço, depois da trava, e a linha excluída não é mais regravada.

**24/10/2026 (a fatia 36, PESQUISAS-TELA):** a tela nova mostra, em cada pergunta, as excluídas à parte, em "Fora da
pesquisa", com os votos de cada uma; o N diz quantos votos estão nas opções ativas e quantos nas excluídas, fora da
porcentagem; e excluir uma opção diz antes, no próprio botão, que os votos dela saem da porcentagem e ficam à parte. A parte
com decisão (a P3) segue o "enquanto isso" (B): alterar uma opção ou uma pergunta com votos diz antes quantos votos passam a
dizer o texto novo, com o texto que se digita. **Provado** no `PesquisasLigado.teste.tsx` ("mostra cada opção com a
porcentagem e os votos, o N, as excluídas à parte", "o segundo clique exclui", "diz antes quantos votos passam a dizer o
texto novo"); os mutantes que escondem as excluídas e que tiram o texto novo do aviso, vermelhos. **O que falta (PARCIAL):**
ver a tela no navegador, contra o banco de contrato.

**Visto no navegador em 24/10/2026**, contra o banco de contrato, como um administrador temporário, a 375 px, a 1.280 px e nos dois temas: uma pergunta de três opções com votos de teste (30, 15 e 5), a de 5 excluída pela pergunta no botão, que diz que os votos saem da porcentagem; ela foi para "Fora da pesquisa" com os 5 votos, e a porcentagem refeita sobre 45 (66,7% e 33,3%); a última alteração da pesquisa, carimbada. A penúltima opção da pesquisa da adesão foi recusada pelo servidor, com a frase na linha.

### D597 — A pergunta nova ia para a pesquisa aberta por último na sessão, e a opção, o editar e o excluir aceitavam o id de qualquer pesquisa — REGISTRADO em 24/10/2026 — CORRIGIDO em 24/10/2026: o alvo vem do POST (e da URL, na API), conferido

**O DP-9 de `docs/f3-telas-restantes/tela-pesquisas.md`.** O `editarAction` da tela antiga guardava a pesquisa aberta na
sessão, e o `perguntaSalvarAction` a lia de lá: com duas abas, a pergunta nova caía na pesquisa da última aberta; sem
nenhuma na sessão, 500. A opção nova aceitava o `idPergunta` de qualquer pergunta -- excluída, ou de outra pesquisa --, e o
ler, o editar e o excluir da pergunta e da opção aceitavam qualquer id do banco.

**O conserto (24/10/2026, a fatia 35):** a tela leva o id da pesquisa num campo escondido (`pesquisa_id`), e o
`mod-configuracoes.js` (`?cache=169`) o manda nas seis ações do questionário; o controller confere que a pergunta é daquela
pesquisa (`PesquisaPerguntaRepository::daPesquisa`) e que a opção é de uma pergunta dela (o `idPergunta` do editar não
muda a pergunta da opção, e não é conferido), e a opção nova só entra em pergunta ativa. O editar não guarda mais a pesquisa na sessão. Na API, o mesmo
aninhamento vem da URL: a pergunta de outra pesquisa e a opção de outra pergunta são 404 (`daPesquisa`,
`PesquisaRespostaRepository::daPergunta`).

**Provado** no `PesquisasPelosControllersTest` (a pergunta nova vai para a pesquisa do POST e não para a da sessão; sete
alvos de outra pesquisa não mudam nada no banco; a opção nova em pergunta excluída é recusada) e no
`PesquisaApiTest::testOAninhamentoErradoE404` (dez aninhamentos), nos dois bancos, e pelo HTTP no
`ensaiar-pesquisas-adm-api.php` (os casos 8 e 9); os mutantes que trocam a conferência pelo `find`, nas duas portas, e o
que volta a ler a sessão, vermelhos.

### D598 — Duas trocas da adesão ao mesmo tempo, em pesquisas diferentes, deixavam as duas marcadas no PostgreSQL e travavam uma na outra no MySQL — REGISTRADO em 24/10/2026 — CORRIGIDO em 24/10/2026: a troca trava todas as pesquisas em ordem de id antes de ler

**Achado pela verificação da fatia 35** (PESQUISAS-API; dois achados confirmados, lidos no código e na configuração dos bancos). O
`PesquisaService::marcarParaAdesao` do D400 travava a pesquisa que entra (o `refresh` com `PESSIMISTIC_WRITE`) e depois as
marcadas (`PesquisaRepository::travarAsMarcadas`, `WHERE programa_fidelidade = 1 FOR UPDATE`). Com duas abas pondo
pesquisas diferentes (X e Y) e a Z na adesão:

- **No PostgreSQL** (read committed), a segunda lia as marcadas pela foto do começo do comando (Z marcada, X não),
  esperava a trava de Z, via Z desmarcada e a tirava do resultado -- e não via X, que a primeira acabara de marcar. Ficavam
  X e Y marcadas: o `selecionarPorFidelidade` (sem `ORDER BY`) escolhia qualquer uma para a etapa 5, e o desativar recusava
  as duas. O comentário do `travarAsMarcadas` dizia o contrário.
- **No MySQL** (repeatable read), a consulta das marcadas, sem índice em `programa_fidelidade`, percorre a tabela pela chave
  e trava cada linha que lê: cada aba já segurava a sua pesquisa, e as duas varreduras esperavam uma pela outra. O InnoDB
  desfazia uma (o erro 1213), e quem perdia recebia 500.

A janela é curta (dois cliques quase juntos), e o caminho é o mesmo pelo `/adesao` da API e pelo "Sim" do editar antigo.

**O conserto:** o primeiro comando da troca é `PesquisaRepository::travarTodas` -- `SELECT pes.id ... ORDER BY pes.id` com
`FOR UPDATE`, todas as linhas de `pesquisas`, na mesma ordem para todo mundo. A segunda troca espera a primeira inteira;
depois, a leitura das marcadas é um comando novo, que vê a marca onde a outra a deixou. Sem deadlock: as duas pegam as
linhas na mesma ordem, e o estado e as exclusões (`comAPesquisaTravada`) só prendem a linha da própria pesquisa. A tabela
tem uma linha por pesquisa da rede. É o padrão que o D534 registra em "a trava lê duas vezes". A unicidade no banco segue no
D29.

**Provado** nos dois bancos: em `PesquisasPelosControllersTest::testATrocaTravaAPesquisaEAsMarcadasAntesDeLer`, a primeira
leitura da troca é a trava de todas, sem `WHERE` e em ordem de id; e
`::testDuasTrocasAoMesmoTempoDeixamUmaSoNaAdesao`, fora da transação do teste, com outro processo (o instrumento do D321,
`OutroProcessoSeguraALinha`) fazendo a troca de uma aba -- prende a pesquisa que entra e a da adesão e confirma depois de
1,5 s -- enquanto o caso põe outra: fica uma só marcada. Mutantes: sem o `travarTodas`, e o `travarTodas` sem a trava --
vermelhos nos dois bancos pela primeira leitura, e no PostgreSQL também pelo caso das duas trocas (duas marcadas); o
`travarTodas` sem a ordem, vermelho. No MySQL o caso das duas trocas é verde nos dois códigos (a leitura com trava vê a
versão confirmada); o deadlock de lá é o da ordem, e não foi reproduzido -- pediria as duas trocas no protocolo antigo.

### D599 — O que já foi excluído na pesquisa só era recusado pelo controller da API, e antes da trava: a tela antiga editava e excluía de novo, e duas abas recebiam dois 200 — REGISTRADO em 24/10/2026 — CORRIGIDO em 24/10/2026: as recusas moram no serviço, lidas depois da trava

**Achado pela verificação da fatia 35** (PESQUISAS-API; seis achados confirmados, lidos no código). Os 409 `excluida` e `ja_excluida`
-- alterar a pergunta ou a opção excluída, pôr opção em pergunta excluída, excluir de novo -- e o `ja_na_adesao` estavam só
no `Controller/Adm/PesquisasController.php`, sobre a entidade lida no começo do pedido, sem trava. Daí:

- **A tela antiga** (`perguntaSalvar`, `respostaSalvar`, `perguntaExcluir`, `respostaExcluir`) gravava o texto novo na
  pergunta ou na opção excluída, e excluía de novo, com um carimbo novo -- e respondia "sucesso". A regra deixava de ser "a
  mesma nas duas portas" (a seção 2.3 do desenho), e o carimbo do DP-7 (o D596) deixava de ser o momento da exclusão.
- **Na API, duas abas** que excluíam a mesma opção (ou pergunta) passavam as duas pela conferência; a segunda, depois da
  trava, relia a linha já excluída, nenhuma recusa da adesão disparava, e ela gravava de novo, com o carimbo dela: dois 200,
  e não um 200 e um 409. O mesmo no `/adesao` repetido na mesma pesquisa, e na opção nova numa pergunta que outra aba
  acabara de excluir. O comentário do `mudarEstado` prometia "um 200 e um 409" -- e isso só valia para o estado.

**O conserto:** as recusas saíram do controller para o `PesquisaService`, dentro do `comAPesquisaTravada` (e do
`marcarParaAdesao`), depois do `refresh` da linha: `PERGUNTA_EXCLUIDA`, `PERGUNTA_EXCLUIDA_SEM_OPCAO_NOVA`,
`PERGUNTA_JA_EXCLUIDA`, `OPCAO_EXCLUIDA`, `OPCAO_JA_EXCLUIDA` e `JA_NA_ADESAO`. O `editarPergunta`, o `editarResposta` e o
`novaOpcao` passaram a travar a pesquisa, e devolvem a frase ou `null`; o `novaResposta` da tela antiga lê a pergunta (e
não o `getReference`) e passa pelo `novaOpcao`. A API só traduz a frase no `motivo` (a tabela `MOTIVOS`), e a tela antiga a
mostra. O "Sim" do editar antigo, quando outra aba já pôs a mesma pesquisa na adesão, vale: grava o título.

**Provado** nos dois bancos: `PesquisasPelosControllersTest::testATelaAntigaNaoMexeNoQueJaFoiExcluido` (cinco escritas da
tela antiga sobre o excluído, recusadas com a frase, e nada muda no banco, nem o carimbo) e
`::testOQueOutraAbaFezNoMeioERecusadoDepoisDaTrava` (a entidade lida antes, a outra aba exclui e põe na adesão, e cada
escrita do serviço recusa depois da trava, sem mudar nada); o `PesquisaApiTest::testAsExclusoesQueOEstadoNaoDeixa` segue
com os mesmos 409; e, pelo HTTP, o `ensaiar-pesquisas-adm-api.php` ganhou os casos 31 (excluir de novo) e 32 (alterar a
pergunta excluída e pôr opção nela). Mutantes: cada recusa arrancada do serviço, a tela antiga dizendo "sucesso" na recusa,
e o motivo trocado -- vermelhos.

### D600 — Salvar o aviso no editar do ERP desfazia a desativação feita noutra tela, e o estado era gravado sem reler — REGISTRADO em 25/10/2026 — CORRIGIDO em 25/10/2026: o editar não toca no estado, e o estado é relido com a linha travada

**O lado D259 do M5 de `docs/f3-telas-restantes/tela-mensagens.md`** (o molde do D259 dos convênios), que a F0 de
04/10/2026 deixou de fora, consertado na fatia 37 (MENSAGENS-API). O editar dos avisos tinha o `<select>` de situação
**visível** (`mensagens/editar.phtml`; no cadastro ficava num `display-none`), e a `RegraDaMensagem::doFormularioDoErp`
repassava a escolha ao serviço. Com a tela antiga e a API nova convivendo, o editar aberto antes de uma desativação feita
noutra tela -- a lista, ou `POST /adm/api/mensagens/:id/desativacao` -- a desfazia ao salvar, e o aviso voltava ao topo da
agenda de cerca de 364 pessoas sem ninguém pedir. E o desativar e o ativar gravavam o estado pedido sem reler a linha
(`MensagemService::alterStatus`): duas abas que desativavam liam dois "sucesso", e a API não teria como dizer 409.

**O conserto:** o `<select>` saiu do formulário (`ISConfiguracao\Form\Mensagem` e o `setFormData`) e das duas telas; a regra
não leva `status` na edição, e o cadastro nasce ativo. O "Ativar" mora agora na lista dos desativados, por POST com o estado
pedido (`mensagens/index.phtml`, no molde dos convênios e das pesquisas). O `alterStatus` virou `definirEstado`, nas duas
portas: relê o aviso com `PESSIMISTIC_WRITE` numa transação e recusa o mesmo estado com a frase (`JA_ATIVO`, `JA_INATIVO`) --
o 409 `mesmo_estado` da API, e a mensagem de erro da tela antiga.

**Provado** nos dois bancos: `MensagensPeloControllerTest::testAEdicaoNaoDesfazADesativacao` (o aviso desativado, o editar
com `status=1` no POST, e ele segue desativado) e `::testOAtivarDaListaEOMesmoEstado`;
`TelasDasMensagensDoErpTest::testOEstadoMudaSoPelaLista` (o editar renderizado sem a situação, e a lista dos desativados
com o "Ativar" por POST); `MensagemAlvosTest::testOEstadoERelidoComATrava` (a primeira leitura é o `FOR UPDATE`, e a
desativação feita por outra aba depois da leitura do controller é recusada); `RegraDaMensagemTest::testOFormularioDoErp`. E
pelo HTTP, os casos 13 e 14 do `ensaiar-mensagens-adm-api.php`. Mutantes: a edição repassando o status, o "Ativar" da lista
trocado, o 409 arrancado do serviço e o estado sem a releitura -- vermelhos.

### D601 — A tela antiga gravava o aviso com o dia no passado, que nascia ativo e nunca ia ao ar, e a API o recusava — REGISTRADO em 25/10/2026 — CORRIGIDO em 25/10/2026: a recusa do dia que passou mora na regra, e as duas portas a chamam

**A decisão 8 de `docs/f3-telas-restantes/tela-mensagens.md`** ("dia no passado recusado ao cadastrar e ao trocar o dia"),
que a seção 2 do desenho põe na regra única das duas portas. A fatia 37 (MENSAGENS-API) a pôs só na API nova
(`RegraDaMensagem::conferir`); o `MensagemFilter` da tela antiga conferia só se a data existe, e o administrador cadastrava
pelo ERP o aviso de ontem, ou trocava o dia de um aviso para um dia que passou -- gravado, ativo, e fora do topo da agenda
para sempre. O `docs/f2-fatia-mensagens.md` dizia "a mesma regra da tela antiga". Achado na verificação da fatia, antes do
commit.

**O conserto:** `RegraDaMensagem::diaQuePassou` (o dia que passou e que não é o que o aviso já tem), chamada pela `conferir`
e por um segundo validador do campo `data` do `MensagemFilter`, que recebe o dia de hoje e o dia atual do aviso do
controller (`Form\Mensagem`, o `novo` e o `editar`). O mesmo dia de volta serve: o aviso que já passou continua editável. A
frase é a mesma nas duas portas (`FRASE_DO_DIA_QUE_PASSOU`). A única diferença que fica entre as portas é o dia vazio, que
espera a pergunta 4.

**Provado** nos dois bancos: `MensagensPeloControllerTest::testODiaQuePassouERecusadoComoNaApi` (o cadastro de ontem, a troca
para ontem, o aviso de 10 dias atrás editado com o mesmo dia e recusado com outro, o de hoje) e
`RegraDaMensagemTest::testODiaQuePassouNoFormularioDoErp`. Mutantes: o validador arrancado, a regra sem o dia atual e o
editar sem o dia atual -- vermelhos.

### D602 — Cinco formulários levam o foco ao campo recusado antes de a recusa estar na tela: o leitor de tela anuncia o campo sem a frase — REGISTRADO em 25/10/2026 — PARCIAL em 25/10/2026: os nove do `focarOPrimeiro` focam depois da marca; faltam os de um campo só (estoque, etiquetas, pesquisas e informar do sorteio) — CORRIGIDO em 25/10/2026: os de um campo só, os banners, os arquivos, os filtros de código e o título do pacote também focam depois da marca

**Achado na fatia 38 (MENSAGENS-TELA), pelo amplificador dos efeitos** (`AMPLIFICAR_EFEITOS_MS=50`). O formulário dos avisos
nasceu no molde do dos convênios: ao enviar, `setErros(...)` e, no mesmo gesto, `focarOPrimeiro(...)` -- na recusa local e
na do servidor (o 422). O `focus()` é síncrono, e o `setErros` só chega ao DOM na renderização seguinte: o campo recebe o
foco ainda sem o `aria-invalid` e sem a frase da recusa no `aria-describedby`. Quem usa leitor de tela ouve o campo, e não
o que está errado nele; a frase só entra depois, sem ser anunciada (o "N campos precisam de ajuste", no `role="alert"`, é
o único aviso). No vitest o caso do 422 no dia achava o foco no campo e a descrição só com a dica -- e, sem o amplificador,
passava.

**Consertado nos avisos** (`frontend/src/telas/Mensagens.tsx`, `FormularioDaMensagem`): cada envio recusado conta um, e um
efeito leva o foco ao primeiro campo marcado DEPOIS de a marca estar na tela. O mutante que volta ao foco no mesmo gesto
fica vermelho com o amplificador ("a recusa do servidor vai ao campo dela, com o foco").

**Ficavam com o defeito** -- os que chamam `focarOPrimeiro(locais)` ou `focarOPrimeiro(lida.campos)` logo depois do `setErros`,
pelo grep de 25/10/2026: `frontend/src/telas/Catalogo.tsx`, `Clientes.tsx`, `Convenios.tsx`, `Cupons.tsx` e `Sorteios.tsx`. O
grep é pelo nome do ajudante: outro formulário que foque o campo no mesmo gesto da recusa, com outro nome, também tem o irmão.
**Conserto:** o mesmo dos avisos, tela por tela, com o caso do 422 rodado com o amplificador (os casos de hoje passam sem
ele). Não toca no servidor.

**PARCIAL em 25/10/2026: as sete telas do `focarOPrimeiro`, nove formulários.** O grep acima procurava o texto
`focarOPrimeiro(locais)`, e deixou de fora as duas telas cujo ajudante recebe a ordem antes: `Pacotes.tsx` (o pacote e o item)
e `Unidades.tsx` (a seção e o cadastro) -- `grep -n "focarOPrimeiro(" frontend/src/telas/*.tsx` acha as sete. Em todas, o
formulário conta as recusas (`recusas`) e o foco vai num `useLayoutEffect` que lê o que está marcado agora: o campo chega ao
leitor de tela no mesmo desenho que traz o `aria-invalid` e a frase. DE LAYOUT, e não o `useEffect` dos avisos, pelo molde do
`AlterarDados.tsx` (o `focoDaRecusa`): com o `useEffect`, o amplificador mostrou o foco atrasado tirando das mãos de quem
clicou o "cadastrar como dependente" (o caso dos clientes) e chegando depois do `findByText` em cinco casos antigos. O
`Mensagens.tsx` segue no `useEffect`, que os casos dele cobrem.

**A prova** é um caso por formulário, "o foco chega ao campo recusado já com a marca, na recusa local e na do servidor (o
D602)", com o ajudante novo `frontend/src/testes/anotarOsFocos.ts`: cada `focusin` anota o `aria-invalid` e o texto das
descrições NO INSTANTE DO FOCO, e por isso o foco adiantado cai sempre, com ou sem o amplificador (o `waitFor` do foco seguido
da descrição só caía com ele). No HEAD, os nove caíram (`invalido: null` no foco); consertado, passam com e sem
`AMPLIFICAR_EFEITOS_MS=50`. Os 18 mutantes -- cada chamada, a da recusa local e a do servidor, voltada ao foco no mesmo gesto,
uma por vez -- caíram na asserção dela, com o amplificador. Nos sete arquivos de teste, com o amplificador, o HEAD dá 33
vermelhos e o consertado 10, os dez também vermelhos no HEAD: o estoque e a remoção do item do pacote (abaixo), e oito
corridas dos próprios casos com os efeitos de chegada (o foco no título ou no primeiro campo, o alerta), que nada têm com a
recusa.

**Faltavam -- os formulários de um campo só**, que fazem `setErro(...)` e `campo.current?.focus()` no mesmo gesto, por um
varrimento de 25/10/2026 (`setErro`/`setErros` com um `.focus()` até três linhas abaixo, fora de `useLayoutEffect`):
`Estoque.tsx` (o lançar, linhas 264 e 289: o caso "a saída maior que o saldo" cai com o amplificador), `Etiquetas.tsx` (860),
`Pesquisas.tsx` (o título, 1593, e a opção, 1905) e o informar da venda do `Sorteios.tsx` (1038 e 1062). O varrimento é pelo
formato, e não prova que não haja outro. **A mesma forma, no título:** remover um serviço do pacote leva o foco ao título antes
de o aviso estar na descrição dele (o caso "confirmar tira o serviço" do `PacotesLigado.teste.tsx` cai com o amplificador, no
HEAD também). **Conserto:** o mesmo, com o caso que anota o foco.

**CORRIGIDO em 25/10/2026: os irmãos.** O varrimento de três linhas deixava de fora o que estava cinco abaixo; refeito até
oito linhas, com `setErro`, `setErros`, `setRecusa`, `setPerguntando`, `setAviso` e `setFalha`, mais o grep de todo ajudante
`focar*` das telas, achou, além dos já listados: a recusa local das etiquetas (846), os três formulários dos banners
(`Banners.tsx`, 1098, 1208 e 1309 -- a do servidor já esperava, pelo `useFocoDepoisDoEnvio`), os três dos arquivos
(`CadastroDeArquivos.tsx`, o enviar, o editar e o trocar), os filtros de código de `Devedores.tsx` e de `VendasRealizadas.tsx`,
a pergunta nova das pesquisas (o `focarPrimeiroErro`, outro nome) e, no informar do sorteio, o botão que recebia o foco ainda
dizendo "informar", antes de virar "confirmar: informar a venda ..." (pelo Enter no campo; pelo clique, ele já tinha o foco). O
`FidelidadeDoCliente.tsx` (1106) entrou no varrimento e não é recusa: leva o foco ao título quando o cancelar dá certo.

O conserto segue o molde de cada tela: o contador com o `useLayoutEffect` (estoque, etiquetas, os três formulários das
pesquisas, os dois filtros de código), um objeto `foco` com o alvo, no informar do sorteio (o campo ou o botão, o molde do
`focoDaRecusa` do `AlterarDados`), o `focarDepois` que os banners e os arquivos já tinham para a recusa do servidor, agora
também na local, e, no pacote, o `aoFocarOTitulo` conta e um efeito de layout foca o título. **A prova:** 15 casos novos com o
`anotarOsFocos` (que passou a anotar também o nome do controle, para o botão do sorteio), todos vermelhos no HEAD -- `invalido:
null` ou a descrição vazia no foco --, verdes com e sem o amplificador; os 22 mutantes, cada chamada voltada ao foco no mesmo
gesto, uma por vez, caíram na asserção dela, com o amplificador. Nos nove arquivos de teste, com o amplificador, o HEAD dá 22
vermelhos e o consertado 5, todos corridas dos casos com os efeitos de chegada, que nada têm com a recusa: a página do catálogo,
três do receber dos devedores e o "desativar com o formulário do item aberto" dos pacotes -- este oscila no HEAD (um verde e
dois vermelhos em três rodadas) e cai sempre no consertado: o clique no "desativar" corre contra o foco de chegada do
formulário do item, que o `findByRole` não espera.

**O que fica sem caso:** no informar do sorteio, a recusa que não é do campo leva o foco ao botão; o mutante que a volta ao
mesmo gesto volta junto a do campo, e é ela que o derruba. **E o limite:** o varrimento é pelo formato e pelo nome do
ajudante; um formulário que foque o campo recusado no mesmo gesto por outro caminho não aparece nele. O `Mensagens.tsx` segue
no `useEffect`, que os casos dele cobrem.

### D603 — A lista dos arquivos mostra "nenhum" com o total acima de zero quando a página deixa de existir depois de desativar — REGISTRADO em 25/10/2026 — CORRIGIDO em 08/11/2026

**Achado na verificação da fatia 38 (MENSAGENS-TELA)**, pelo irmão do defeito consertado nos avisos. O servidor dos arquivos
pagina à mão (`module/ISConfiguracao/src/Entity/ArquivoRepository.php`, o `setFirstResult` da listagem da API) e devolve a
página pedida, mesmo quando ela já não existe -- ao contrário do `AdapterQuery` do catálogo, que devolve a última. A tela
(`frontend/src/telas/ArquivosLigado.tsx`) relê a lista depois de cada escrita, com a mesma página: quem desativa o último
arquivo ativo da página 3 volta a uma página vazia, com o total acima de zero no título, "Nenhum arquivo" no corpo e o
"anterior" pulando uma página (`Arquivos.tsx`, o `Math.min(listagem.pagina, paginas)` com a nota "ver o catálogo").

**Consertado nos avisos** (`frontend/src/telas/MensagensLigado.tsx`): a página vazia acima da 1 não vai à tela, e o invólucro
pede a última que existe, pelo total do envelope (o molde das comandas, `ComandasLigada.tsx`). O caso
"recua para a última que existe" do `MensagensLigado.contrato.teste.tsx` fica vermelho sem o recuo.

**Fica com o defeito:** a lista dos arquivos. O grep de 25/10/2026 por `setFirstResult` em `module/*/src/Entity/` acha só o
`ArquivoRepository` e o `MensagemRepository` (o `VendaRepository` só o cita num comentário). **Conserto:** o mesmo dos avisos,
no `ArquivosLigado.tsx`, com o caso do contrato. Não toca no servidor.

**CORRIGIDO em 08/11/2026 (a fatia 123, ARQUIVOS-NA-TELA-NOVA).** O conserto dos avisos, no `ArquivosLigado.tsx`: a página
vazia acima da 1 não vai à tela, e o invólucro pede `Math.min(última que existe pelo total do envelope, a pedida − 1)` -- a de
antes fica na tela, "atualizando", até a nova chegar. O servidor não mudou. Os casos, no `ArquivosLigado.contrato.teste.tsx`:
"recua para a última que existe, e a página vazia não vai à tela", "recua uma página de cada vez quando a última que existe ainda
está acima" e "a página 1 vazia é a lista vazia, e não recua"; verdes também com `AMPLIFICAR_EFEITOS_MS=50`. Mutantes: sem o recuo
(F1) e o recuo direto à última pelo total (F2), vermelhos. O `Math.min` do `Arquivos.tsx` fica, como no catálogo. Não visto num
navegador. CORRIGIDO.

### D604 — A agenda nova não mostrava o mural: a unidade que passasse para ela deixava de ver os avisos do topo da agenda — REGISTRADO em 25/10/2026 — PARCIAL em 25/10/2026: a faixa acima da grade, pela mesma consulta da agenda antiga; falta o navegador — CORRIGIDO em 25/10/2026 (vista no navegador)

**O M11 de `docs/f3-telas-restantes/tela-mensagens.md`**, que não tinha número. O mural de avisos da equipe só era impresso
pela agenda antiga (`module/ISCadastro/src/Controller/AgendasController.php`, o `indexAction`, e o partial dele); a agenda nova
(`frontend/src/telas/AgendaLigada.tsx`, a Home da tela nova) não o pedia, e a API não o servia. Com o corte por unidade, quem
passasse para a agenda nova deixava de receber os avisos que ~364 pessoas leem ao abrir a agenda -- e a tela nova dos avisos
(a fatia 38) escreveria para ninguém nessa unidade. A resposta 210 (a) do dono: "numa faixa acima da grade".

**O conserto** (a fatia 39, MENSAGENS-MURAL; a F3 do desenho): `GET /adm/api/agendas/avisos`
(`module/ISCadastro/src/Controller/Adm/AgendasController.php`, `avisosAction`), com a guarda da grade
(`agendas/visualizar/ler`), pela MESMA consulta da agenda antiga (`MensagemRepository::listagemIndexUsuarios`, agora com o
desempate pelo id e o dia de hoje lido uma vez pelo controller): os ativos de hoje -- ou sem dia -- que chegam à unidade e ao
grupo DA SESSÃO, sem parâmetro nenhum vindo de fora. O aviso vai como texto, com a cor pelo nome (`MensagemJson::doMural`). Na
tela, a peça `AvisosDoDia` (a `PreviaDoAviso` da tela dos avisos, um por linha), acima da grade, só nos dias com aviso, pedida
uma vez ao abrir, antes da grade, e na virada do dia -- e não a cada minuto, com a grade --; recolhível, por pessoa, até o dia
seguinte do servidor; e a
altura dela sai do `max-height` da grade (o D322 não piora). Com a grade noutro dia, a faixa diz que os avisos são os de hoje
(a decisão 10 do desenho).

**Provado** nos dois bancos: `AgendaAvisosTest` (o 401, o 403, os de hoje da unidade e do grupo da sessão, a outra sessão, a
query ignorada, o aviso como texto e a cor crua como a neutra, o desempate, e as duas agendas pela mesma consulta); pelo HTTP,
os casos 32 a 34 do `scripts/ensaiar-mensagens-adm-api.php` e a rota nova no `ensaiar-permissao-adm-api.php`. No front, o
`AgendaLigada.mural.teste.tsx`, o `AvisosDoDia.teste.tsx` e o `agendas.teste.ts`. Mutantes, cada um sozinho: ver a entrada de
25/10/2026 da fatia 39 no `docs/roadmap.md`.

**Fica de fora:** a F4 do desenho (o "usar de novo" ou o de/até), que espera a pergunta 3; e o navegador, que é do orquestrador.

**A verificação da fatia 39** (25/10/2026, ainda antes do commit) achou oito defeitos distintos, todos consertados: (1) o
"recolhido" era guardado por navegador, e no computador do balcão quem recolhesse escondia o aviso de todos que entrassem
depois no mesmo dia -- a chave agora leva o usuário e a unidade da sessão (o `quem` que o `ConsoleLigado` passa ao `Console`, e
ele à `AgendaLigada`), e ao recolher as marcas de outros dias saem; (2) o mural era pedido DEPOIS da grade, e a sessão do PHP
põe os dois pedidos em fila: a faixa chegava acima da grade já pintada e a empurrava para baixo -- agora sai antes, e a medida
da faixa é feita antes de pintar (`useLayoutEffect`); (3) a falha do mural era muda, igual a "hoje não há aviso", e só era
pedida de novo com a recarga da grade de hoje -- agora uma linha diz que os avisos não chegaram, e o pedido vai de novo a cada
minuto com a aba à vista; (4) com o relógio do navegador adiantado, a virada trazia o dia de ontem e ele ficava o dia todo com
a grade noutro dia -- agora a virada que traz o mesmo dia pede de novo a cada minuto, por até meia hora; (5) e (6) dois casos
que não prendiam o que diziam (o vão da coluna na altura da faixa, e a altura que volta a zero quando a faixa sai); (7) a
prévia dos dois avisos na ordem que o servidor nunca devolve; (8) um comentário de teste que prometia casos inexistentes.

**Resta (o porquê do PARCIAL):** o navegador -- a altura real da grade com a faixa a 375 e a 1.280 px (o lado D322 do conserto),
os dois temas e o contraste da faixa e da linha da falha, o encaixe da prévia na faixa, e o salto da grade, que agora só
acontece quando o mural chega depois dela (o que falhou e veio, a virada). O vitest só confere que a regra CSS existe. Ao
passar no navegador, acrescente a marca CORRIGIDO no fim do título.

**Visto no navegador em 25/10/2026**, contra o banco de contrato, como um administrador temporário, com dois avisos de hoje semeados pela API (um com `<b>` no texto), a 375 px e a 1.280 px e nos dois temas: a faixa acima da grade, os dois avisos na ordem do servidor, a cor pelo nome e o `<b>` como texto; o pedido dos avisos saindo antes do da grade; a altura da grade descontando a faixa (`--altura-da-faixa` de 182 px no celular; a 1.280x800 a grade termina em 777 px, sem rolar a página); o "recolher até amanhã" com o foco no "mostrar", a marca guardada por pessoa e unidade e a grade de volta à altura toda; e, com a grade no dia seguinte, a frase "São os avisos de hoje ...: o mural não muda com o dia da agenda", sem pedir o mural de novo. Nenhum defeito. A 375 px a grade ainda passa da dobra, como antes da faixa: o cabeçalho da agenda quebra em mais linhas que os 10rem da conta -- não é desta fatia.

### D605 — O endereço anexado às notificações sai sem checagem: `javascript:` vira `href` na tela da equipe e segue nas mensagens — REGISTRADO em 25/10/2026 — CORRIGIDO em 25/10/2026: o link da campanha de WhatsApp aparado e só `http(s)`, e o `href` da lista só do que abre

**Achado no desenho da tela das campanhas** (a fatia 40 da fila das telas que faltam, NOTIFICACOES-DESENHO;
`docs/f3-telas-restantes/tela-notificacoes.md`, o N1). O formulário da campanha (Marketing › Notificações) tem o campo
"link" com `maxLength` de 100 só no HTML (`module/ISMarketing/src/Form/NotificacaoEnvio.php:64`), e o servidor grava o que
vier (`module/ISMarketing/src/Service/NotificacaoService.php:42`), numa coluna `text` (`mysql.sql:1037`). Esse texto vai a
três lugares:

- **à lista da equipe**, como `<a href="..." target="_blank">` com `escapeHtmlAttr`
  (`module/ISMarketing/view/is-marketing/notificacoes/index.phtml:146`): o escape fecha o atributo, mas não barra
  `javascript:` -- quem escreve na campanha deixa um link que roda código no navegador de quem clicar nele na lista
  (os colegas da unidade e os administradores);
- **ao fim da mensagem de WhatsApp** (o D300; `module/ISApi/src/Service/CronJobService.php:1366-1368`), cru;
- **ao push**, como o endereço que o aplicativo abre (`CronJobService.php:1346`).

Quem escreve é quem tem o privilégio de escrever nas campanhas: 105 gerentes e 1 caixa ativos, mais os 10 administradores
(SELECT agregado na `franquiabv_dev`). Na cópia, **0 links começam por `javascript:`**; dos 576 links de push, 21 não começam
por `http://` nem `https://` (3 começam por `www.`, e o aplicativo recebe um endereço sem esquema); os 34 de WhatsApp começam
todos por `http`. Lido no código; o clique não foi reproduzido no navegador.

**O conserto** é o da F0 do desenho (desde a 40b, de 25/10/2026, a fatia **92a** da fila, o N1 do desenho): o link opcional, aparado (8 dos 34 links de WhatsApp da cópia têm espaço nas pontas),
depois `http://` ou `https://`, sem espaço no meio, até 255, pela `RegraDaCampanha` nas duas portas (a tela antiga e a API
nova) **na campanha de WhatsApp**; e a lista antiga imprime o `href` só do que começa por `http://` ou `https://`, em qualquer
canal, e o resto como texto. Os links que já estão gravados não mudam; a lista deixa de fazer `href` dos outros.

**O que fica de fora, e por quê (a verificação do desenho, 25/10/2026):** o link do **push** continua gravado como vier, e
chega ao aplicativo como hoje. O push é do OneSignal, que o dono mandou deixar de lado ("por hora, ignorar tudo do
onesignal, visagismo e sms"), e 20 dos 576 links de push não começam por `http` mesmo aparados (10 de 2021, 8 de 2022 -- 3
com `www.` -- e 2 de 2026): recusar ali mudaria o que as unidades fazem pelo push. Quando o D461 for resolvido, a regra do
link vale também para ele, e este débito fecha; até lá, depois da F0, ele fica PARCIAL, com o push dito no corpo.

**A 254 (c) muda o que fecha este débito (25/10/2026, a 40b).** O push de campanha acaba nas duas telas: nenhuma campanha
de push nova nasce, e o link de push que sobra é o das já gravadas. A condição de cima ("quando o D461 for resolvido")
deixa de ser a que fecha: **o débito fecha com a 92a** -- a regra do link na campanha de WhatsApp, nas duas portas, e o
`href` da lista antiga só do `http(s)` em todo canal, o que cobre também os links de push gravados. Fica escrito no
fechamento que a de push agendada antes da troca sai com o link que já tem, como hoje (na cópia, 0 agendadas e 0 links
começando por `javascript:`; quantas agendadas há na produção, a seção 8 do `scripts/medir-texto-das-campanhas.sql` conta).

**CORRIGIDO em 25/10/2026 (a fatia 92a, NOTIFICACOES-ERP-ANTIGO).** O link da campanha -- só de WhatsApp desde a 254 (c) --
é aparado e aceito só quando começa por `http://` ou `https://`, em qualquer caixa, sem espaço no meio e com até 255
caracteres (`RegraDaCampanha::linkSeguro` e `doFormularioDoErp`, "- Link: comece por http:// ou https://..."); o
`javascript:`, o `data:`, o `www.` sem esquema e o `ftp://` são recusados. A lista antiga faz o `href` só pelo
`RegraDaCampanha::hrefDaLista`, em todo canal -- o link gravado aparado, se é de abrir; senão o título como texto --, e o
`target="_blank"` ganhou o `rel="noopener noreferrer"`. **O espaço é o do Unicode também** (a verificação da 92a): sem
o `/u`, o `\S` contava os bytes do NBSP, do U+2028, do U+200B, do U+0085 e do U+3000 como letra, e o link com a quebra
de linha no meio passava e saía assim no fim da mensagem; agora o meio recusa o espaço, o separador, o controle e o
invisível do Unicode, as pontas são aparadas deles (`apararLink`), e o texto que não é UTF-8 é recusado
(`RegrasDoMarketingTest::testOEspacoDoUnicodeNoLink`). E o controller grava o que a regra aparou, provado pela porta
(`CampanhaAudienciaTest::testOSalvarGravaOsCamposAparados`, na criação e na edição). Os links gravados não mudam no
banco; a de push agendada antes da troca sai com o que tem, como hoje (nada do OneSignal muda). Provado em
`RegrasDoMarketingTest` e, pela lista desenhada
pelo `ViewRenderer`, em `WhatsAppQueFalhaTest::testAListaSoFazLinkDoHttpEOPushSoSeCancela` (o atributo decodificado: o
`escapeHtmlAttr` escreve `javascript&#x3A;`, e o texto cru não acharia o `href`), nos dois bancos, com os mutantes
vermelhos. **A API nova** (a 92d) nasce chamando a mesma `RegraDaCampanha::linkSeguro`, pelo desenho ("a porta nova e a
antiga recusam as mesmas regras"): hoje ela não existe, e nenhuma porta grava link sem a regra. O clique no navegador não
foi feito.

### D606 — LGPD dos envios de marketing: o aceite de quem os recebe, e por quanto tempo fica o rol de quem já recebeu — REGISTRADO em 25/10/2026 — ANOTADO em 25/10/2026

**Nasce da verificação do desenho da tela das campanhas** (a fatia 40 da fila das telas que faltam, NOTIFICACOES-DESENHO;
`docs/f3-telas-restantes/tela-notificacoes.md`). O desenho deixa a LGPD das campanhas de fora, pela ordem do dono ("as
questões da LGPD como débito futuro"), e a frase só existia nele: este débito a anota, sem pergunta nova e sem decisão
tomada aqui, como o D583 fez com o rastro dos arquivos. O que fica em aberto:

- **O consentimento de marketing por WhatsApp.** O cliente recebe campanha se `clientes.marketing = 1`
  (`NotificacaoService::audiencia`, e de novo na hora do envio, `RegraDaCampanha::recebe`). Não há registro de quando nem
  por onde ele consentiu, nem do texto que aceitou; 175 dos 239.228 clientes ativos da cópia desligaram o marketing. O
  campo é um só para o WhatsApp e o push.
- **O prazo de guarda de quem recebeu.** `notificacoes_clientes` guarda uma linha por envio -- o cliente, a campanha e a
  hora --, 875.435 linhas em 951 campanhas desde 2020, e nada as apaga. A coluna `notificacoes.clientes_ids` guarda a
  lista de cada campanha (os ids que faltam receber, e que a rotina vai tirando), sem prazo também.
- **O acesso do titular.** O cliente não vê quais campanhas recebeu, e não há como pedir a exclusão dessas linhas.

**Onde mora:** `module/ISMarketing/src/Entity/NotificacaoCliente.php`, `module/ISMarketing/src/Service/NotificacaoService.php`
(`audiencia`, `enviarCampanha`), as tabelas `notificacoes` e `notificacoes_clientes` (`mysql.sql:1028-1070`). **O que o
desenho já faz a favor da LGPD:** a tela nova não manda ao navegador a lista de quem recebe (nem ids, nem nomes; o D297), e
o detalhe mostra só os números.

### D607 — SMS fora, a mando do dono: 13 mensagens de 2020 a 2023, e o tipo que nenhuma tela cria — REGISTRADO em 25/10/2026 — ANOTADO em 25/10/2026

**Nasce da verificação do desenho da tela das campanhas** (a fatia 40, NOTIFICACOES-DESENHO). O dono mandou: "por hora,
ignorar tudo do onesignal, visagismo e sms. Anote como debito tecnico e sera resolvido no futuro". O OneSignal é o D461 e
o visagismo é o D35; o SMS não tinha débito. Este o anota, sem decisão tomada aqui.

O que existe: o tipo `sms` saiu das listas fechadas (`RegraDaCampanha::TIPOS` só tem `push` e `whatsapp`,
`module/ISMarketing/src/Regra/RegraDaCampanha.php:20-21`, o D297), então nenhuma tela cria campanha de SMS; o
`NotificacaoService::salvar` ainda trata o `sms` (o link fica nulo, `NotificacaoService.php:42`). Na cópia, **13 campanhas
de SMS**, de 2020 a 2023: 6 enviadas (a última em 26/04/2021) e 7 canceladas (a última criada em 07/07/2023). A lista antiga
as mostra (`NotificacaoRepository::listagemIndex` não filtra o tipo por padrão), e o desenho da tela nova as lista só para
ler, com o canal "SMS". **O que fica para o futuro:** se o SMS volta (com que provedor), ou se as 13 saem da lista.

### D608 — O interruptor do canal em off não segura o envio de marketing: a chave que ficou salva basta — REGISTRADO em 25/10/2026 — CORRIGIDO em 25/10/2026: o `mandaWhatsApp()` no `envioDeCampanha`

**Achado na verificação do desenho da tela das campanhas** (a fatia 40, NOTIFICACOES-DESENHO;
`docs/f3-telas-restantes/tela-notificacoes.md`). O `CronJobService::envioDeCampanha`
(`module/ISApi/src/Service/CronJobService.php:1352-1359`) só recusa o WhatsApp quando a unidade está fechada
(`getStatus()`, o D351 e a resposta 132) e chama o provedor com a chave e a instância gravadas na unidade
(`criarClienteWhatsAppUnidade`, `:1645-1651`), sem olhar o `whatsapp_ativo`. As outras rotinas de WhatsApp olham:
`Unidade::mandaWhatsApp()` -- a unidade ativa **e** o WhatsApp ligado -- no parabéns, na pesquisa e no "sentimos sua falta"
(`CronJobService.php:458`, `:503`, `:543`, `:955`) e na fidelidade (`module/ISCadastro/src/Service/FidelidadeService.php:216`);
o `AgendaService:112`, o `VendaPagamentoService:730` e `:846` e o `ClienteService:723` olham o `getWhatsappAtivo()`. O
interruptor e a credencial são campos independentes do cadastro da unidade, e nada apaga a chave ao desligar.

O passo a passo: a unidade conectada desliga o WhatsApp em Unidades, e a chave e a instância ficam gravadas; o
`GET /adm/api/whatsapp` diz "desligado" (`RegraDaConexaoWhatsApp::doBanco`); a campanha agendada antes, ou depois, sai na
hora marcada pela instância, que segue de pé. Quem desligou para parar os envios é desmentido pela campanha -- e a tela nova
das campanhas diria "ninguém recebe". A resposta 178 do dono parte de que "quando o WhatsApp da unidade está desligado" a
mensagem não sai por ele. Na cópia, 1 campanha de WhatsApp de 2025 é de uma unidade hoje desligada (SELECT agregado); se a
credencial continua gravada não se mede, porque a base endurecida zera as chaves.

**O conserto** está na F0 do desenho (desde a 40b, de 25/10/2026, a fatia **92a** da fila; o mutante "o `getStatus()` no
lugar do `mandaWhatsApp()`" está lá): `mandaWhatsApp()` no lugar de `getStatus()` no `envioDeCampanha`, com o destinatário
contado como "não recebeu", como na unidade fechada, e o caso no `WhatsAppQueFalhaTest` com o cliente de WhatsApp que anota
(nenhum teste roda a `marketingEnvios`).

**CORRIGIDO em 25/10/2026 (a fatia 92a, NOTIFICACOES-ERP-ANTIGO).** O `envioDeCampanha` pergunta
`$notificacao->getUnidade()->mandaWhatsApp()` -- a unidade ativa **e** o WhatsApp ligado --, no lugar do `getStatus()`: a
campanha da unidade com o interruptor desligado e a credencial gravada não chama o provedor, e cada destinatário conta
como "não recebeu", como o da unidade fechada (o D351). Provado em
`WhatsAppQueFalhaTest::testAUnidadeComOWhatsAppDesligadoNaoChamaOProvedor` (a chave e a instância inventadas gravadas na
unidade; o provedor de mentira não anota nada; ligada de novo, a mesma unidade manda), nos dois bancos, com o mutante (o
`getStatus()` de volta) vermelho. As unidades das fixtures nascem com o WhatsApp desligado: os casos de campanha que
mandam (`WhatsAppQueFalhaTest`, `D351UnidadeDesativadaTest`) passaram a ligá-lo antes. Nenhum teste roda a
`marketingEnvios` dentro do horário: o único que a chama
(`WhatsAppQueFalhaTest::testARotinaDasCampanhasRecusaForaDoHorarioComAFraseNova`, de antes da fatia) segura a trava
antes e só usa instantes fora da janela, e recusa antes de ler campanha, com o WhatsApp de mentira e o HTTP do push que
lança; os casos do D608 chamam o `enviarCampanha` com o `$enviar` do `envioDeCampanha`, em processo, e o cliente de
WhatsApp de mentira (`usarFabricaDeWhatsApp`), que anota. O "mandar para mim primeiro" (a 261 (b), na 92d) usa a mesma pergunta.

### D609 — A campanha atrasada sai na primeira rodada que vier, sem limite de atraso: a mensagem de sábado vai na segunda — REGISTRADO em 25/10/2026; a pergunta foi ao lote — DECIDIDO em 25/10/2026 (262 b), EM ABERTO: o prazo na 92b — PARCIAL em 26/10/2026: o prazo na seleção e no assumir, com a exceção da fila, e o "Atrasada" da lista antiga; faltam a tela nova (92c e 92e) e o número do dono (a 267) — PARCIAL em 26/10/2026: o número do dono (267 a) e a situação na API (92c); falta a tela nova (92e) — PARCIAL em 26/10/2026: a 268 (a) registrada; falta a tela nova (92e) — CORRIGIDO em 26/10/2026: a tela nova (92e)

**Achado na verificação do desenho da tela das campanhas** (a fatia 40, NOTIFICACOES-DESENHO). O
`NotificacaoRepository::selecionarEnviosCronJob` pega toda campanha `agendado` com `data <= agora`, e o `assumirEnvio`
também (`module/ISMarketing/src/Entity/NotificacaoRepository.php:62-73`, `:92-99`): não há limite inferior. Com a rotina
parada -- queda, manutenção, o deploy, o corte para o sistema novo --, a campanha que passou da hora sai na primeira rodada
das 9h às 20h, com o texto como está, dias depois.

Medido no `franquiabv_dev`, só com SELECT agregado: em 2025, 21 das 384 campanhas de WhatsApp enviadas começaram a sair uma
hora ou mais depois da hora marcada, e **7 saíram 24 h ou mais depois, a maior 114 h**; no push de 2024, 16 com um dia ou
mais, a maior 354 h; em 2026, a maior demora foi de 1 h. E uma campanha de WhatsApp que começou a sair em 18/12/2025 parou
às 11:40 e voltou sozinha em 22/12/2025 às 11:41, com a rede inteira sem envio nesses quatro dias.

**Quanto atraso ainda vale mandar é do dono**: a pergunta foi ao lote em 25/10/2026 ("a campanha atrasada: até quando ela
ainda sai?"). Até a resposta, a tela nova diz o risco na campanha atrasada e põe o cancelar à vista; a rotina não muda. A
mesma resposta vale para as campanhas agendadas durante a troca de sistema, que o `docs/procedimento-de-corte.md` ainda não
trata.

**DECIDIDO em 25/10/2026 pela 262 (b)** (*"Passado um prazo, a rotina não a manda: por exemplo, 3 horas;
ela fica 'atrasada' até alguém mudar a hora ou cancelar"*). O conserto: o `selecionarEnviosCronJob` e o `assumirEnvio`
deixam de pegar a `agendado` com a hora passada há mais do prazo (`RegraDaCampanha::PRAZO_DA_ATRASADA_HORAS`), e a tela
nova a mostra "atrasada", com mudar a hora e cancelar; a lista antiga diz "Atrasada". Nada muda no banco. **Voltou ao dono,
na mesma pergunta do lote de 25/10/2026** ("notificações: os números do limite por dia e do prazo da campanha atrasada"), o
número e um caso que o prazo pega sem querer: a campanha que esperou a vez enquanto a rotina mandava outras (em 2025, 1 das
13 de WhatsApp que começaram a sair 3 h ou mais depois da hora; nas outras 12 a rede inteira estava parada; em 2026,
nenhuma -- medido no `franquiabv_dev`, só SELECT agregado). Até a resposta, 3 horas, e a da fila sai. **Falta o código**:
a fatia 92b; e a linha do prazo no `docs/procedimento-de-corte-etapa-1.md`, que a 92b acrescenta (a volta ao código antigo
não tem o prazo).

**A regra, acertada na verificação da 40b** (o desenho, seção 2, e as decisões 20 a 22): o prazo conta do **começo** --
a hora marcada, dentro do horário das campanhas, ou as 9h seguintes, fora dele (a porta antiga aceita a hora pelo formato,
e a de 05:30 chegaria à primeira rodada já fora do prazo) --; vale para todo canal, e a de push que passou dele fica
"atrasada" só com o cancelar; e **a exceção da fila vale só enquanto a rotina não parou desde o começo do prazo** (nenhuma
hora do horário sem envio da rede). A primeira versão dizia "salvo se a rede mandou alguma mensagem depois da hora dela",
sem janela: a rotina que parasse no sábado e voltasse na segunda soltaria a campanha de sábado assim que a primeira
campanha de segunda saísse -- o caso que a 262 (b) segura. A pergunta que voltou ao dono diz agora as duas coisas. **No
corte**, a 92b acrescenta ao procedimento a leitura das campanhas na fila **na janela** (o SELECT está no desenho, seção
5), para o dono saber quantas o código novo vai segurar antes das 9h.

**PARCIAL em 26/10/2026 (a fatia 92b, NOTIFICACOES-ROTINA-DO-DIA).** O `NotificacaoRepository::selecionarEnviosCronJob` e o
`assumirEnvio` não pegam mais a `agendado` que passou do prazo desde o começo dele, para todo canal (a de push agendada
antes da troca também), salvo a da fila; a `enviando` é retomada sem prazo. As regras são puras, na
`RegraDaCampanha`: `PRAZO_DA_ATRASADA_HORAS` (3, o exemplo da 262 (b), com o comentário que cita a pergunta 267),
`comecoDoPrazo`, `foraDoPrazo`, `redeSemPararDesde` (os intervalos medidos só dentro do horário, a régua de uma hora) e
`saiNaRodada`; o horário das campanhas passou a ser um só (`CronJobService::foraDoHorarioDasCampanhas` pergunta à regra).
Os envios da rede (`NotificacaoRepository::enviosDaRedeDesde`) só são lidos quando há uma candidata fora do prazo, uma vez
por rodada; a coluna `notificacoes_clientes.data` não tem índice, e a leitura de um dia cheio leva 0,14 s na cópia (873.642
linhas varridas). A lista antiga diz "Atrasada" pela mesma conta (`NotificacaoService::atrasadasDaLista`), com o que fazer
no selo (a de push, só cancelar). O relógio é injetável (`NotificacaoService::usarAgora`, e o `$agora` dos dois métodos do
repositório); a rotina passa o `agoraDasCampanhas` do teste, ou nada. A leitura das campanhas na fila entrou no
`docs/procedimento-de-corte-etapa-1.md` (o passo 7, antes da troca, e a linha das 09:00). Provado nos dois bancos pelo
`RegrasDoMarketingTest` (as fronteiras pelos minutos da constante) e pelo `CampanhaDoDiaTest` (a de fora do prazo não é
selecionada nem assumida, a de dentro é; a fila sai; a rotina que parou e voltou não a solta; a meia hora depois e duas
horas parada não a solta; o push também atrasa; a `enviando` é retomada; a de 05:30 conta das 9h; a lista diz "Atrasada"),
com os mutantes. **Falta**: a situação `atrasada`/`na fila` na API e na tela nova (a 92c e a 92e), e o número do dono (a
pergunta 267), que muda só a constante.

**A verificação da 92b (26/10/2026)** confirmou, no que acima se diz feito, três defeitos, tratados: (1) **a exceção da
fila aceitava a rede sem envio nenhum** quando o horário entre o começo do prazo e agora somava até uma hora -- a de
19:30 com a rotina parada desde as 19:30 saía na rodada das 9h05 do dia seguinte, com 13 h de atraso, o caso que a 262 (b)
segura; agora "a rede mandou" exige um envio, e a campanha marcada depois da última rodada do dia (das 19:51 às 20:00: o
crontab é de 10 em 10 minutos, e a das 20:00 já começa fora do horário) conta o prazo das 9h seguintes
(`RegraDaCampanha::ULTIMA_RODADA_DO_DIA`), para não depender da exceção; (2) **a leitura dos envios da rede não tinha
teto** -- a atrasada que ninguém cancela fica `agendado` para sempre (a de push de "Usuários" do D456 também), e cada
rodada e cada página da lista antiga liam todo o histórico desde o prazo dela, inteiro na memória (com um ano, de 120 mil
a 380 mil instantes a cada 10 minutos, perto do `memory_limit` de 512M; o fatal de memória não passa pelo `catch`, e
nenhuma campanha da rede sairia); a frase "só roda com uma candidata fora do prazo" acima não limitava nada. Agora o
`NotificacaoRepository::enviosDaRedeDesde` é um gerador em lotes de 500 instantes distintos, cada candidata lê do começo
do prazo dela, e a `redeSemPararDesde` para na primeira quebra sem pedir o resto: a atrasada esquecida lê um lote por
rodada (cada lote é uma varredura da coluna sem índice; o esquema não mudou); (3) **os testes não aguentavam outra
resposta da 267** (a fila escrita à mão, a frase do limite com o número fixo, os casos montados para trás das 16h):
agora derivam tudo das constantes, e a família ficou verde com 6 h e 400 (a (c) da 267), 1 h e 100, e 8 h e 150. E **a
atrasada segura o dia** -- os clientes dela no "mesmo cliente" e o total no limite, pela letra do desenho ("não
cancelada"), com a frase da recusa dizendo agora que pode ser uma atrasada e o que fazer; se deve segurar foi ao dono
(26/10/2026, "a campanha atrasada continua segurando o dia ..."). Provado no `CampanhaDoDiaTest` e no
`RegrasDoMarketingTest`, nos dois bancos, com os mutantes (a fila sem envio nenhum; o começo do prazo sem a última
rodada; a régua que lê tudo antes; a leitura de uma vez).

**A resposta 267 (a), 26/10/2026** (*"Os exemplos, e a campanha que espera a vez sai"*): 300 contatos por unidade e por
dia, 3 horas de prazo contadas da hora marcada (ou das 9h, para a marcada antes das 9h ou depois das 20h), e a campanha
que passou do prazo esperando a vez, com a rotina mandando outras sem parar desde a hora dela, sai. É o que a 92b fez:
nenhum código mudou pela resposta, só o comentário das duas constantes (`RegraDaCampanha::PRAZO_DA_ATRASADA_HORAS` e
`::LIMITE_POR_DIA`). **A 268 continua aberta** (a atrasada segura o dia dos clientes dela e o limite da unidade?): até a
resposta, segura, como a 92b deixou (a recomendação (a)).

**PARCIAL em 26/10/2026 (a fatia 92c, NOTIFICACOES-API-LEITURA).** A `/adm/api/notificacoes` diz a situação de cada
campanha pela mesma conta da rotina (`RegraDaCampanha::situacao`, que chama o `saiNaRodada`; o
`NotificacaoService::atrasadasDaLista` da lista antiga passou a ler da mesma `NotificacaoService::situacoes`):
`atrasada` com `foraDoPrazo` (a que a rotina não manda mais), `atrasada` no prazo, e `na_fila` para a de fora do prazo
que esperou a vez com a rede mandando sem parar desde o começo do prazo dela; a de push atrasada só com o cancelar (a
decisão 20). Provado no `NotificacaoApiTest`, nos dois bancos (a lista, o detalhe e a lista antiga dizem o mesmo, e a
rodada pega a da fila), com os mutantes (a atrasada sem o prazo; o `foraDoPrazo` sem o prazo; o `podeEditar` da de
push). **Falta**: a tela nova (a 92e), que mostra o selo e as frases.

**A rede lida uma vez, na lista e na rodada (a verificação da 92c, 26/10/2026).** Depois da 92b a atrasada fora do prazo
não sai mais sozinha e fica, e cada uma lia os envios da rede desde o começo do prazo DELA
(`NotificacaoRepository::enviosDaRedeDesde`), numa coluna sem índice (`notificacoes_clientes.data`; na cópia, 875 mil
linhas, de 0,14 s a 0,8 s por varredura no MySQL): vinte atrasadas esquecidas eram vinte varreduras em cada página da lista
(a ordem da lista as põe na página 1) e em cada rodada de dez minutos da rotina. Agora a página e a rodada leem a rede
uma vez, de agora para trás, em janelas de um dia (`NotificacaoRepository::enviosDaRedeAntesDe`: só um dia na tabela
temporária do `DISTINCT`, e a janela vazia para, porque um dia sem envio tem 11 h do horário e é quebra para quem começa
antes dele), e guardam o trecho até a primeira quebra ou até o menor começo de prazo (`RegraDaCampanha::trechoDaRede`).
Cada campanha passa o trecho ao mesmo `saiNaRodada`: a conta não muda. Provado no `RegrasDoMarketingTest` (o trecho dá a
mesma fila que a leitura de cada uma, sobre envios sorteados em seis dias, e o mesmo último envio da `parada`) e no
`NotificacaoApiTest`, nos dois bancos (cinco atrasadas de hoje a 40 dias: uma leitura com a rede parada, duas com ela
mandando, e a mesma resposta da `selecionarEnviosCronJob`; a fila de três dias seguidos e o dia vazio que quebra; as
janelas, os lotes e as paradas da leitura), com os mutantes (a leitura por linha de volta, na lista e na rodada; a rodada
sem o trecho; só a primeira janela; a janela vazia que não para; a primeira sem o próprio agora; o trecho sem o mais
recente, sem parar na quebra, e até o começo mais novo). O `assumirEnvio` continua lendo desde o começo do prazo da campanha que vai sair (uma por vez, e só a que a
rodada escolheu). Nenhum índice novo: a leitura única bastou, e o esquema não mudou.

**A resposta 268 (a), 26/10/2026** (*"Segura o dia, como está"*): enquanto ninguém mudar a hora nem cancelar, os
clientes da atrasada e o total dela continuam reservados naquele dia. É o que a 92b fazia, e o "Contar" da 92c e as
escritas da 92d (a fatia NOTIFICACOES-API-ESCRITA, pelo mesmo `NotificacaoService::agendar`) seguem: nenhum código mudou
pela resposta, só os comentários do `NotificacaoRepository::jaRecebemNoDia` e do `NotificacaoService::contarAudiencia`, e o
desenho. A 92d deu à atrasada o que a tela fará com ela: o `PUT` da hora (com o momento por vir, e o dia novo pelas regras
do dia) e o cancelamento, e a pausa. **Falta**: a tela nova (a 92e), com o selo e as frases.

**CORRIGIDO em 26/10/2026 (a fatia 92e, NOTIFICACOES-TELA-LISTA).** A tela nova das campanhas diz a
atrasada pelas duas frases do desenho: no prazo, "A hora marcada passou e a campanha ainda não saiu: ela sai na próxima
rodada, entre 9h e 20h." (e a que espera a vez na fila, a mesma); fora do prazo, "Passaram mais de 3 horas da hora marcada:
a campanha não sai mais sozinha. Mude a hora ou cancele.", com o "mudar a hora" (o `PUT` do dia e da hora, pela lista dos
horários das opções) e o "cancelar" à vista; a de push, só "Cancele" e o cancelar (a decisão 20). O prazo na frase é a
constante `PRAZO_DA_ATRASADA_HORAS` de `frontend/src/api/notificacoes.ts`, que o `NotificacaoApiTest` compara com a da
regra. A pausada que se retoma com a hora passada pede o dia e a hora novos (o 422 `hora_passada`) antes da retomada.
Provado no `formularioDaNotificacao.teste.ts` e no `NotificacoesLigado.teste.tsx`, com os mutantes (a de push com o "mude
a hora", a hora nova na de push, a retomada sem a hora nova, o `PUT` sem o dia).

### D610 — Os arquivos que a rotina da Fidelização gravou na produção ficam na pasta de cache, e ninguém mais os escreve — REGISTRADO em 25/10/2026; a pergunta foi ao lote — DECIDIDO em 25/10/2026 (263 a): ficam

**Nasce da fatia 41 (FIDELIZACAO-SAI; a resposta 138 (a) do dono, o D306).** A rotina `relatoriosFidelizacaoAtualizar`
gravava, às 03:10, um arquivo JSON por unidade em `data/cache/relatorios/fidelizacao/<unidade>.json`, com o id e o nome de
cada colaborador do grupo 1, o período e dois números. Ela saiu do código; os arquivos que já gravou na produção ficam,
porque apagar arquivo pede a palavra do dono. Nenhuma tela os lê -- nunca leu: o controlador do item escondido não
existia. Na cópia local a pasta não existe (o `data/cache/` está no `.gitignore`); quantos são na produção não foi medido,
porque a produção está congelada.

**A pergunta foi ao lote** em 25/10/2026 ("os arquivos que a rotina da Fidelização gravou na produção ficam ou saem?"),
com a recomendação de ficarem, como as tabelas dos painéis que saíram em 13/10/2026 (a decisão do dono de 01/10). Até a
resposta, ficam, e nada no código nem no procedimento os toca.

**DECIDIDO em 25/10/2026 pela 263 (a)** (*"Ficam como estão: como as tabelas do Dash. Colaboradores e do
Fluxo, que você mandou guardar em 01/10."*). Os arquivos de `data/cache/relatorios/fidelizacao/` ficam na
produção; nada no código, no procedimento nem no corte os toca, e ninguém mais os escreve. Nada a fazer.

### D611 — O `podeAbrir` do Console confere o privilégio e o `quando`, e não o papel que o menu exige — REGISTRADO em 25/10/2026 — CORRIGIDO em 27/10/2026

**Achado pela especificação da parte comum dos relatórios** (`docs/f3-relatorios/spec-comum.md`, o D-a, de 02/10/2026), que
o deixava para registrar na C6, e reconferido no código pela fatia 41b (REL-ESPECIFICACOES-EM-DIA) em 25/10/2026. O
`podeAbrir` de `frontend/src/telas/Console.tsx:921-928` acha o item do menu pelo caminho e confere o `privilegio` e o
`quando`, mas não o `exige` (o papel: administrador, gerente). O menu confere os três (`podeVer`,
`frontend/src/telas/Moldura.tsx:93-114`; o `exige` em `:101-105`). Um atalho do Console para um destino guardado por papel
-- o Royalties (`menu-do-erp.ts:371-375`, `exige: "administrador"`), a Cota-parte v2 que vai nascer -- apareceria a quem não
o tem, e levaria a um 403.

**Hoje é latente:** o `podeAbrir` só é chamado com `RELATORIO_DOS_SERVICOS`, `CAMINHO_DO_PARCEIRO` e `CAMINHO_DOS_SERVICOS`
(`Console.tsx:1017-1025`, `:1185`), e nenhum dos três tem `exige`. Nenhuma pessoa vê hoje um atalho que não devia.

**O conserto é da C5 da parte comum** (a fatia 45 da fila das telas que faltam): o `podeAbrir` passa a delegar ao `podeVer`,
exportado da `Moldura.tsx`, com o caso "o gerente não abre um item com `exige: "administrador"`" no `Console.teste.tsx` e o
mutante M44 da especificação. Até lá, nenhum atalho novo do Console para um item com `exige`.

**CORRIGIDO em 27/10/2026, pela fatia 45 da fila das telas que faltam (REL-MENU-E-PARCEIRO; a C5 do
`docs/f3-relatorios/spec-comum.md`).** O `podeAbrir` do Console é agora uma linha que delega a
`podeAbrirNoMenu(caminho, privilegios, papeis, guardas)` (`frontend/src/telas/menu-do-erp.ts`), que acha o item pelo
caminho e devolve o `podeVer` dele -- o MESMO do menu, com o privilégio, o papel e a guarda da sessão. O `podeVer` não ficou
exportado da `Moldura.tsx`, como o desenho escrevia: mudou-se para o `menu-do-erp.ts`, e a `Moldura` o importa (exportar
função de um `.tsx` de componente é aviso do `only-export-components` do lint, e a regra continua uma só). As provas: o
`menu-do-erp.teste.ts` ("o papel vale": uma entrada de prova com `exige: "administrador"` e o privilégio que o gerente tem,
que a cópia de antes abria; e o Royalties do menu de verdade) e o `ConsoleAbreRelatorios.teste.tsx` (o Console pergunta ao
`podeAbrirNoMenu`, com os papéis da sessão, ao desenhar os dois atalhos do Painel). Os mutantes: o C5-M44 (a cópia de antes
de volta no Console) deixa vermelho o caso do Console; o C5-M44b (a cópia de antes dentro do `podeAbrirNoMenu`), o caso do
papel. O `abrirRelatorio` novo também confere o `podeAbrir`, mas isso NÃO tem caso (a verificação da 45): os dois únicos
chamadores de hoje, os atalhos do Painel, já conferem antes, com a mesma regra, e o mutante que tira o `if` de dentro fica
verde. Quem o prende é a primeira fatia cuja tela chame o `aoAbrir` (o "ver as vendas" dos Descontos e das Canceladas, o
link entre a Cota-parte e a v2), com o caso "o `aoAbrir` para um destino que a pessoa não abre não faz nada" -- o item 10
do bloco "Em dia" do `docs/f3-relatorios/spec-comum.md`. O que este débito pedia (o papel nos atalhos) está provado acima.

### D612 — A vigência da taxa de royalty não tem o portão que acha outro escritor das taxas — REGISTRADO em 25/10/2026 — CORRIGIDO em 08/11/2026: o portão dos escritores da alíquota

**Achado pela fatia 41b (REL-ESPECIFICACOES-EM-DIA), ao conferir a R3 do `docs/f3-relatorios/spec-royalties.md` contra o
commit que a fez (688922c, 13/10/2026, o D368).** A especificação pedia, além da tabela e do serviço, um portão no molde do
da vigência do horário (`scripts/conferir-escritores-do-horario.php`): os `setRoyltes*` só em `Unidade.php` e em
`UnidadeService.php`, `royalties_aliquotas` só com `INSERT` e só em `VigenciaDaAliquota`, e nenhum `UPDATE unidades` que toque
as taxas. O portão não foi feito. A porta de hoje está certa -- as duas telas que mudam a taxa passam pelo
`UnidadeService::update`, que grava a vigência, e o `D368VigenciaDaTaxaDeRoyaltyTest` prova as duas --, e o grep de
25/10/2026 não acha outro escritor de `royltes_*` no `module/` nem nos `scripts/` (o único `UPDATE unidades ... royltes_servico`
é o do `scripts/ensaiar-recusas-adm-api.php:1409`, que devolve o estado do ensaio).

**O risco** (reescrito na verificação da 41b, 25/10/2026: o texto dizia que a taxa de fora "valeria no mesmo dia" nos dois
casos, e que com linha o retrato lê a "desde sempre" -- e só metade é assim). Um escritor novo das taxas fora do
`UnidadeService` (uma tela, uma rotina, um roteiro) muda o cadastro sem a vigência, e o efeito depende de a unidade já ter
linha em `royalties_aliquotas`:

- **com linha** (a unidade já mudou de taxa pela porta): o retrato das 05:25 lê a vigência do ciclo -- a linha de maior
  `ciclo_inicial` que não passa dele (`RegraDosRoyalties::aliquotaDoCiclo`, pelo `FranquiaService::comAsTaxasDoCiclo`,
  `module/ISRelatorio/src/Service/FranquiaService.php:359-376`) -- e NUNCA vê a taxa de fora, enquanto o cadastro e a tela
  de Unidades a mostram: a fatura cobra uma taxa e a tela diz outra, para sempre;
- **sem linha** (nunca mudou pela porta): o retrato lê o cadastro, e a taxa de fora vale no mesmo dia, no ciclo em
  andamento; e o próximo `UnidadeService::update` lê o "antes" pelo cadastro já mudado
  (`VigenciaDaAliquota::taxasDaUnidade`, `module/ISRelatorio/src/Service/VigenciaDaAliquota.php:60-73`) e grava a taxa de
  fora como a "desde sempre" (`registrar`, `:128-135`), o que falsifica a história.

Nenhum portão fica vermelho em nenhum dos dois.

**O conserto:** o portão da especificação -- que ainda não existe: o `conferir-escritores-da-aliquota.php`, a nascer em
`scripts/` e no `conferir-tudo.sh` --, lido sem
comentários, com um mutante que acrescenta um `setRoyltesServico` fora da porta. Cabe em qualquer fatia que mexa nas taxas, ou
na R1 (a fatia 54).

#### CORRIGIDO em 08/11/2026 -- o portão (a fatia 126, PORTOES-E-COBERTURA)

`scripts/conferir-escritores-da-aliquota.php`, no `conferir-tudo.sh` e no `ci.yml` (o `conferir-lacuna-do-ci.sh` em dia), lê
`module/` e `scripts/` (982 `.php` e 106 `.sql`/`.sh` em 08/11/2026) sem os comentários e afirma: (1) os `setRoyltes*` só na
entidade e no `UnidadeService::update` -- e hoje nem lá: a porta escreve pelo hidratador; (2) o hidratador só escreve uma
unidade na porta -- `new` da entidade com dados só no `insert`, e `->hydrate(..., $unidade)` só no `update`; (3) o `update` lê as
taxas de antes antes do hidratador e registra a vigência depois do `flush`; (4) `royalties_aliquotas` só recebe o INSERT do
`inserir` da `VigenciaDaAliquota` -- nenhum UPDATE, DELETE, REPLACE ou TRUNCATE em texto, nem `->update(`/`->delete(` da conexão;
(5) nenhum UPDATE de `unidades` (SQL) ou da entidade (DQL) escreve uma coluna `royltes`, fora do `ensaiar-recusas-adm-api.php`
(o ensaio devolve a unidade como estava), nomeado com o motivo e com catraca. Não lê `tests/`: os casos plantam a taxa no banco
para montar o cenário. A heurística do hidratador é o nome `$unidade`, o que o código usa hoje.

**Falsificado**, cada um devolvido pelo sha1: A1, um `setRoyltesServico` no `definirEstado` (na porta, fora do `update`); A2, o
mesmo noutro serviço; A4, o `registrar` arrancado do `update`; A5, um `UPDATE unidades SET royltes_servico` num serviço; A6, um
`->delete('royalties_aliquotas', ...)` na própria vigência; A7, o UPDATE do ensaio sem a taxa (a exceção velha); A8, um `new` da
entidade com dados fora da porta; A9, o `registrar` antes do `flush` -- todos vermelhos; e o controle A3, o setter só num
comentário, verde.

#### A correção depois da verificação (08/11/2026, a mesma fatia)

A verificação achou dois furos de leitura, consertados no portão. (1) Ele lia só `module/` e `scripts/`, e o irmão PostgreSQL de
cada migração do passo 0 mora em `postgres/` (o `procedimento-de-corte.md`): um `UPDATE unidades SET royltes_servico` numa
migração de lá passava, enquanto o irmão MySQL, em `scripts/`, ficava vermelho. Agora lê também `postgres/`, `sql/`, `docker/` e os
`.sql` da raiz (o `mysql.sql`), com piso de 20 `.sql` em `postgres/` (28 no dia). (2) A regra 5 só via o UPDATE em texto: passa a
ver também a conexão (`->update('unidades', ['royltes_servico' => ...])`) e o QueryBuilder (`->update(Unidade::class, 'u')->set(
'u.royltesServico', ...)`, pela classe no `::class` ou num texto), até o fim do comando. Hoje nenhuma das duas formas existe.
Falsificado, cada um devolvido pelo sha1: A10 e A11, um UPDATE da taxa e um DELETE da vigência numa migração de `postgres/`; A16 e
A17, o UPDATE no `sql/sessoes.mysql.sql` e no `mysql.sql`; A12, A13 e A14, a conexão, o QueryBuilder pelo `Unidade::class` e pela
classe num texto, no `UnidadeRepository` -- todos vermelhos; e os controles A15 (a conexão mudando o `nome`) e A18 (o UPDATE num
comentário de SQL), verdes.

### D613 — O convite do formulário com a ligação entra no relatório de indicação como se fosse do aplicativo — REGISTRADO em 25/10/2026; a pergunta foi ao lote — DECIDIDO em 25/10/2026 (264 b), EM ABERTO: a I3 — CORRIGIDO em 03/11/2026 (264 b)

**Nasce da fatia 41b (REL-ESPECIFICACOES-EM-DIA).** Desde 10/10/2026 (a resposta 53 (c) do dono), o amigo indicado pela
etapa 6 da adesão nasce com a mesma ligação do convite do aplicativo -- a linha em `indicacoes` e o `clientes.indicacao` --
quando quem indica está ativo (`module/ISCadastro/src/Service/FidelidadeService.php:197-207`). A especificação do Programa de
Indicação (`docs/f3-relatorios/spec-indicacao.md`, de 02/10/2026) separava as portas pela ligação ("aplicativo é quem tem
`clientes.indicacao`") e pela marca da origem ("Indicação - N"): com a 53 (c), o amigo do formulário passa a contar como
aplicativo, e, enquanto não adere, contaria duas vezes (a linha e a marca). A dupla contagem a 41b corrigiu no texto (a I0
lê só o amigo com a marca e SEM a ligação); a porta da linha não tem como ser separada sem gravá-la: na adesão a marca vira
"Indicação", a mesma origem do convidado do aplicativo, e a `url` é igual nas duas.

**Medido na cópia** (`franquiabv_dev`, MySQL, 25/10/2026, só contagem): 183 amigos com a marca, 0 com a ligação; `indicacoes`
com 10 linhas, nenhuma de outubro. **Na produção, 0** até o corte: ela está congelada desde 23/08/2026, e o código da 53 (c)
só chega lá com a etapa 1. Se a coluna da porta entrar no mesmo passo 0 que leva o código, nenhuma linha fica sem porta.

**E o caminho de volta da I3 estava errado:** `DELETE FROM indicacoes WHERE porta = 'formulario'` apagaria, pela
`FK_CLIENTES_indicacao` (`ON DELETE CASCADE`, `mysql.sql:421`), os clientes amigos que apontam para essas linhas. A
especificação foi corrigida (a volta é só o `DROP COLUMN`); nada no código apaga `indicacoes`.

**A pergunta foi ao lote** em 25/10/2026 ("Programa de Indicação: o amigo do formulário com a ligação conta em qual porta?"),
a Q9 da fila das telas que faltam refeita depois da 53 (c), com a recomendação de gravar a porta daqui em diante, no mesmo
passo 0 do código. Até a resposta, a linha conta como "aplicativo", e a tela o diz no "Como se conta".

**Corrigido na verificação da 41b (25/10/2026).** A pergunta dizia que os 183 amigos antigos "já estão separados pela marca
deles". Não para sempre: a etapa 3 troca a marca por "Indicação" (`FidelidadeService::salvarPontuacaoCadastro`,
`module/ISCadastro/src/Service/FidelidadeService.php:404-413`, a troca na `:410`), e dos 183, 110 ainda têm `status = 0`
(medido na cópia, só contagem; 73 têm `status = 1`). Cada um que concluir o cadastro depois da tela sai dos "Convites >
Formulário" do mês em que foi criado (o `linhasDoFormulario` lê a marca na hora da consulta) e passa a contar como "origem"
nos novos. A pergunta foi reescrita no lote com isso, e a recomendação passou à (b), a que guarda o passado separado (os 183 viram
linhas de convite, escritas uma vez, sem comissão). E a especificação ganhou a
coluna `indicacao` no `programaFormulario`, que o filtro "sem a ligação" precisava, com o caso F6 e o mutante IND-M39.

**DECIDIDO em 25/10/2026 pela 264 (b)** (*"A (a), e os 183 antigos viram linhas de convite"*): a coluna
`indicacoes.porta`, gravada daqui em diante, e o passado escrito uma vez, sem comissão, pelo `INSERT ... SELECT` da mesma
migração da I3 -- nas cópias na fatia I3, e na produção no laço do passo 0 da janela, com o sistema parado e depois do
backup (lá, todo amigo do formulário que tiver a marca na hora, e não só os 183 da cópia). Onde, quando e como se prova:
o bloco "As respostas de 25/10/2026" no começo do `docs/f3-relatorios/spec-indicacao.md`. **Falta o código**: a I3.

**CORRIGIDO em 03/11/2026 na fatia 75 (REL-INDICACAO-PORTA, a I3).** A coluna `indicacoes.porta` (`aplicativo` ou `formulario`,
`NOT NULL` com o padrão `aplicativo`) pela `scripts/migracao-indicacao-porta.sql` e o irmão em `postgres/`, no laço do passo 0
(a linha `| 0 |` do `docs/procedimento-de-corte.md` e o ensaio geral), e no mesmo roteiro, logo depois do `ADD COLUMN`: as linhas
que já estavam lá ficam do aplicativo, menos a do formulário com a ligação cujo amigo ainda tem a marca de quem indicou (0 na
cópia), e o passado -- uma linha do formulário por marca "Indicação - <id>" de quem existe e sem linha, sem `clientes.indicacao`,
`data_venda`, `comissao`, `primeira_venda` nem `data_adesao`. Aplicada nas duas cópias, duas vezes (0,44 s e 0,21 s no MySQL,
0,47 s e 0,21 s no PostgreSQL): 183 linhas do formulário, 0 marcas sem linha, 0 de indicador inexistente; a regra da marca em
SQL e a do `MarcaDaIndicacao` dão o mesmo nas 119.518 origens com "ndica" das duas cópias. Toda criação de `Indicacao` grava a
sua porta (o portão novo `scripts/conferir-escritores-da-porta.php`); o relatório conta o formulário pelas linhas (o
`programaFormulario` e o `linhasDoFormulario` saíram), e a adesão do formulário desde a linha `indicacaoDoFormulario` de
`colunas_gravadas` (o passo próprio `scripts/colunas-gravadas-indicacao-do-formulario.sql`, no passo 7 da etapa 1 e no 7d do
corte). Em julho de 2026, os convites e a lista ficaram iguais nas duas cópias (o sha1 da lista, antes e depois), e o pedido dos
números caiu de 0,08 s para 0,003 s no MySQL. A lista do aplicativo e a tela antiga continuam mostrando só a linha que é a ligação
de um convite: o D689, ao dono. O caminho de volta é só o `DROP COLUMN porta`.

**Na correção da fatia (03/11/2026).** O mês antes da data da adesão do formulário (ou sem ela) soma em `adesoes.aplicativo` o
que ficou gravado da adesão do formulário -- a do amigo com a ligação da 53 (c), que contava como aplicativo até a porta existir --,
em vez de tirá-la do cartão; depois da data, cada porta a sua (`ProgramaDeIndicacaoJson::numeros`; o
`testComALinhaDaDataAAdesaoDoFormularioEASua`). O `UPDATE` da migração ganhou caso (o
`testAMigracaoDevolveAoFormularioALinhaDa53cPelaMarcaDeQuemConvidou`), e o "0 na produção" é inferido: a 53 (c) não chegou lá.
O contrato do aplicativo rodou nos dois bancos, 74 de 74, com os instantâneos sem mudar; o oráculo, pelo HTTP.

### D614 — No relatório de descontos, a linha do convênio com o desconto do balcão não tem origem decidida — REGISTRADO em 25/10/2026; a pergunta foi ao lote — DECIDIDO em 25/10/2026 (266 c), EM ABERTO: a D1 — PARCIAL em 28/10/2026: a conta e a API (a fatia 50); falta a tela (a 51) — CORRIGIDO em 28/10/2026: a tela (a fatia 51)

**Nasce da fatia 41b (REL-ESPECIFICACOES-EM-DIA).** Desde 14/10/2026 (a resposta 177 (c) do dono), o item do convênio em que
o balcão dá desconto grava o motivo "Convênio X; <o digitado>" e o valor dos dois descontos SOMADOS na linha
(`RegraDoItemDaComanda::motivoDoConvenioComOBalcao`, `module/ISVenda/src/Regra/RegraDoItemDaComanda.php:128-134`;
`VendaProdutoService.php:213-215`). A especificação do relatório de Descontos (`docs/f3-relatorios/spec-descontos.md`, de
02/10/2026) classifica a origem pelo prefixo: a linha inteira contaria como convênio, e o desconto dado à mão -- o que o dono
quis rastrear na pergunta 2 de 01/10/2026 -- sumiria dentro dele. **Nada no código divide o valor da linha** (corrigido na
verificação da 41b, 25/10/2026: o texto dizia que "o código já sabe separar"): o `temODescontoDoBalcaoNoConvenio`
(`:318-340`) só responde SE a linha tem desconto do balcão -- na linha com o separador, pelo texto (`:334-336`), sem conta.
Dividir seria uma conta nova, a do lançamento (`module/ISVenda/src/Service/VendaProdutoService.php:154-156`) refeita com a
porcentagem de HOJE: o banco não guarda a da época (`vendas` guarda só o id do convênio, e não há histórico de `convenios`,
`mysql.sql:618` e `:644` só têm `convenios` e `convenios_unidades`), e nada avisaria um convênio que mudou depois da venda.

**Medido na cópia** (25/10/2026, só contagem pelo HEX do prefixo): 0 linhas assim em venda finalizada (a cópia é de antes de
14/10/2026). **Na produção, 0** até o corte.

**A pergunta foi ao lote** em 25/10/2026 ("Descontos: a linha do convênio com o desconto do balcão conta como convênio, como
manual, ou dividida?"), com a recomendação de dividir. Até a resposta, a especificação manda contá-la como convênio e marcá-la
(`comBalcao`, o `temODescontoDoBalcaoNoConvenio` chamado só com o motivo), para a lista mostrar o motivo digitado, e os
números levam `convenioComBalcao`; o agrupamento por motivo fica só com o manual. O relatório ainda não existe (a fatia 50).
Na verificação da 41b (25/10/2026), a opção (c) da pergunta deixou de prometer que "a tela avisa" quando a porcentagem do
convênio mudou depois da venda: sem a porcentagem da época no banco, não há como.

**DECIDIDO em 25/10/2026 pela 266 (c)** (*"Dividida: a parte do convênio vai para convênio e o resto para
manual, pela porcentagem do convênio de hoje"*). A conta: a parte do convênio é `min(desconto, round(preço sem desconto *
porcentagem de hoje / 100))`, e o resto é do balcão, que entra no manual e no motivo digitado; sem a porcentagem (o
convênio que não existe mais), a linha fica inteira no convênio e conta à parte; o "Como se conta" diz que a divisão usa a
porcentagem de hoje. O item 7 do bloco do começo do `docs/f3-relatorios/spec-descontos.md` tem a conta, as funções da D1,
os casos e os mutantes; na verificação da 40b, ganhou a regra da lista filtrada (cada filtro escolhe uma parte da linha
dividida, e a soma da lista soma só aquela parte, para "a soma da lista filtrada é a linha do grupo" continuar valendo), e
o corpo da D1, da D2 e da D3 ganhou a marca "(40b: vale o item 7)" em cada linha que dizia o "enquanto isso". **Falta o
código**: o relatório ainda não existe (a fatia 50).

**PARCIAL em 28/10/2026 (a fatia 50, REL-DESCONTOS-CONTAS; a D1 e a D2 do `spec-descontos.md`).** A conta está no código, como
o item 7 a escreve: `ContasDosDescontos::partes` divide a linha "Convênio X; <o digitado>" pela porcentagem de HOJE do convênio
da venda (`ConvenioRepository::resumoPorId`, lida uma vez por período e só com os convênios das linhas divididas), com o teto
do desconto; sem a porcentagem, a linha fica inteira no convênio e conta em `divididas.semPorcentagem`. A origem da linha é
`dividida`; os totais continuam três, e ela conta no convênio e no manual; o agrupamento por motivo leva a parte do balcão pela
chave do digitado; cada filtro da lista escolhe uma parte, e a soma da lista soma só ela. Provado sem banco
(`RegrasDosDescontosTest`: 1798 e 1000, o teto, a porcentagem menor, a quantidade 2, o arredondamento, as três causas sem a
porcentagem) e nos dois bancos (`DescontosTest`: a linha pelo banco, e a porcentagem trocada dentro da transação), com os
mutantes do item 7 vermelhos (o `docs/roadmap.md`, a entrada da fatia 50). **Falta a tela** (a D3, a fatia 51): a lista com as
duas partes, a soma dizendo de que parte é, e o "Como se conta" com a frase do dono -- a divisão usa a porcentagem de hoje.
Na cópia continua 0 linha assim.

**CORRIGIDO em 28/10/2026 (a fatia 51, REL-DESCONTOS-TELA; a D3 do `spec-descontos.md`).** A tela fecha o que faltava: a lista
mostra a linha dividida com as duas partes ("convênio R$ 17,98 + balcão R$ 10,00") e o motivo inteiro; a soma da lista, com o
filtro que escolhe uma parte, diz "desconto do convênio" ou "desconto do balcão" (`telas/listaDosDescontos.ts`, `fraseDaSoma`);
"De onde veio" diz quantas linhas foram divididas, quanto foi a cada lado, que cada uma conta nas duas origens, e quantas
ficaram inteiras no convênio sem a porcentagem de hoje; e o "Como os números são contados" tem a frase do dono no lugar da do
"inteira no convênio" -- **"Se a porcentagem do convênio mudou depois da venda, a divisão usa a de hoje."** Provado pelos
casos da vista, do invólucro e do glossário, com os mutantes P51-X3 (a parte trocada), P51-X4 (a soma sem a parte), P51-X12 (a
nota sem a condição) e P51-X13 (a frase velha de volta) vermelhos (o `docs/roadmap.md`, a entrada da fatia 51). Não visto no
navegador nesta fatia (é do orquestrador), e a cópia continua sem linha assim.

### D615 — O amigo do formulário sem a ligação ainda perde quem indicou ao concluir o cadastro: a decisão 14 pela metade — REGISTRADO em 25/10/2026; a pergunta foi ao lote — DECIDIDO em 25/10/2026 (265 b), EM ABERTO: a I3 — CORRIGIDO em 03/11/2026 (265 b)

**Achado pela verificação da fatia 41b (REL-ESPECIFICACOES-EM-DIA).** A decisão 14 do dono, de 01/10/2026, diz: "O formulário
para de apagar quem indicou" (`docs/f3-relatorios.md:1643`). A I3 do `docs/f3-relatorios/spec-indicacao.md` a cumpria dando uma
linha de `indicacoes` a todo amigo da etapa 6. Desde 10/10/2026 (a 53 (c)) o amigo de quem indica ATIVO nasce com a ligação,
e ela guarda quem indicou. Mas dois amigos continuam sem ela: os 183 de antes da 53 e o de quem indica inativo, que pela
resposta 67 (c) nasce sem a ligação (`module/ISCadastro/src/Service/FidelidadeService.php:197`, o `indicaComALigacao`). Quando
um deles conclui o cadastro, a etapa 3 troca a origem "Indicação - N" por "Indicação" (`:404-413`, a troca na `:410`), e quem
indicou se perde -- o defeito que a decisão 14 mandou consertar.

**Por que não se decide aqui:** os dois modos de cumprir a decisão mudam regra ou dado. Parar a troca da origem estende os 10
pontos por compra do amigo para sempre (`module/ISVenda/src/Service/VendaPagamentoService.php:942`: eles saem enquanto a marca
existir), o que a decisão 14 não pediu. Dar uma linha de `indicacoes` sem o `clientes.indicacao` é dado novo, e uma regra
nova (a linha que não paga comissão, porque a comissão lê o `getIndicacao()` do amigo, `:573`).

**Medido na cópia** (`franquiabv_dev`, MySQL, 25/10/2026, só contagem): dos 183 amigos com a marca, todos sem a ligação, 110
têm `status = 0` e podem ainda concluir o cadastro; 1 tem quem indicou inativo hoje. **Na produção**, a 53 (c) ainda não
chegou (a etapa 1): lá todo amigo do formulário está sem a ligação.

**A pergunta foi ao lote** em 25/10/2026 ("Programa de Indicação: o amigo sem a ligação continua perdendo quem indicou quando
conclui o cadastro?"), com a recomendação de dar ao amigo de quem indica inativo, daqui em diante, a linha de convite sem a
ligação e sem comissão (a que a I3 já desenhou, lida pelo `doFormularioDoConvidado`); os 183 de antes seguem a resposta do
D613 (a (b) de lá é o INSERT deles). Até a resposta, nada muda no código; a especificação diz, na I3 e no bloco do começo, que o item 5 da I3 não está
cumprido para esses amigos.

**DECIDIDO em 25/10/2026 pela 265 (b)** (*"Guardar quem indicou numa linha de convite, sem comissão"*): o
amigo de quem indica inativo ganha, daqui em diante, a linha de `indicacoes` com a porta do formulário e sem o
`clientes.indicacao` -- a primeira compra não paga comissão (a 67 (c) continua) --, e a adesão dele grava a `data_adesao`
nela pelo `doFormularioDoConvidado`; os 183 antigos seguem a 264 (b) (o D613). Com as duas, a decisão 14 do dono fica
cumprida para todo amigo que tiver a marca na hora da janela e para os que nascerem depois. **O teto de 10 indicações por
dia muda a conta para continuar o mesmo** (a verificação da 40b): o `IndicacaoRepository::contarDoIndicadorNoDia` soma as
linhas do indicador no dia e os amigos com a marca sem a ligação criados no dia, e o amigo de quem indica inativo, com a
linha nova, entraria nas duas -- o inativo bateria no teto com 5 amigos; a segunda parcela ganha o `NOT EXISTS` da linha
pelo convidado. O bloco "As respostas de 25/10/2026" do `docs/f3-relatorios/spec-indicacao.md` tem os casos e os mutantes.
**Falta o código**: a I3.

**CORRIGIDO em 03/11/2026 na fatia 75 (REL-INDICACAO-PORTA, a I3).** O `FidelidadeService::indicarAmigo` grava a linha do
formulário para todo amigo novo, com a porta, e só chama `setIndicacao` quando a `RegraDaFidelidade::premiaQuemIndica` diz que
sim (o inativo e o cadastro do sistema ficam sem a ligação, e sem comissão: a 67 (c) continua). A etapa 3 do amigo sem a ligação
grava a `data_adesao` na linha dele (`IndicacaoRepository::doFormularioDoConvidado`), e a troca da origem para "Indicação"
continua: quem indicou fica na linha. A segunda parcela do `contarDoIndicadorNoDia` ganhou o `NOT EXISTS` pelo convidado: o
inativo indica 10 no dia e o 11º é recusado; com 5 amigos, a conta dá 5. O amigo dele que conclui a etapa 3 no mesmo dia
continua na conta (o `testOAmigoDeQuemIndicaInativoQueConcluiAEtapa3NoDiaContinuaNaContaDoTeto`, o caso que antes se chamava
`testOAmigoSemALigacaoQueConcluiAEtapa3NoDiaSaiDaContaDoTeto`); o buraco ficou só para o amigo SEM linha nenhuma, o de antes da
migração, e quem o prende é o `testOAmigoSemLinhaQueConcluiAEtapa3NoDiaSaiDaContaDoTeto`. Os casos: o `IndicacaoDoFormularioTest`
e o `D304EtapaSeisDaAdesaoTest`, nos dois bancos.

### D616 — A medição de `/adm/api` no PostgreSQL nunca chegou às consultas dos relatórios: o gerente das fixtures parava na guarda — CORRIGIDO em 25/10/2026

**Achado pela especificação da parte comum dos relatórios** (`docs/f3-relatorios/spec-comum.md`, o D-b, de 02/10/2026), que
o dava como corrigido na C1; registrado e corrigido na fatia 42 da fila das telas que faltam (REL-BASE), a C1. O
`scripts/medir-adm-api-no-postgres.php` entra como `gerente.teste` (90001001), e o grupo dele não tem privilégio de
relatório nenhum nas fixtures. As oito rotas do Profissional Parceiro (`/adm/api/profissionais` e as filhas) respondiam 403,
e o medidor contava o 403 como guarda -- o comentário dele dizia isso. O `PRECISA_TRAZER` delas estava escrito e nunca valia:
nenhuma consulta do Parceiro rodava no PostgreSQL por este instrumento. Os valores estavam provados nos dois bancos pelo
`ProfissionalParceiroTest`; o que faltava era a borda HTTP no PostgreSQL, que é o que este medidor existe para dar.

**O conserto:** antes de entrar (a sessão lê o privilégio na entrada), o medidor concede ao `gerente.teste` o `ler` na
funcionalidade do Parceiro e na de cada política com `naTelaNova` (`PoliticaDoRelatorio`), nos ids 90006170 a 90006179, com
o id lido da fixture pelo nome (o nome sai do bloco `relatorios` de `SessaoAcl::setAcl`). As concessões saem pelo desligamento
do PHP, que roda também nos `exit` do roteiro, e uma rodada interrompida não as deixa para trás (o roteiro apaga antes de
conceder). O roteiro recusa banco com mais de 10 unidades: ele escreve, e só no descartável. Na mesma fatia, a folha `GET` da
família `/adm/api/relatorios` sem entrada no `PRECISA_TRAZER` passou a ser saída 2, e o relatório só do administrador é medido
por último, com o gerente num grupo temporário de administrador (90006191; o item 7 da 1.1 do `spec-ordem.md`).

**Provado** no `franquiabv_pgcontrato` (o `medir-ensaios-pg.sh`, depois do `ensaiar-fluxo-adm-api`): as oito rotas do
Parceiro responderam 200, com o `PRECISA_TRAZER` cheio, e o medidor saiu 0 ("Chegaram ao trabalho (2xx): 36 de 197"); as
concessões e os grupos temporários foram conferidos em zero depois da rodada, e o grupo do gerente de volta ao 90001001.

**A verificação da fatia 42 (25/10/2026) fechou três frestas do conserto, que voltariam o D616 calado ou derrubariam o
medidor quando o primeiro relatório ligar:**

- **O 4xx da família contava como guarda.** Uma rota de relatório que respondesse 422 (a `QUERY` errada) ou 403 (a guarda
  própria) "passava" com saída 0, sem a consulta rodar no PostgreSQL. Agora toda folha `GET` da família que não chega ao 2xx
  é saída 1 ("RELATORIO QUE PAROU ANTES DO TRABALHO"), menos o 403 `papel` do gerente num relatório só do administrador,
  que a parte do administrador pede de novo; ao administrador, nenhuma parada serve (`relatorioQueNaoTrabalhou`).
- **A concessão colidia com a do CI.** O passo das telas do CI e o `medir-telas-no-postgres.sh` já concedem ao
  `gerente.teste` toda funcionalidade `>= 90002000` (a do Parceiro inclusive) sem remontar o banco antes do medidor, e a
  chave única (usuário, funcionalidade) recusaria o INSERT: a exceção não tratada sairia 255 e o `set -e` derrubaria o
  `medir-telas`. Hoje isso está mascarado (as duas concessões em massa já colidem com as dez linhas que as fixtures dão ao
  90001001, e o fluxo morre antes), mas quem consertar aquelas faria este quebrar. Agora o medidor lê as linhas do gerente
  e só insere as que faltam (`concessoesAFazer`); a que existe com `ler` fica, e não sai no fim.
- **"Uma rodada interrompida não as deixa para trás" valia só para as concessões, e não para o grupo.** Uma rodada morta
  na parte do administrador deixava o gerente no grupo temporário 90006191, e a seguinte o lia como "o original": o
  `UPDATE` não mudava nada, o `DELETE` do grupo batia na `FK_USUARIOS_grupo`, e o roteiro morria antes de devolver as
  concessões -- o `gerente.teste` ficaria administrador no banco descartável até a próxima montagem. Agora a volta é o grupo
  da fixture quando o lido é temporário (`grupoDeVolta`). A parte 3 do `ensaiar-permissao-adm-api.php` tinha o mesmo molde
  com o 90006190, e nem limpava na entrada: agora devolve antes de conceder, e a exceção do banco é "não medi" (saída 2).

As regras moram em `scripts/regras-da-familia-dos-relatorios.php`, puras, e o `PortoesDaFamiliaDosRelatoriosTest` (sem
banco) as exercita com a família ainda vazia; cada uma tem o seu mutante (V9 a V12 e V14 da verificação), vermelho.

### D617 — A lista de unidades do filtro não desempatava: as duplas de mesmo país, estado, cidade e bairro saíam na ordem que o banco quisesse — CORRIGIDO em 25/10/2026

**Achado na verificação da fatia 42** (REL-BASE). `UnidadeRepository::popularCombobox`, a lista suspensa de unidades do ERP
e, desde a C1, a lista do filtro de todo relatório novo (`RelatorioAdmController::unidadesDoFiltro` e o
`GET .../unidades` da base), ordenava por país, estado, cidade e bairro, sem o id no fim. Na cópia (`franquiabv_dev`, por
SELECT agregado) há duas duplas de unidades ativas com os quatro iguais (em Itajaí e em Florianópolis, as duas no Centro):
os dois rótulos idênticos chegavam ao administrador numa ordem que nenhum banco garante, e que pode trocar entre o MySQL e o
PostgreSQL e entre execuções. O `conferir-ordenacao-empates.php` lê só as listagens paginadas e não a via. O rótulo repetido
em si é o D530; o defeito vem de antes da fatia (o Parceiro já usava a mesma consulta), mas a base o levava a cada relatório.

**O conserto:** o `ORDER BY` termina em `u.id`. Nenhuma outra ordem muda: o id só decide entre os empatados.

**Provado:** `RelatorioAdmControllerTest::testAListaDoFiltroDesempataPeloId`, nos dois bancos, põe a unidade de fora no
lugar da da sessão (dentro da transação) e confere a ordem da dupla e o último critério da consulta. Medido: sem o
desempate, os dois bancos devolveram a dupla na ordem do id também -- a ordem sozinha não fica vermelha --, e por isso o caso
lê a consulta; com o desempate arrancado (o mutante V2), ele cai nos dois bancos.

**O que fica:** a ordem do texto entre o MySQL e o PostgreSQL (a colação: acento e caixa) continua a de cada banco, nesta e
nas outras listas que ordenam no banco; o `opcoesDosFiltros` da tela das unidades já ordena em PHP pelo português, e esta
não. Não registrei débito novo: nenhuma tela lê a ordem entre bancos, e o relatório que precisar a escreve.

### D618 — A busca das campanhas por título e por texto acha sem acento no MySQL, e no PostgreSQL só com o acento escrito — REGISTRADO em 26/10/2026 — PREPARADO E DESLIGADO em 07/11/2026: a coluna e o termo sem o acento, pelo mesmo mapa, atrás da chave `busca.campanhas_sem_acento`, que liga na janela com a do D119

**Achado na fatia 92c** (NOTIFICACOES-API-LEITURA), escrevendo o caso da busca da `/adm/api/notificacoes`. A lista nova
procura cada palavra como a lista antiga (`NotificacaoRepository::listagemIndex` e `::listagemDaApi`): `LOWER(coluna)
LIKE '%palavra%'`, com a palavra em minúsculas (`BuscaPorTags::normalizar`, que não tira acento). No MySQL, o `titulo` e
o `conteudo` de `notificacoes` são `utf8mb4_unicode_ci`, e a colação ignora o acento: "promocao" acha "Promoção". No
PostgreSQL não ignora: medido em 26/10/2026, `LOWER('Promoção de corte') LIKE '%promocao%'` dá falso, e com o acento
escrito dá verdadeiro. **No dia do corte, quem procura a campanha sem o acento deixa de achá-la** -- a mesma família do
D119 (a busca de clientes), e pelo mesmo motivo.

O caso da fatia busca com uma palavra sem acento ("promo"), que vale nos dois bancos, e não prova o acento. **O conserto**
é o mesmo que o D119 desenhou para os clientes -- normalizar os dois lados, ou o `unaccent` no PostgreSQL, que é extensão e
deixa os bancos diferentes --, e vale para as outras listas que buscam por `LOWER ... LIKE` com colação `_ci` no MySQL;
fica para quando o D119 for decidido, junto. Não muda nada que a tela antiga faz hoje.

**PREPARADO E DESLIGADO em 07/11/2026 (a fatia 112 da fila dos débitos de pé, MARKETING-CAMPANHAS-E-SORTEIOS).** O D119 já
está PREPARADO E DESLIGADO, e o caminho dele para a coluna natural é tirar o acento dos dois lados com o mesmo mapa -- o
`normalizarTags()` não serve aqui: o título e o texto são colunas naturais, ninguém as reconstrói, e tirar o acento só do
termo trocaria de defeito. O que entrou:

- **O termo**: `BuscaPorTags::normalizarNasCampanhas()` -- o `normalizar()` de sempre e, com a chave ligada, o `semAcento()`.
- **A coluna**: a função DQL nova `SEM_ACENTO(expr)` (`module/ISBase/src/Doctrine/SemAcento.php`, registrada no
  `config/autoload/doctrine.global.php`). No PostgreSQL é o `TRANSLATE` do PRÓPRIO mapa do `BuscaPorTags`
  (`paresDoAcento()`: as letras acentuadas e as delas, e as marcas combinantes U+0300 a U+036F sem par, que o `TRANSLATE`
  apaga) -- núcleo do PostgreSQL, e não a extensão `unaccent`, que este débito citava; no MySQL devolve a coluna como está,
  porque a colação `utf8mb4_unicode_ci` já compara sem o acento.
- **As duas listas**: `NotificacaoRepository::listagemIndex` (a lista antiga) e `::listagemDaApi` (a `/adm/api/notificacoes`)
  usam `LOWER(SEM_ACENTO(coluna)) LIKE` e o termo novo SÓ com a chave `busca.campanhas_sem_acento` ligada
  (`BuscaPorTags::campanhasSemAcento()`); sem ela, a consulta e o termo são os de antes, byte a byte. A função não lê a
  chave: a mesma DQL nunca muda de SQL conforme a configuração.

**A chave nasce desligada** porque a fatia pediu o molde do D119, e liga na janela junto com a `tags_sem_acento` (o 7c do
`docs/procedimento-de-corte.md`, que ganhou a chave nova na mesma linha). Diferente do D119, **não há coluna a reconstruir**:
ligá-la no MySQL não muda o que se acha, e no PostgreSQL passa a achar -- pode ligar a qualquer hora, sem passada. **Na
cópia** o título e o texto das campanhas são anonimizados (todos com a mesma forma), e quantas campanhas da produção têm
acento não se mede ali.

**Provado** no `D618BuscaDasCampanhasSemAcentoTest` (3 casos), nos dois bancos: com a chave desligada a busca é a de hoje
(o termo só perde a caixa; "promocao" acha "Promoção D618 de verão" no MySQL e NÃO acha no PostgreSQL -- o defeito, medido);
com a chave ligada (no cache do `ConfiguracaoLocal`, devolvido no fim), "promocao", "PROMOÇÃO", "Promoção" e a grafia com as
marcas soltas acham pelas duas listas, no título e no texto, e "promocoes" não; e a `SEM_ACENTO()` da consulta dá o mesmo
texto que o `semAcento()` do PHP para todas as letras do mapa e a marca solta (no MySQL, a coluna como está). **Mutantes**,
um por vez e devolvidos pelo sha1: o `TRANSLATE` tirado (vermelho no PostgreSQL), o termo com o acento (vermelho nos dois),
a chave nascendo ligada (vermelho nos dois), a API sem a `SEM_ACENTO` no título e a lista antiga sem ela no texto (vermelhos
no PostgreSQL). **Continua de fora** o resto da família: as outras listas que buscam por `LOWER(coluna) LIKE` numa coluna
natural com colação `_ci` no MySQL (as 35 da segunda regra do `scripts/conferir-busca-por-tags.php`, o D120) -- o caminho é o
mesmo (`SEM_ACENTO` e um termo sem acento), lista a lista, e nenhuma fatia da fila as tem.

**A marca solta no MySQL (07/11/2026, a verificação da fatia 112).** O texto acima dizia que, no MySQL, a `SEM_ACENTO()`
devolvia a coluna como estava porque a colação já compara sem o acento -- e que ligar a chave lá não mudava o que se acha.
Não era verdade para o texto GRAVADO com a marca do acento solta (a letra seguida da marca, a outra forma que a cópia tem;
o cabeçalho do `BuscaPorTags::semAcento`): a colação iguala "ç" a "c", mas o `LIKE` compara letra a letra, e a marca
solta é uma letra a mais. Medido no MySQL 8.4, só com literais: "promoc" + U+0327 + "a" + U+0303 + "o" `LIKE
'%promocao%'` dá 0, e a forma de uma letra só dá 1 -- o `TRANSLATE` do PostgreSQL a achava, e a diferença entre os bancos
continuava nesse caso. **O conserto:** no MySQL a `SEM_ACENTO()` é `REGEXP_REPLACE(coluna, '[\x{0300}-\x{036F}]', '')` --
só a marca sai, a mesma faixa do `paresDoAcento()`; a letra acentuada fica, e a colação da coluna (que o `REGEXP_REPLACE`
mantém, conferido) a compara sem o acento. Existe desde o MySQL 8.0.4 (a produção é 8.0.46; o CI roda 8.4 e 8.0). Ligar a
chave no MySQL passa a achar esse texto, e só ele. **Provado** no caso novo
`D618BuscaDasCampanhasSemAcentoTest::testOTextoGravadoComAMarcaSoltaAchaNosDoisBancos` (a campanha gravada com as marcas
soltas: com a chave desligada, nenhum banco acha "promocao"; com ela, as quatro grafias acham pelas duas listas, no título e
no texto) e no `testAConsultaEOPhpTiramOMesmoAcento` refeito (no MySQL a função tira a marca e deixa a letra), nos dois
bancos. **Mutantes**, um por vez e devolvidos pelo sha1: a função do MySQL devolvendo a coluna como estava e a faixa só com
o agudo (vermelhos os dois casos, no MySQL). A etapa 1 do corte (`docs/procedimento-de-corte-etapa-1.md`) passou a citar a
chave nova ao lado da `tags_sem_acento`.

### D619 — Na campanha da equipe, quem não a pode criar (o caixa) altera o texto, pausa, retoma, cancela e encerra — REGISTRADO em 26/10/2026; a pergunta foi ao lote — CORRIGIDO em 26/10/2026 (269 a)

**Achado na verificação da fatia 92d** (NOTIFICACOES-API-ESCRITA). Pela 255 (b), a campanha de WhatsApp para os
colaboradores da unidade ("Usuários") fica "para o gerente e o administrador": na criação, nas duas telas, o caixa não
a escolhe (a tela nova recusa o `para: usuario` dele com 422; a antiga confere o destinatário só quando o `id` vem vazio).
**Depois de criada, ninguém olha o destinatário**: o `PUT /adm/api/notificacoes/:id`, o cancelamento, a pausa, a retomada e
o encerramento conferem o 401, o `escrever` e a unidade (`NotificacoesController::campanhaDaRota`), e o recurso devolve o
`podeEditar` verdadeiro ao caixa; a edição da tela antiga, o mesmo (`salvarValidarFormulario`). O caixa com o `escrever`
(havia 1 em 2026, P5/P6 do desenho) troca o texto que vai a todos os colaboradores ativos da unidade. **A 92d herdou o
furo da tela antiga, e não o abriu**; a decisão 1 do desenho ("escreve quem tem o privilégio de escrever") não trata o
caso. É regra do dono -- a 255 (b) fala de quem manda a campanha, e não de quem a corrige depois --, e a pergunta foi ao
lote (as ações da campanha da equipe só ao gerente e ao administrador, ou como hoje). Até a resposta fica como hoje, nas
duas telas, e o `NotificacaoApiTest::testOCaixaAlteraACampanhaDaEquipeComoNaTelaAntiga` fixa o que existe. **O conserto
provável**, se o dono escolher a guarda: o `campanhaDaRota` recusando (403) a campanha `usuario` a quem não usa esse
destinatário (`destinatariosDaPessoa`), o `pode*` do recurso pela mesma conta, e a edição antiga também.

**CORRIGIDO em 26/10/2026 pela resposta 269 (a) do dono** (*"Só o gerente e o administrador mexem nela: na campanha para a
equipe, alterar, pausar, retomar, cancelar e encerrar ficam para quem pode criá-la, nas duas telas; o caixa a vê, sem os
botões"*), na fatia 92e. A regra é a da criação, lida de novo na campanha gravada: `RegraDaCampanha::destinatariosDoPapel`
(o da equipe só ao gerente e ao administrador, a 255 b -- a conta que os dois controllers tinham cada um a sua) e
`::mexeNaCampanha`. **Na API**: os `pode*` do `NotificacaoJson` falsos para quem não mexe na campanha, na lista e no detalhe
(o `escreve` passa pela regra); as cinco escritas da rota (o `PUT`, o cancelamento, a pausa, a retomada e o encerramento)
recusam no `campanhaDaRota`, depois do 404 e antes de qualquer gravação, com 403 e o `motivo` `campanha_da_equipe`
(`RegraDaCampanha::FRASE_SO_QUEM_CRIA_A_DA_EQUIPE`); o cadastro e o teste já recusavam o `para` (422). **Na tela antiga**:
a lista (`notificacoes/index.phtml`, com o `destinatario` no `listagemIndex`) tira o editar e o cancelar da linha da equipe
para quem não pode criá-la; o "Salvar" da edição (`conferirAudiencia`) e o cancelar recusam no servidor, com a frase, sem
gravar. O `mod-marketing.js` não mudou (o cache segue no 166). **Desfaz**: o `mexeNaCampanha` devolvendo verdadeiro (a (c),
como era). O caso `testOCaixaAlteraACampanhaDaEquipeComoNaTelaAntiga` virou o
`NotificacaoApiTest::testOCaixaNaoMexeNaCampanhaDaEquipe`, e a tela antiga ganhou o
`CampanhaAudienciaTest::testOCaixaNaoMexeNaCampanhaDaEquipeNaTelaAntiga` (a lista renderizada pelo `ViewRenderer`), nos dois
bancos, com os mutantes (o `pode*` da lista e do detalhe ao caixa, a recusa da rota tirada e cada uma das cinco escritas
sem ela, a regra que sempre mexe, o papel sem o gerente, os dois botões da tela antiga de volta, o salvar e o cancelar
antigos sem a recusa).

### D620 — O Líder (grupo 8) não tem a marca de gerente, e a regra da pergunta 3 o recusa nos relatórios novos — REGISTRADO em 26/10/2026

**O D-c da parte comum dos relatórios** (`docs/f3-relatorios/spec-comum.md`, seção 4), registrado na fatia 43
(REL-CONSULTA-E-EXPORTACAO), como a seção manda. Na cópia, `grupos.gerente = 0` no grupo 8 (o Líder): a migração
`scripts/migracao-grupo-lider-gerente.sql` (o D124) roda no passo 3 da janela, e não no passo 0 (a pergunta 11 do lote de
06/10/2026, a opção (b)). Pela regra da pergunta 3 (01/10/2026), que a política dos relatórios segue pelas MARCAS da sessão
(`RecorteDoRelatorio::escopo`, desde a fatia 42) e nunca pelo id do grupo, o Líder com o privilégio cai em "os outros": é
recusado no Caixa e em Produtos/Serviços e vê só a si mesmo na Cota-parte (uma pessoa em cada, medido em 02/10/2026,
`spec-comum.md`, tabela 1.1). O consolidado dizia que o Líder de Produtos/Serviços "fica como está", com visão de gerente:
isso só vale depois da migração.

**Nada a fazer no código:** ele não volta a comparar o id do grupo. Em produção nada muda antes do corte, porque as telas novas
dos relatórios só chegam nele, e a migração do D124 roda na mesma janela. **O ponto ao dono** já está decidido (a pergunta 11):
rodá-la no passo 3 resolve. Fica registrado para que ninguém leia a recusa do Líder nos relatórios, entre hoje e o corte, como
defeito da política. Não bloqueia fatia nenhuma.

### D621 — O `conferir-rotas-orfas.php` não confere a ação de quase nenhuma rota de `/adm/api`: ela mora numa rota-filha mais funda — REGISTRADO em 26/10/2026 — CORRIGIDO em 07/11/2026: o portão desce a config mesclada (a fatia 115)

**O D-d da parte comum dos relatórios** (`docs/f3-relatorios/spec-comum.md`, seção 4), registrado na fatia 43 e medido de
novo. Desde 17/10/2026 (a fatia ACL-API) o portão põe no mapa os controllers de `Controller/Adm/`, mas procura a ação só nos
200 caracteres depois de `'controller' => Controller\Adm\X::class` (`scripts/conferir-rotas-orfas.php`, o laço das rotas). Nas
rotas de `/adm/api` a declaração do controller fica na rota-mãe, e a ação de cada folha numa `child_routes` mais abaixo:
**medido em 26/10/2026, 34 declarações de controller `Adm\` nos `module.config.php`, e 33 sem ação nenhuma na janela** -- o
portão as pula caladas. Uma rota de `/adm/api` que aponte para uma ação que não existe (a ação renomeada, a folha nova com
erro de digitação) passa verde, e o sintoma é um 404 do Laminas em produção.

**Para a família dos relatórios** o caso "toda rota da família aponta para uma ação que existe" do
`RelatorioAdmControllerTest` (`testAFamiliaDeRotasEAsPoliticasNaTelaNova`) cobre, quando a família nascer (a fatia 47). **O
conserto, para o resto:** o portão descer a árvore da config mesclada, como o `conferir-rotas-sem-login.php` já faz desde a
fatia 42 (o `controller` herdado da mãe, a `action` da folha), e conferir cada folha contra as ações do controller. Não muda
nada que o sistema faz.

#### CORRIGIDO em 07/11/2026 (a fatia 115, ROTAS-404-E-401)

**O portão desce a config mesclada.** `scripts/conferir-rotas-orfas.php` não lê mais o texto dos `module.config.php`: monta
a aplicação (`Application::init`, como o `conferir-rotas-sem-login.php`), anda a árvore das rotas com os `defaults` da mãe
somados aos da filha (o controller herdado, a ação da folha), e confere cada rota que termina contra a classe de verdade, por
reflexão: o controller pelos apelidos do `ControllerManager`, a ação pela regra do Laminas (`getMethodFromAction`), a ação
herdada conta (a `cota-parte-v2` responde o `indexAction` de `CotaParteController`), e não contam as que não são tela -- o
`indexAction` de marcador do Laminas, o `notFoundAction` e o `index` do `CrudController` (o 404 do D424). Eram **87 pares**
lidos; agora são **398**, 312 deles em rotas-filhas. A órfã conhecida passou a ser chamada pelo nome da rota
(`isapi-aplicativo-agenda-salvar`). A guarda contra verde sobre nada exige também que haja par em rota-filha.

**Mutantes:** a ação `verAction` do `Adm\CuponsController` renomeada -- o portão novo sai com 1 nomeando
`adm-api-cupons/um/ver`, e o de antes (o do HEAD) sai com **0** sobre o mesmo mutante, que era o defeito; a rota
`isapi-aplicativo-agenda-salvar-v2` apontada para `index` -- saída 1 ("só o index sem tela do CrudController"), e com essa
regra arrancada do portão, saída 0. Devolvidos pelo sha1.

**Ele precisa do `vendor/`**: no `ci.yml` o passo "Rotas orfas" foi para depois do `composer install` (o mesmo job dos
textuais, ao lado do `conferir-rotas-sem-login.php`), e no `conferir-tudo.sh` a linha foi para o grupo que precisa do
`vendor/`. O `conferir-lacuna-do-ci.sh` sai 0.

### D622 — No PostgreSQL, a chave de `colunas_gravadas` é só o nome: um segundo inquilino não grava a data da mesma coluna — REGISTRADO em 26/10/2026 — PREPARADO em 07/11/2026: a chave com o inquilino no gerador

**Achado na fatia 43** (REL-CONSULTA-E-EXPORTACAO), criando a tabela `colunas_gravadas` (`nome VARCHAR(40) PRIMARY KEY,
desde DATETIME NOT NULL`, a 1.5 do `docs/f3-relatorios/spec-ordem.md`). O gerador (`scripts/gerar-ddl-postgres.php`) dá a ela o
`inquilino_id`, o RLS e a política, como a toda tabela com escopo, e copia a chave primária do `mysql.sql` como está:
`PRIMARY KEY (nome)`. As outras tabelas têm `id` de identidade, único na rede inteira, e o problema não aparece nelas; aqui a
chave é o NOME da coluna -- "cancelamento" --, e com dois inquilinos o segundo `INSERT` da mesma coluna esbarra na chave do
primeiro, mesmo sem vê-lo (o RLS esconde a linha, a chave não). **Hoje não muda nada:** há um inquilino só (o D45), e a tabela
nasce vazia; cada migração da fatia do PDV grava a sua linha uma vez.

**O conserto** é a chave `(inquilino_id, nome)` no PostgreSQL -- uma exceção no gerador para as tabelas cuja chave não é o `id`
(hoje só esta), ou o `id` de identidade com a chave única `(inquilino_id, nome)`. Fica para quando o segundo inquilino entrar
(o D45), junto com o resto do que ele pede; até lá a data de cada coluna é uma por banco, que é o que a cópia e a produção têm.

**Conferido na verificação da fatia 43 (26/10/2026):** na cópia `franquiabv_dev` do PostgreSQL a tabela tem o `inquilino_id` com o
padrão da sessão, o RLS ligado e forçado, a política `inquilino_isolado` e os privilégios do `bvip_app`, como a `convenios`; só
a chave difere, e a tabela tem 0 linhas nos dois bancos. O gerador JÁ põe o `inquilino_id` na frente das chaves ÚNICAS
(`privilegios`, `privilegios_usuarios`, `royalties`, `dashboard_colaboradores`, `sorteios_vendas`): a chave PRIMÁRIA que não é
`id` é o caminho que ele não trata, e o conserto é dar a ela o mesmo tratamento. A falha também contaria ao segundo inquilino que
a linha do outro existe (a chave não passa pelo RLS).

**Preparado em 07/11/2026 (a fatia 114, ESQUEMA-QUE-SO-ACRESCENTA).** Conferido antes: o `postgres/schema.sql` tinha
`PRIMARY KEY (nome)` em `colunas_gravadas`, e era a única tabela com escopo cuja chave primária não era o `id`. **O conserto, no
gerador** (`scripts/gerar-ddl-postgres.php`, onde a tabela é emitida): em tabela com escopo, a chave primária que não é o `id`
ganha o `inquilino_id` na frente -- a regra que as unicidades e os índices já seguiam. O `postgres/schema.sql` regerado mudou
nessa linha (`PRIMARY KEY (inquilino_id, nome)`), e o gerador rodado no contêiner dá o mesmo arquivo. Os bancos da suíte
nascem dele.

**A cópia PostgreSQL ficou com a chave de antes** (`(nome)`, 0 linhas): trocar a chave primária não é só acrescentar, e a
resposta 83 (a) só autoriza o que acrescenta. Com um inquilino só, as duas chaves se comportam igual, e a produção nasce do
`schema.sql` no corte, já com a nova. A diferença está escrita em `docs/ambiente-desenvolvimento.md`. No MySQL nada muda: lá
não há inquilino.

**Os casos** (`EsquemaQueSoAcrescentaTest`): a chave no catálogo dos bancos da suíte (`inquilino_id,nome` no PostgreSQL,
`nome` no MySQL); no PostgreSQL, um segundo inquilino gravado na transação do caso e a linha "cancelamento" gravada pelos
dois, cada um vendo a sua data; e a regra lida na saída do gerador (a única chave primária fora do `id`, em tabela com escopo,
é a de `colunas_gravadas`, com o inquilino na frente). **Os mutantes**, devolvidos pelo sha1: o gerador sem o `array_unshift`
(vermelho o caso do gerador) e o `schema.sql` com a chave de antes (no PostgreSQL, vermelhos o do catálogo e o dos dois
inquilinos, este com a recusa da chave).

**O que falta é do corte:** o PostgreSQL de produção nascer do `schema.sql` (o passo 4 da etapa 2). O resto do segundo
inquilino segue no **D45**.

### D623 — Vendas Realizadas aberta por um relatório noutra unidade diz a unidade pelo número, e não pelo nome — REGISTRADO em 27/10/2026 — CORRIGIDO em 28/10/2026 (a fatia 51)

**Achado pela fatia 45 da fila das telas que faltam (REL-MENU-E-PARCEIRO; a C5 de `docs/f3-relatorios/spec-comum.md` e o item 1
da 1.4 do `spec-ordem.md`).** O "ver as vendas" dos relatórios (a pergunta 5 do dono: Descontos, Canceladas e Caixa) abre
Vendas Realizadas com a unidade do recorte no `filtros.unidade` (`frontend/src/api/vendas.ts`), e a tela, que não tem o campo
da unidade, diz de que unidade são as vendas -- "Abertas pelo relatório: só as vendas da unidade nº 7." (`fraseDaUnidade`, em
`frontend/src/telas/VendasRealizadas.tsx`). Pelo NÚMERO: a listagem (`GET /adm/api/vendas`) devolve o `recorte.unidade` só com
o `id`, e a tela não tem de onde ler o nome. O administrador que conhece as unidades pelo nome lê um número.

**Não engana:** o número é o da unidade que o servidor APLICOU (o `recorte`), e não o pedido -- para quem não é administrador,
ele usa a da sessão, e a frase diz a dela. E nenhum relatório abre a lista assim ainda: o primeiro é a fatia dos Descontos.

**O conserto** é o nome no `recorte.unidade` da listagem (`RegraDaListagemDeVendas`, o `recorte`, com o nome do cadastro, como
o `RecorteDoRelatorio::json` dos relatórios faz) e a frase com ele -- uma consulta pequena por pedido, só quando o filtro veio.
Fica para a fatia dos Descontos, a primeira que abre a lista noutra unidade.

**Na fatia 50 (28/10/2026, REL-DESCONTOS-CONTAS; a D2 de `docs/f3-relatorios/spec-descontos.md`), a API sem a tela:** o
"ver as vendas" é da tela (a D3, a fatia 51), e a D2 não precisa de nada dele no servidor -- a lista dos Descontos já leva o
id da venda e o `recorte.unidade` com o nome. O conserto deste débito (o nome no `recorte` da listagem de vendas e a frase com
ele) fica para a 51, no mesmo commit da frase que o lê: o nome sem a tela que o mostre mudaria o contrato de
`GET /adm/api/vendas` sem leitor.

**CORRIGIDO em 28/10/2026 (a fatia 51, REL-DESCONTOS-TELA; a D3 do `docs/f3-relatorios/spec-descontos.md`), no mesmo commit
do "ver as vendas" dos Descontos, o primeiro que abre a lista noutra unidade.** `ISVenda\Controller\Adm\VendasController::recorteComONome`
devolve `recorte.unidade = { id, nome }`: o nome do cadastro (`UnidadeRepository::nomesDoCadastro`) da unidade que o servidor
APLICOU -- para quem não é administrador, a da sessão --, buscado só quando a lista veio com o filtro `unidade` (uma consulta
pelo id; a lista do menu não a paga). Sem o filtro, na unidade que não está no cadastro e na de nome vazio, `nome: null`; a rede
continua `unidade: null`. A regra (`RegraDaListagemDeVendas`) continua sem banco, e o `recorte` dela não mudou. Na tela, a frase
diz "Abertas pelo relatório: só as vendas da unidade <nome>.", e o número só sem o nome (`fraseDaUnidade`, em
`frontend/src/telas/VendasRealizadas.tsx`; o tipo em `api/vendas.ts`). **Provado** nos dois bancos (`ListagemDeVendasTest`, quatro
casos em processo, com sete conferências do recorte -- o administrador com a escolhida, o gerente com a da sessão e não a
pedida, sem o filtro, a rede, a que não está no cadastro e o nome vazio --, o nome lido do banco pelo teste; até a verificação da
51 este parágrafo dizia "cinco casos", e o roadmap, "seis") e no front (`VendasRealizadasLigada.teste.tsx` e
`ConsoleAbreDescontos.teste.tsx`), com os mutantes P51-X7 (a frase de volta ao número), P51-X17 (o nome sempre nulo), P51-X18 (o
nome da unidade pedida), P51-X19 (a consulta sem a condição do filtro), P51-X20 (o recorte sem passar pela função) e P51-X21 (o
nome vazio como nome), os do servidor vermelhos nos dois bancos. Os ensaios que leem a listagem (o de permissão, o de recusas e
o de fluxo, e o medidor do PostgreSQL) não leem o `recorte.unidade`, e rodaram nos dois bancos de contrato. Não visto no navegador
nesta fatia.

### D624 — O custo dos pacotes é o preço copiado: toda margem que leia `valor_compra` do pacote mente — REGISTRADO em 27/10/2026

**O D-p1 de `docs/f3-relatorios/spec-produtos.md` (seção 4), registrado na fatia 47 (REL-PRODUTOS), a primeira do
relatório.** O cadastro do pacote grava o custo igual ao preço (`module/ISConfiguracao/src/Service/PacoteService.php:21`,
`setValorCompra($data['valor'])`), e a edição muda o preço e não o custo. Na cópia, em 02/10/2026, 183 dos 239 pacotes tinham
o custo igual ao preço, e 30 o custo 100 vezes o preço, em 11 unidades (o levantamento [d7] do desenho; que a causa dos 100
vezes seja `ISBase\Util\Monetario` é hipótese, não reconferida).

**Pesa em toda margem ou "valor em reais" que leia `valor_compra`:** o "Lucro" da tela antiga de Produtos / Serviços
(`VendaProdutoRepository::relatorioVendasProduto`, que ainda vive até o corte) e a R2 da Retirada. **A tela nova não tem custo
nem lucro** (o parágrafo `produtos.semLucro` do "Como os números são contados" diz por quê). O dono já decidiu gravar o
custo no item, na hora da venda (a resposta 224 (b), de 15/10/2026, a pergunta da seção 3 do `spec-produtos.md`), e a margem
vale dali em diante; a coluna vai na fatia 74b da fila (o D627) -- o pacote precisa do custo certo no cadastro antes, ou a
margem dele nasce com este defeito. Não bloqueia; fecha quando o cadastro do pacote gravar o custo de verdade e a
margem nascer do custo gravado, ou quando a tela antiga sair.

**Nota da fatia 74b (03/11/2026, PDV-CUSTO-DO-ITEM; o D627 CORRIGIDO):** o custo passou a ser gravado no item, e o pacote foi
deixado de fora DE PROPÓSITO: o pacote vendido e a sessão de pacote gravam o custo nulo ("sem registro"), e a gaveta de
Pacotes do Produtos / Serviços não tem custo nem margem. Gravar o `valor_compra` do pacote daria margem zero a todo pacote, e
100 vezes o preço aos 30 do defeito -- o número continua o da cópia em 03/11/2026: **183 dos 239 pacotes com o custo igual ao
preço, e 30 com o custo 100 vezes o preço** (só SELECT). Este débito segue de pé: fecha quando o cadastro do pacote gravar o
custo de verdade, e então a 74b grava o pacote como grava o produto (o `movimentaEstoque` do escritor é a pergunta a trocar).

### D625 — O histórico de retiradas da tela antiga exige o privilégio de Produtos / Serviços, e não o de Retiradas — REGISTRADO em 27/10/2026 — CORRIGIDO em 27/10/2026

**Corrigido em 27/10/2026** (a fatia 48 da fila das telas que faltam, REL-RETIRADA; a F0 de `docs/f3-relatorios/spec-retirada.md`,
o D-r6 de lá): o `retiradasHistoricoAction` confere `getAcesso2('retiradasDeProdutos', 'ler')` e o escopo da política
`retiradas` (`RecorteDoRelatorio::escopo`, o `null` recusado), como a lista (`retiradasAction`), que passou a aplicar a mesma
política. O `unidadePermitida` (o D155) fica. Provado no `RetiradaDeProdutosTest::testOHistoricoAntigoPedeOPrivilegioDaRetirada`
(com o da Retirada sem o de Produtos, `sucesso`; com o de Produtos sem o da Retirada, 404; o colaborador com o da Retirada,
404), nos dois bancos, e pelas vigias novas do `conferir-recorte-por-unidade.php`; os mutantes P48-P14 e P48-G8, vermelhos. O
histórico antigo continua ignorando o período (o resto do D-r6): a tela nova o substitui pelo detalhe do mês, e a tela antiga
fica até o corte (o D628).

**O D-p2 de `docs/f3-relatorios/spec-produtos.md` (seção 4), registrado na fatia 47.** O
`ISRelatorio\Controller\ProdutosController::retiradasHistoricoAction` confere `getAcesso('ler')`, que é o
`relatorios/vendasProdutos` do controller, enquanto a lista das retiradas (`retiradasAction`) confere `retiradasDeProdutos`.
Na cópia, 1 dos 106 usuários que leem Retiradas não tem o de Produtos (o levantamento [d8]): para ele, a lista abre e o
histórico de um produto não.

**A fatia 47 não o muda, de propósito:** a política nova de Produtos (o gerente e o administrador) entrou só no `indexAction`,
e o histórico continua no privilégio de antes -- o Líder, recusado no Produtos, segue abrindo o histórico de retiradas. A troca
para `retiradasDeProdutos` é da fatia da Retirada (`spec-retirada.md`, e o item 3 da 1.4 do `spec-ordem.md`: as duas
especificações concordam). Fecha nela.

### D626 — O dublê de sessão do `ControllerEmProcesso` devolve nulo em `getUsuario()` sem campo, e a guarda de tela antiga que lê o arranjo inteiro fica verde pelo motivo errado — REGISTRADO em 27/10/2026 — CORRIGIDO em 08/11/2026

**O D-p3 de `docs/f3-relatorios/spec-produtos.md` (seção 4), registrado na fatia 47.** O `getUsuario($campo = null)` do dublê
(`tests/Caracterizacao/ControllerEmProcesso.php:51-54`) indexa o arranjo por `$campo`, e com `null` devolve `null`; a sessão
de verdade devolve o arranjo inteiro (`module/ISConfiguracao/src/Permissions/SessaoAcl.php`, o `getUsuario` sem campo). Toda
guarda de tela antiga que leia `getUsuario()` inteiro -- a de `ProdutosController::indexAction` desde a fatia 47, pela
`RecorteDoRelatorio::escopo` -- recusa o gerente sob esse dublê, e o caso que prova a recusa do colaborador fica verde porque
TODOS são recusados.

**A fatia 47 contornou, e não consertou:** o `ProdutosVendidosTest::testATelaAntigaAplicaAMesmaPolitica` usa a sessão do
`RelatorioEmProcesso` (`sessaoDoRelatorio`, que devolve o arranjo inteiro), e prova o gerente e o administrador atendidos ao
lado do colaborador e do caixa recusados -- o mutante que arranca o `$escopo !== null` (P47-P14) fica vermelho nos dois bancos.
**O conserto** (o dublê devolver o arranjo sem campo) muda o comportamento de todo teste que usa o trait: meça antes de mudar,
numa fatia própria. Não bloqueia.

#### CORRIGIDO em 08/11/2026 -- a fatia 127 da fila dos débitos de pé (ARNES-DOS-TESTES-E-ENSAIOS)

**Medido antes de mudar.** O `getUsuario()` do dublê foi instrumentado (sem mudar o que devolvia) para gravar, a cada chamada sem
campo, o caso que a fez, e a suíte inteira rodou no MySQL (7.190 casos, saída 0). Só **quatro** casos passam por ali, todos do
`ListagemDeVendasTest` (o recorte da `/adm/api/vendas`: `testComOFiltroDaUnidadeORecorteTrazONomeDoCadastroDaQueFoiAplicada`,
`testSemOFiltroONomeENuloEARedeContinuaNula`, `testAUnidadeQueNaoEstaNoCadastroFicaSemNome` e `testONomeVazioNoCadastroFicaNulo`),
e nenhum muda: o `Adm\VendasController::indexAction` lê o usuário inteiro só para o `RecorteDoRelatorio::soDoCaixa`, que dá `false`
tanto para o `null` quanto para o arranjo sem a marca de caixa. Nenhum outro caso do trait chega a uma guarda que leia o usuário
inteiro -- as telas antigas dos relatórios já tinham saído para a sessão do `RelatorioEmProcesso` (a fatia 47, a 61, o D105 e o
D367), que era o contorno. Ninguém ficava verde pelo motivo errado hoje; o defeito esperava o próximo caso.

**O conserto.** `tests/Caracterizacao/ControllerEmProcesso.php`: sem campo (o mesmo `empty` da `SessaoAcl::getUsuario`), o
arranjo inteiro (`id`, `administrador`, `gerente`); com campo, o valor, e o campo que o dublê não tem segue nulo. O
`D626SessaoDuplaDevolveOArranjoTest` (2 casos) prova o dublê e a tela antiga do Produtos / Serviços pelo trait: o gerente atendido
(200, a `ViewModel` com `produtos`), o colaborador com o privilégio recusado (404) e o administrador atendido. Os textos do
`spec-produtos.md` (o D-p3 e o aviso da seção 4) e do `spec-comum.md` dizem que o dublê serve.

**Provado.** A suíte inteira com o conserto nos dois bancos (7.192 casos em cada, saída 0); o
caso novo, o `ListagemDeVendasTest` e o `ProdutosVendidosTest` (79 casos) nos dois bancos. **Mutante** M1 (o `if (empty($campo))`
arrancado, o dublê de antes): os dois casos novos vermelhos nos dois bancos -- "null is identical to Array" e "404 is identical
to 200", o gerente recusado --; devolvido pelo sha1. **O que fica:** a sessão dupla não tem a marca de caixa (`caixa`), e o teste que precise do caixa
sem gerente segue pela sessão do `RelatorioEmProcesso`; os 74 dublês de sessão escritos dentro dos próprios testes (73 arquivos) (`function
getUsuario($campo = null)` em `tests/Caracterizacao/`) não são deste débito.

**A verificação de 08/11/2026 (três lentes) achou dois restos, consertados.** (1) O caso novo dizia que julho de 2023 tinha venda
nas fixtures, e não tem (o `ProdutosVendidosTest` planta a dele no próprio caso): o gerente atendido sobre o mês vazio só provava o
200. O caso agora planta um produto, um cliente e uma venda fechada de julho de 2023 na unidade 90001000 (ids 90012790, dentro da
transação do teste) e confere a linha `Pomada (D626)` na `ViewModel` do gerente e do administrador com a unidade; o mutante P1 (sem
o plantio) fica vermelho nos dois bancos ("Failed asserting that an array contains 'Pomada (D626)'"), e o M1 (o dublê de antes)
continua vermelho nos dois, os dois casos. (2) Oito testes ainda diziam que a sessão dupla não devolve o usuário inteiro -- o
`ListagemDeVendasTest`, o `ProdutosVendidosTest`, o `D428NpsDaListaDeVendasTest`, o `D367RoyaltiesDaUnidadeInativaTest`, o
`D105CacheVazioDosRelatoriosTest`, o `D432DataNaFormaTest`, o `D447DataNosFiltrosTest` e o `RelatorioDeCaixaNoErpTest`: o texto diz
que ela não devolvia até aqui e por que cada um fica com a sessão própria ou a do `RelatorioEmProcesso` (a marca de caixa, o
privilégio só da tela, o `setValores`); nenhuma linha de código desses testes mudou. O `spec-comum.md`, o `spec-caixa.md`, o
`README.md` e o `spec-produtos.md` de `docs/f3-relatorios/` dizem o mesmo onde falavam no presente. Os dez arquivos (178 casos)
nos dois bancos, saída 0.

### D627 — A margem que o dono pediu na 224 (b) não tem fatia: o custo do item vendido não é gravado — REGISTRADO em 27/10/2026 — CORRIGIDO em 03/11/2026

**Corrigido em 03/11/2026** (a fatia 74b da fila das telas que faltam, PDV-CUSTO-DO-ITEM; a resposta 224 (b) do dono). A coluna
`vendas_produtos.custo_unitario` (o custo do cadastro POR UNIDADE, `double` anulável, sem chave) entra pela
`scripts/migracao-custo-do-item.sql` (idempotente pelo `information_schema`, `ALGORITHM=INSTANT`, 0,2 s nos 3,26 milhões de
itens da cópia; o irmão em `postgres/`), no laço do passo 0 do corte e do ensaio geral, no `mysql.sql` e no `postgres/schema.sql`
regerado, aplicada nas duas cópias duas vezes. O escritor é um só, `VendaProdutoService::gravarOCustoDoCadastro`, que o
lançamento (o PDV antigo e as portas da comanda nova) e a agenda chamam: grava o `valor_compra` do cadastro só no produto de
estoque (`Produto::movimentaEstoque`), fora do pacote; o serviço, o pacote vendido e a sessão de pacote ficam nulos (o D624),
e o item que vira sessão pelo item tirado perde o custo (`comoSessao`); o ajuste de quantidade mantém o custo da entrada, como
mantém o preço; o cadastro com custo zero grava zero (o D684). O portão `scripts/conferir-escritores-do-custo.php` (no
`conferir-tudo.sh` e no CI). A linha `custoDoItem` de `colunas_gravadas`, num passo próprio depois do código
(`scripts/colunas-gravadas-custo-do-item.sql` e o irmão), no passo 7 da etapa 1 e no 7d do corte, nas duas cópias. O
Produtos / Serviços: o `gravadoDesde.custoDoItem`, o custo e a margem nos Produtos vendidos e o custo nas Cortesias, só dos
itens com o custo gravado, e "sem registro" no resto; a Retirada conta a cortesia pelo custo gravado onde houver. Provado no
`CustoDoItemGravadoTest` (as portas), no `ColunasDoPdvTest` (as seis linhas), no `ItensDoRelatorioTest` (as 31 chaves), no
`ProdutosVendidosTest`, no `RetiradaDeProdutosTest` e nas regras puras, nos dois bancos, e no oráculo pela API no contrato. **A
margem do pacote continua fora** (o D624).

**Achado na verificação da fatia 47 (REL-PRODUTOS).** O dono respondeu a 224 (b) em 15/10/2026: cada item vendido guarda o
custo do cadastro naquele momento, o Produtos / Serviços mostra a margem e a Retirada o valor em reais, a partir desse dia, e o
que veio antes aparece como "sem registro"; a coluna só acrescenta (nas cópias, pela 83 (a); na produção, antes do código que
a usa). Nenhuma fatia da fila das telas que faltam a leva: as do PDV (70 a 74) são o cancelamento, o "lançado por", a gaveta
contada, o motivo da retirada e a cota do pacote, e nenhuma outra cita a 224. A F1 do Produtos saiu sem margem, como o desenho
manda (o parágrafo `produtos.semLucro` do "Como os números são contados"), e o texto da fatia 47 dizia que a coluna "é da fatia
do PDV" -- fatia que não existe.

**O que falta, no molde da seção 3 da parte comum (`docs/f3-relatorios/spec-comum.md`) e da seção 3 do `spec-produtos.md`:**
a coluna do custo em `vendas_produtos` (só acrescenta, nas duas cópias locais e no laço do passo 0 do corte); o PDV gravando o
custo do cadastro em cada item; a linha dela em `colunas_gravadas`; o `gravadoDesde` na resposta do Produtos e o
`AindaNaoGravado` ("sem registro") na gaveta de Produtos vendidos; e o valor em reais da Retirada (a F1 do `spec-retirada.md`
mostra o custo de hoje com o rótulo "custo atual" e passa a mostrar o gravado onde houver). A margem do pacote espera o D624.
Não bloqueia; fecha com a fatia 74b da fila (PDV-CUSTO-DO-ITEM, depois da 70, da 47 e da 48), que o orquestrador pôs na
fila em 27/10/2026: ela grava a coluna e mostra a margem.

### D628 — A tela antiga da Retirada escreve a quantidade com a máscara de dinheiro, mostra a rede sem a unidade e abre o histórico sem o período — REGISTRADO em 27/10/2026

**O D-r7 de `docs/f3-relatorios/spec-retirada.md` (seção 4), e o resto do D-r6, registrados na fatia 48 (REL-RETIRADA).** A
tela antiga (`module/ISRelatorio/view/is-relatorio/produtos/retiradas.phtml`, a quantidade com a máscara: "3" vira "3,00") lê a
rede pela URL em 85 linhas com 70 nomes e nenhuma unidade (`ProdutoEstoqueRepository::relatorioRetiradas`, que agrupa por
produto sem dizer de que unidade é; o `q10` do desenho), e o histórico de um produto (`retiradasHistoricoAction`,
`historicoRetiradasPorProduto`) lista todas as saídas da história, sem o período nem o filtro da tela (3.522 linhas contra 186
na rede em julho de 2026, o `q9`). **A tela nova não tem nenhum dos três**: a quantidade sai inteira ou decimal, a rede tem a
coluna da unidade do produto, e o detalhe é o das saídas do mês e do filtro. A antiga fica como está até o corte (a seção 6,
item 8, do `spec-comum.md`); não bloqueia.

### D629 — A retirada grava como autor a pessoa assumida na personificação, e não quem entrou — REGISTRADO em 27/10/2026

**O D-r8 de `docs/f3-relatorios/spec-retirada.md` (seção 4), registrado na fatia 48.** O `ProdutoService::retirada` grava o
`usuarioId` como quem lançou (`module/ISConfiguracao/src/Service/ProdutoService.php`, o `usuario` da saída; e a ação do PDV
antigo, `ISConfiguracao\Controller\ProdutosController::retiradaAction`), e na personificação ele é a pessoa assumida -- o mesmo
problema que o D315 e a F4.1 trataram com `autorDaEscrita()`. A "Lançada por" da Retirada nova mostra o que está gravado.
`produtos_estoque` não tem coluna para quem entrou: criá-la é mudança do PDV, e espera o dono como as outras colunas da seção 3
do desenho. Não bloqueia.

### D630 — `produtos_estoque.data_criacao` não tem índice, e o mês da Retirada lê a tabela inteira — REGISTRADO em 27/10/2026 — PREPARADO em 07/11/2026: o índice no passo 0 e nas cópias

**O D-r9 de `docs/f3-relatorios/spec-retirada.md` (seção 4), registrado na fatia 48.** Só a chave e as três estrangeiras
(`mysql.sql`, a tabela `produtos_estoque`); o `ProdutoEstoqueRepository::retiradasDoPeriodo` filtra o mês por
`data_criacao` e lê as 33.576 linhas da cópia. Medido em 02/10/2026: de 0,02 a 0,30 s; na fatia 48, a ação inteira da
Retirada (a consulta das saídas com a dos itens do painel) está na entrada dela no `docs/roadmap.md`. O índice só acrescenta, e
iria pelo passo 0 do corte (a resposta 83 (a)); hoje não pesa, e nada o pede. Não bloqueia.

**Preparado em 07/11/2026 (a fatia 114, ESQUEMA-QUE-SO-ACRESCENTA), pela resposta 83 (a) do dono.** Conferido antes: a
`produtos_estoque` tinha só a chave e as três estrangeiras, nos dois bancos da cópia. Entrou:

- `scripts/migracao-produtos-estoque-indice-data.sql`: o `IDX_PRODUTOS_ESTOQUE_data_criacao` em `produtos_estoque
  (data_criacao)`, `ALGORITHM=INPLACE, LOCK=NONE` (as escritas no estoque seguem), idempotente, parando com saída 1 se o
  nome estiver noutras colunas; na linha `| 0 | **Aplicar**`, no laço do passo 0 e no do ensaio geral; o `KEY` no
  `mysql.sql`; o `postgres/schema.sql` regerado com `produtos_estoque_IDX_PRODUTOS_ESTOQUE_data_criacao (inquilino_id,
  data_criacao)`;
- nas duas cópias às 18:56: no MySQL em 0,16 s (e "indice ja existe" na segunda vez); no PostgreSQL por
  `postgres/migracao-produtos-estoque-indice-data.sql` (`CREATE INDEX CONCURRENTLY IF NOT EXISTS`, válido; "already exists"
  na segunda).

**A Retirada antes e depois**, a consulta do `retiradasDoPeriodo` (julho de 2026, as saídas ativas, com as três junções e a
ordem) por `EXPLAIN ANALYZE` na `franquiabv_dev`, seis vezes cada, a primeira fria:

| | antes | depois |
|---|---:|---:|
| MySQL, a rede (186 saídas) | 5,2 a 6,5 ms (a fria 24 ms), `Table scan on est` (33.815 estimadas) | **0,58 a 0,64 ms**, `Index range scan` no índice novo |
| MySQL, a unidade 1 (0 saídas no mês) | 1,1 a 1,2 ms (a fria 2,5), pelos produtos da unidade | 0,33 a 0,41 ms, pelo índice novo |
| PostgreSQL, a rede | 2,9 ms (a fria 14,8), `Seq Scan` | 2,9 a 3,1 ms, **ainda `Seq Scan`** |

**No PostgreSQL o planejador não o usa** com o `random_page_cost` de fábrica (4, o de disco giratório, o achado do D189): com
1,1 (o de NVMe), entra pelo índice novo e leva 0,47 a 1,0 ms. É parâmetro do servidor, de toda consulta, e não deste índice.

**Os casos:** `EsquemaQueSoAcrescentaTest::testOIndiceDasSaidasPelaData` (o catálogo dos bancos da suíte, nos dois) e o
`PassoZeroDoEsquemaQueSoAcrescentaTest` (o índice tirado do `mysql.sql` reprova). **Os mutantes:** o `KEY` tirado do
`mysql.sql` (vermelhos o caso do catálogo no MySQL e o controle do portão) e a migração tirada do laço do procedimento
(vermelho o controle do portão). **O que falta é da janela:** rodar a migração na produção, pelo laço.

**A correção da verificação (07/11/2026): a migração rodada nos três ramos.** Num banco descartável (apagado no fim), com a
`produtos_estoque` da cópia sem o índice (33.576 linhas): cria em 0,09 s, a segunda vez "índice já existe" em 0,04 s, e com o
nome em `(data_criacao, id)` a parada com o `mysql` saindo 1 e o índice intocado. O caso que a roda a cada suíte, no MySQL:
`EsquemaQueSoAcrescentaTest::testAMigracaoDasSaidasCriaOIndiceUmaVezEParaNaFormaQueNaoConhece`. Os mutantes, devolvidos pelo
sha1: o `WHEN @colunas IS NULL` trocado e a parada trocada por um `SELECT` inofensivo, os dois vermelhos nesse caso.

### D631 — A Retirada de Produtos da rede sobe a 122,5 MB de pico, perto do `memory_limit` de 128 MB de fábrica do PHP — REGISTRADO em 27/10/2026

**Medido na fatia 48 (REL-RETIRADA), na cópia** (o `franquiabv_dev`, buffer pool de 128 MB, em processo, julho de 2026, três
processos de cada): a unidade 39 em 1,03 a 1,25 s com 20,0 MB de pico; **a rede em 1,52 a 1,95 s com 122,5 MB de pico** (72,6 KB
de JSON, 121 linhas, 186 saídas). Acima dos 64 MB; e **os dois tempos acima do que o desenho esperava** (`spec-retirada.md`,
"O que medir": menos de 1 s na unidade e menos de 1,5 s na rede, "acima disso, registre e me diga antes de seguir") -- a
unidade e a rede passaram nas três rodadas, e não só no pior caso (a correção da verificação da 48: o texto de antes só
apontava a rede). Dito ao orquestrador no relatório da correção. O peso é o `VendaRepository::painelItens` da rede inteira (31.898 itens no mês), que a Retirada lê só para as cortesias:
o resultado cru e a lista normalizada vivem juntos dentro do `ContasDoPainelDaUnidade::normalizar`. O `docker/php/php.ini` do
repositório dá 512 MB; o de fábrica do PHP é 128 MB, e o da produção não foi lido (é do levantamento do item 7 da etapa 1,
`docs/procedimento-de-corte-etapa-1.md`). Com 128 MB, um mês da rede maior que julho de 2026 cairia num erro fatal.

**O que não se fez, de propósito:** o desenho manda importar os itens do painel e nunca reescrever a regra da cortesia, e o
roteiro da fatia diz para registrar e não mudar o desenho pela medida. **O caminho**, numa fatia própria: ler os itens da rede
por unidade (uma consulta por unidade, o pico de uma), ou uma consulta das cortesias que devolva só os itens de produto a preço
zero (a regra continua no `normalizar`). Não bloqueia enquanto o `memory_limit` da produção for o do repositório.

**Na verificação da 48 (27/10/2026), os pedidos ao mesmo tempo:** o servidor solta a sessão antes das consultas, e o pico
acima é de UM pedido -- a mesma pessoa trocando de unidade, ou voltando à tela com a rede no ar, punha várias leituras da rede
em paralelo, cada uma com o seu pico. Entrou a regra da base ("o relatório novo que ler a rede inteira em pedido de segundos
ganha a sua" trava, e a sua fila): a **trava da rede por pessoa** no `RetiradaDeProdutosController` (o segundo pedido da rede
da mesma pessoa, com o primeiro no servidor, é o 429 com a frase do Profissional Parceiro; a unidade não trava) e a **fila
`retiradas`** do `filaDaRede.ts` na tela (a seguinte espera a do ar, e a que a tela trocou nem sai). O que fica: o pico de um
pedido, e dois administradores ao mesmo tempo, um pedido cada -- a trava é da pessoa, como a do D311.

**O dono, em 27/10/2026, sobre a rede e a produção:** "Acredito que o servidor de produção permite 1024mb ou 2048mb para o
limite do php. mantenha a rede, siga o desenho". A rede fica, como o desenho a escreve. O `memory_limit` é de cada processo:
com 1.024 MB, o pico medido de 122,5 MB cabe com folga num pedido; pedidos ao mesmo tempo somam na memória da máquina, e não no
limite. **Ainda não lido na produção:** o número entra pela leitura do item 7 da etapa 1
(`docs/procedimento-de-corte-etapa-1.md`); se ela der 128 MB, este débito volta a bloquear antes do corte.

**Na fatia 49 (27/10/2026), com o custo atual dentro de `numeros`** (a F1: `ContasDaRetirada::custo` sobre o mesmo
`cadastroDosProdutos`, sem consulta a mais), remedido pelo mesmo instrumento, na cópia, buffer pool de 128 MB, julho de 2026,
três processos de cada, **e o código da 48 na mesma hora** (as classes do `HEAD` carregadas antes, para separar o custo do
ruído da máquina): a unidade 39 em **1,17 a 1,19 s com 20,1 MB** (a 48, na mesma hora: 1,18 a 1,25 s, 20,1 MB); a rede em
**1,58 a 1,99 s com 122,6 MB** (a 48: 1,73 a 2,14 s, 122,6 MB). O custo não moveu o pico nem o tempo além do ruído; o JSON da
rede passou de 72.605 para 77.492 bytes. Os 0,1 MB a mais contra os números escritos acima (20,0 e 122,5) são do instrumento,
e aparecem iguais no código da 48. O débito fica como estava.

**Os Descontos da rede pesam o mesmo (28/10/2026, a fatia 50; a D2 de `docs/f3-relatorios/spec-descontos.md`).** O
`DescontosController` lê `VendaRepository::relatorioItens` da rede (o `painelItens` com as colunas dos relatórios), duas vezes
no `index` -- a comparação primeiro, contada e liberada, e depois o mês, como o desenho manda para o pico não dobrar --, e uma
na lista. Medido na cópia (buffer pool de 128 MB, em processo, julho de 2026, três processos de cada): **a rede em 2,53 a 2,67
s no `index` e 1,62 a 2,37 s na lista, com 122,3 a 122,5 MB de pico** (o mesmo pico da Retirada: o peso é o mesmo
`normalizar` da rede inteira); com um pedido antes no mesmo processo (o custo do primeiro despacho fora), 1,35 a 1,45 s e 0,65
a 0,73 s. Abaixo do teto do desenho (4 s e 2,5 s, e 150 MB), e a mesma trava da rede por pessoa (o 429). O que vale para a
Retirada vale aqui: com 128 MB de `memory_limit` um mês da rede maior que julho cairia, e o caminho é o mesmo (ler a rede por
unidade).

**Produtos / Serviços da rede pesa o mesmo (03/11/2026, a fatia 69, REL-PRODUTOS-REDE; a F2 de
`docs/f3-relatorios/spec-produtos.md`).** O `ProdutosVendidosController` lê `VendaRepository::relatorioItens` da rede duas vezes,
um mês de cada vez (julho e a comparação), dentro da trava da rede por pessoa (`produtosDaRede-<id>`, o 429). Medido no Mac Mini
M4 (não se compara com os números do Windows acima), em processo, na cópia, buffer pool de 128 MB antes e depois, três rodadas por
processo (a primeira, a fria do processo; o banco não foi reiniciado), julho de 2026 com junho (`p69-medida.txt` do rascunho da
sessão): **de 0,57 a 0,65 s no MySQL e de 0,51 a 0,78 s no PostgreSQL, com 132 MB de pico nos dois**, abaixo da parada do desenho
(256 MB e 10 s). **Com `memory_limit=128M` a rede cai** -- no MySQL no `normalizar` (o limite esgotado), no PostgreSQL no cache do
Doctrine (o D646) --, como cairiam a Retirada e os Descontos. O `memory_limit` do contêiner é 512M (o `zz-app.ini`, e o FPM o
herda); o da produção segue não lido, e o dono já respondeu sobre ele ("mantenha a rede, siga o desenho"). O caminho é o mesmo
(ler a rede em partes ou por unidade). O débito fica como estava.

**A Capacidade × realizado da rede fica abaixo (04/11/2026, a fatia 76i, CAPACIDADE-CR0; a CR0 de
`docs/f3-relatorios/spec-capacidade.md`).** O `CapacidadeController` lê a agenda do mês da rede pela régua da F4 e os itens do
painel da rede. Lidos de uma vez, com a lista crua e a normalizada vivas juntas, a rede de julho de 2026 chegava a 158 MB de pico
(a crua sozinha, 62 MB), e com `memory_limit=128M` caía no cache de metadados do Doctrine (o `OutOfSpaceException`, metade do
limite). Ficou assim: a agenda primeiro, os itens por último e em duas metades do mês (`ContasDosRoyalties::partes`), cada linha
crua normalizada e solta na hora, e a consulta dos itens aquecida numa faixa vazia antes da agenda. Medido no Mac Mini M4, em
processo, na cópia, três rodadas: **de 0,49 a 0,55 s no MySQL e de 0,70 a 0,75 s no PostgreSQL, com 116 a 120 MB de pico, e
passando com `memory_limit=128M` nos dois** -- perto do teto, como a Retirada. A Capacidade não acrescenta caso ao débito; ele fica
como estava.

**A Capacidade com as desativadas que venderam (04/11/2026, a fatia 76m, CAPACIDADE-CR4; a resposta 300 (b) e a 301 (c)).** As
linhas da rede passam a ter as desativadas com item no mês (março de 2025: 42 linhas, 6 desativadas). Medido em processo, com
`memory_limit=128M`, cada mês num processo novo (`p76m-pico-mysql-*.txt` e `p76m-pico-pg-07.txt` no rascunho): no MySQL, março de
2025 com 118 MB de pico, março de 2024 com 122 MB e julho de 2026 com 120 MB; no PostgreSQL, julho com 118 MB. Na medida dos três
meses num processo só, julho, medido por último, deu **124 MB nos dois bancos** -- o `memory_get_peak_usage(true)` não volta entre
pedidos no mesmo processo, então é o acumulado dos três, e não o de um pedido; mas é o número mais alto que a Capacidade já mostrou,
a 4 MB do limite. A margem de um pedido segue de 6 a 10 MB, como a da Retirada; o débito fica como estava.

### D632 — O motivo "Convênio …" em venda que não guarda o convênio: os Descontos o contam numa linha "sem o convênio na venda", e a causa não foi lida — REGISTRADO em 28/10/2026 — MEDIDO em 07/11/2026 (a 111: causa achada no PDV e contada na cópia; o conserto aguarda o dono) — PREPARADO em 07/11/2026 (a 132: o lançamento grava na venda o convênio do item, nas três portas; o acerto das vendas antigas é roteiro do dono) — PREPARADO em 08/11/2026 (a 136: o roteiro também avança a "última venda" dos convênios, a 363 (a); o lançamento revê o convênio da venda, a 362 (a); o roteiro espera o dono)

**O D-d de `docs/f3-relatorios/spec-descontos.md` (seção 4; o D-ds4 do `spec-ordem.md`, 1.6), registrado na fatia 50
(REL-DESCONTOS-CONTAS), a D2 do relatório.** O item tem o motivo que o PDV escreve sozinho para o desconto do convênio
("Convênio X", `RegraDoItemDaComanda::PREFIXO_DO_CONVENIO`), e a venda dele não guarda convênio nenhum (`vendas.convenio`
nulo). A regra da origem (a 1.3 do desenho, `ContasDosDescontos::origem`) o conta como convênio, pelo motivo; o agrupamento
por convênio, que é pelo convênio DA VENDA, o põe na linha nula, "sem o convênio na venda", sempre a última.

**Medido na cópia** (`franquiabv_dev`, julho de 2026, pela própria API em processo, e conferido pelo oráculo independente em
Python sobre as linhas cruas): **7 itens e R$ 98,00 na unidade 1; 66 itens e R$ 867,50 na rede** -- os números do desenho, de
02/10/2026, reproduzidos ao centavo.

**A pergunta a responder ao ler o PDV:** o convênio sai da venda depois de lançado o item (a troca de cliente na comanda, o
cliente que perdeu o convênio), ou a venda nasce sem ele por outro caminho? Não lido. Até lá, o número não se perde (está no
total do convênio e na linha à parte), e a tela da D3 (a fatia 51) mostra a linha com o nome dela.

**A causa, lida em 07/11/2026 (a fatia 111, PDV-ANTIGO-ABAS-E-CONVENIO).** O convênio DA VENDA e o desconto DO ITEM são
decididos em momentos diferentes, cada um pelo convênio que o cliente tem naquele instante:

- **`vendas.convenio` é escrito uma vez só, quando a venda nasce**, e nunca mais: `VendaService::insert` (a venda em
  andamento do `/vendas/novo`, que nasce no primeiro item), `comandaAbrir` (a comanda, que nasce vazia) e
  `comandaAbrirAgenda` -- o convênio do cliente, se ligado. Fora dali, só `ClienteService::creditoComprar` o grava, nulo
  (a compra de crédito). Era assim no código importado da produção (`eaf1fca5`).
- **O desconto do item é decidido no lançamento** (`VendaProdutoService::calcularLinha`, pelo `convenioQueOCaixaDa`): o
  convênio do cliente da venda AGORA, ligado e liberado na unidade e no dia. Ele grava o motivo "Convênio X".
- Então **o cliente que ganha o convênio (ou o convênio que é ligado) depois de a venda nascer** leva o desconto nos
  itens lançados depois, e a venda segue "sem convênio".
- **O `comandaAbrir` que reaproveita a venda em andamento troca o cliente sem rever o convênio** (o ramo `else`; a troca é
  permitida sem pagamento, a 15 (c) do D384). Os dois sentidos: se o cliente anterior não tinha convênio e o novo tem, é o
  caso deste débito por outra porta; se o anterior tinha o convênio Y, a venda **fica com o Y**, e os itens lançados depois
  saem pelo convênio do novo cliente, ou sem nenhum -- a venda conta na lista do Y, que não deu desconto. É o CONTRÁRIO
  deste débito, e a primeira rodada da fatia o chamava de "o mesmo efeito" (a verificação apontou). Também o cliente que
  troca de convênio entre a abertura e o lançamento cai aqui: a venda guarda o de antes.

**Contado na cópia** (`franquiabv_dev`, só SELECT de contagem, sem dado pessoal; os roteiros em
`/Users/joao/Dev/sistemabv/estado-sessao-d5c2211c/scratchpad/medir-d632-*.sql`): **2.338 itens em 1.708 vendas**, de
2018 a 2026 (984 itens em 2025, 407 em 2026; 66 itens em 41 vendas concluídas em julho de 2026, os do desenho).

- **Em 1.642 das 1.708 o primeiro item da venda já tem o motivo do convênio.** Na venda em andamento, que nasce no
  primeiro item com o convênio daquele instante, isso não acontece; logo, a venda nasceu VAZIA -- a comanda -- antes de
  o cliente ter o convênio (ou o primeiro item foi tirado depois). No grupo de controle (as vendas COM o convênio desde
  2025), 6.632 de 11.356 começam pelo item do convênio.
- **1.251 são a primeira venda do cliente com um item do convênio**, e em 1.210 nenhuma venda anterior do cliente
  guardava convênio; a venda seguinte guarda em 936. É a venda em que o cliente passou a ser do convênio.
- **O desconto é o do sistema, e não digitado:** dos 1.847 itens cujo nome no motivo é de um convênio cadastrado hoje,
  1.795 têm o desconto igual à porcentagem dele (os 52 restantes, porcentagem mudada depois, ou o balcão por cima); 506
  citam um nome que não existe mais (renomeado). Nenhum tem o "; " do balcão ou do cupom. Só 6 são do "Sem Cadastro".
- **O contrário, contado na verificação** (`/Users/joao/Dev/sistemabv/estado-sessao-d5c2211c/scratchpad/medir-d632-convenio-de-outro.sql`,
  só SELECT de contagem): **172 itens em 126 vendas** guardam um convênio e têm o motivo com outro nome. Desses, **88 itens
  em 53 vendas** citam outro convênio que existe hoje -- e nenhuma das 53 tem item do convênio que ela guarda --; os outros
  84 citam um nome que não existe mais (renomeado, e então talvez o mesmo convênio). As 53 são o teto do caso da troca de
  cliente ou de convênio; separar uma da outra pediria a história do cliente da venda, que o banco não guarda. Não contado:
  a venda que guarda o Y e cujos itens saíram sem convênio nenhum (o novo cliente sem convênio) -- o motivo vazio não diz
  de onde veio.

**O que a venda órfã perde:** a lista de vendas do convênio (`VendaRepository`, `ven.convenio = :convenio`, a tela do
convênio), a "última venda" dele (o `setDataUltimaVenda` do finalizar), e, nos Descontos, a linha do convênio -- ela
cai em "sem o convênio na venda". O desconto foi dado, e o total do convênio no relatório o conta (pelo motivo).

**O conserto pede decisão, e foi ao lote:** "TELAS-RESTANTES [vendas] (07/11/2026) -- A venda passa a guardar o convênio
que o item recebeu? (D632)" -- (a) só a venda SEM convênio grava o do item que o recebe, daqui para a frente; (b) a venda
segue o convênio do item que o recebe também quando guardava outro (o do cliente anterior da comanda); (c) o (a) ou o (b),
e acertar as antigas por roteiro na produção; (d) como está. Recomendado (b). A pergunta da primeira rodada não citava a
venda que fica com o convênio do cliente anterior (a verificação apontou) e foi reescrita, com as 53 vendas. Nada mudou no
código por este débito.

**O conserto, em 07/11/2026 (a fatia 132, CONVENIO-SEGUE-O-ITEM; a resposta 358 do dono, (c) com a (b): "A venda segue o
convênio do item que o recebe, daqui para a frente [...], e também quando a venda guardava outro convênio [...]. Se uma venda
tiver itens de dois convênios [...], vale o do último item lançado", e "acertar as de antes: um roteiro, rodado por você na
produção, grava nas vendas antigas o convênio pelo nome que está no motivo").**

- **Daqui para a frente, nas três portas.** `VendaProdutoService::aVendaSegueOConvenioDoItem` grava na venda o convênio
  que o item recebeu, esteja ela sem convênio ou com outro. Quem chama: `calcularLinha` (o PDV antigo, a API da tela nova,
  e o "+", o "-" e a troca pelo cupom, que relançam a linha pela mesma conta), ao fim da conta, só se a linha termina com o
  desconto do convênio -- e não quando o cupom venceu (a linha é do cupom; a origem dela nos Descontos é o cupom) --; e o
  laço próprio de `VendaService::comandaAbrirAgenda`, no ramo do `convenioAtivo`. O item sem o desconto do convênio (fora
  do serviço, o cliente sem convênio, fora do dia, a sessão do pacote) não mexe no convênio da venda. Com itens de dois
  convênios, o último lançado decide (o "+" de uma linha mais antiga a relança, e ela passa a ser a última: a regra da
  opção). O dinheiro não muda: o desconto já era o do item; só a coluna `vendas.convenio`. **O que a tela do convênio passa
  a mostrar:** a venda entra na lista de vendas do convênio que deu o desconto (`VendaRepository::selecionarVendasPorConvenio`,
  pelo `ven.convenio`, concluídas), sai da lista do convênio do cliente anterior, e o finalizar atualiza a "última venda"
  do convênio certo (o `setDataUltimaVenda` lê o convênio da venda); nos Descontos, o item novo deixa de cair na linha "sem
  o convênio na venda". Sem mudança nessas telas: todas leem a coluna.
- **O passado: `scripts/acertar-convenio-das-vendas.sql` e o irmão `postgres/acertar-convenio-das-vendas.sql`, rodados
  pelo dono na produção quando ele quiser, fora da janela** (o molde do `baixar-debitos-em-dobro.sql` da fatia 101; não é
  passo do procedimento, e o melhor momento é depois da etapa 1, com o código novo no lugar; o do PostgreSQL, depois da
  etapa 2, só se o do MySQL não tiver rodado). **Vale para os dois casos**, a venda sem convênio e a venda com outro: a (c)
  manda gravar "o convênio pelo nome que está no motivo", e a (b), escolhida junto, diz que a venda segue o convênio do
  item também quando guardava outro, com o último item decidindo. O roteiro decide pelo último item do convênio que a
  venda TEM; o lançamento novo, pelo último LANÇADO. Dão o mesmo, menos quando o item do convênio saiu da venda (tirado, ou
  a linha relançada com o cupom vencendo, a verificação da fatia 132): o lançamento deixa a venda com o convênio do item que
  saiu, e o roteiro, rodado depois, a passa ao do último que ficou (com os itens 1 do X e 2 do Y, tirado o 2, o lançamento
  deixa o Y e o roteiro grava o X); sem nenhum que ficou, o roteiro não a toca. É o caminho 2 do **D738**, ao lote. O item do
  convênio é o do motivo que o sistema escreve, comparado byte a byte: começa por "Convênio " exato (os 651 itens com
  "convenio", "CONVENIO" ou "convênio" digitados à mão ficam de fora, como nos Descontos, que os contam como manual) e sem a
  marca do cupom. O nome é o motivo inteiro (cortado em 100, como o `motivoDoConvenio`) ou o que vem antes de "; " (o balcão
  por cima, a 177 (c)), comparado byte a byte com `convenios.nome` (no MySQL a colação da coluna, `utf8mb3_general_ci`,
  igualaria maiúscula e acento). A venda é decidida pelo último item do convênio; se o nome dele não bate com exatamente
  um convênio (renomeado, apagado, ou o nome que dois convênios têm), ela fica como está, contada e listada à parte. Conta
  antes, lista o que muda com o convênio de antes (o caminho de volta), grava só `vendas.convenio` (nada de
  `data_alteracao`, do item, do dinheiro nem do convênio), confere no fim refazendo a conta do zero; idempotente.
- **Na cópia (franquiabv_dev, só SELECT de contagem, 07/11/2026):** 20.997 itens têm o motivo exato, em 15.152 vendas; o
  roteiro mudaria **1.181 vendas sem convênio** (1.152 concluídas) e **46 com outro** (44 concluídas); 13.760 já estão
  certas; ficam 38 cujo último item cita um nome que não existe mais e 127 cujo nome é de dois convênios (12 convênios
  repetem o nome de outro). "Ficam" é "o roteiro não as toca", e não "ficam erradas" (a verificação da fatia 132, no
  MySQL da cópia, a mesma junção do roteiro): das 127, **120 já guardam um dos convênios desse nome** e só 7 estão sem
  convênio; das 38, 34 estão sem convênio. Na linha "sem o convênio na venda" dos Descontos, que só lê as concluídas, o
  roteiro tira **1.152 vendas** (as concluídas das 1.181), e ficam 41 (as 34 e as 7, todas concluídas). Só 1 venda tem itens de dois nomes. No PostgreSQL da cópia, o mesmo 1.181 e 46 (15.149 vendas;
  a diferença é o MySQL local, que as rotinas tocam). Os 2.338 itens da fatia 111 contavam o motivo por `LIKE 'Conv_nio %'`,
  com os digitados à mão; pelo motivo exato do sistema, são 1.790 itens em venda sem convênio.
- **Os testes:** `D632VendaSegueOConvenioDoItemTest` (18 casos: as três portas -- o PDV antigo e a API despachados em
  processo, a agenda pelo serviço --; a venda sem convênio, com o de outro e já com ele, com o mesmo dinheiro; o item sem o
  desconto não mexe, também na agenda num dia em que a unidade não dá o convênio; dois convênios, vale o último; a comanda
  trocada de cliente pela porta de verdade; o "+" que relança; o cupom que vence e o que perde; e o item do convênio que sai
  -- tirado, ou com o cupom vencendo na linha relançada -- deixa a venda com o convênio dele, como está até a resposta do
  D738) e `AcertoDoConvenioDasVendasTest` (3 casos: o roteiro inteiro sobre treze vendas, duas
  rodadas, e a leitura sem comentários: um `UPDATE` só, em `vendas`, só do convênio), nos dois bancos. Os mutantes, um por
  vez e devolvidos pelo sha1: M1 (a linha não marca o convênio), M2 (o cupom que vence leva a venda), M3 (a agenda sem a
  chamada), M4 (só a venda sem convênio, a opção (a)), M5 (a venda leva o convênio do cliente em qualquer item); S1 (o MySQL
  sem a comparação byte a byte), S2 e P1 (o primeiro item decide), S3 e P3 (sem a exclusão do cupom), S4 e P2 (o nome de
  dois convênios também grava), S5 (sem o balcão) -- todos vermelhos; e, na verificação, M6 (a chamada da agenda subida
  para antes do `convenioAtivo`, que passava verde com o convênio liberado todos os dias; vermelho nos dois bancos nos
  dois casos novos do dia sem convênio) e M7 (o `remover` que limpa o convênio da venda; vermelho no caso do item tirado,
  que fixa o de hoje). Não provado: a chave do inquilino no PostgreSQL (com
  um inquilino só, tirá-la não muda nada). **O roteiro inteiro pelo cliente do banco**, com a transação, o `SET` e o
  `ON_ERROR_STOP` dele, duas vezes, em dois bancos descartáveis montados pela receita do contrato (`descartavel_f132_contrato`
  e `descartavel_f132_pgcontrato`, o dono superusuário), com oito vendas por cima das fixtures: `mysql` e `psql` saíram 0; a
  primeira contou 3 sem convênio, 1 com outro, 1 nome que não existe e 1 de dois convênios, e acertou 4; a segunda, 0 a
  mudar e 4 certas; o dinheiro e a situação das vendas iguais antes e depois; os dois bancos apagados no fim (o ensaio em
  `estado-sessao-d5c2211c/scratchpad/ensaio-132/`, fora do repositório).
- **A marca é PREPARADO:** o lançamento já grava o convênio do item (o defeito deixa de nascer, como todo conserto deste
  código, a partir do corte), e o que resta do título -- as vendas de antes na linha "sem o convênio na venda" -- é o roteiro
  pronto, que o dono roda. Fica de fora, com pergunta própria: a "última venda" dos convênios cujas vendas o roteiro acerta
  (na cópia, 8 convênios ficariam com a "última venda" mais velha que uma venda acertada; o roteiro não a toca: pergunta
  "TELAS-RESTANTES [vendas] (07/11/2026) -- O acerto das vendas antigas também atualiza a "última venda" do convênio?
  (D632)"); e a venda que guarda um convênio sem nenhum item com o desconto dele (a troca de cliente da comanda, sem item
  do convênio novo -- a metade "ou sem desconto nenhum" do caso 2, que a (b) prometia resolver --, e o item do convênio
  que sai da venda), que é o **D738**.


**Em 08/11/2026 (a fatia 136, CONVENIO-REVISTO-NA-VENDA; as respostas 362 (a) e 363 (a) do dono, gravadas na página do
lote em 07/11/2026).** **A 363 (a):** "o roteiro também avança a 'última venda' desses convênios para a data da venda
acertada mais nova, quando ela for mais recente que a gravada (nunca a volta para trás), com a lista do valor de antes."
Os dois roteiros (`scripts/` e `postgres/acertar-convenio-das-vendas.sql`) ganham o passo 1b (os convênios que recebem
venda **concluída** acertada -- `status` 1, a que o finalizar conta; a aberta e a cancelada não --, com a `data_criacao` da
mais nova delas, a hora do fechamento, quando passa da gravada ou a gravada é nula), o 4b (a lista: o convênio, a data de
antes e a nova, antes da escrita; só ids e datas), o 5b (o `UPDATE` de `convenios.data_ultima_venda`, só para a frente) e
o 5c (a conferência: 0 atrás). O convênio que perde a venda fica como está. Na cópia (franquiabv_dev, só SELECT de
contagem, 08/11/2026, a mesma junção do roteiro): **8 convênios** andariam, 1 deles com a gravada nula. **A 362 (a):** o
lançamento revê o convênio da venda que perde o motivo dele (o **D738**, parcial: três ramos voltam ao dono), pelo mesmo
critério do roteiro: dali em diante, tirar o item do Y deixa a venda no X, como o roteiro.

- **Os testes** (`AcertoDoConvenioDasVendasTest`, nos dois bancos): o roteiro inteiro, com o "Teste" já com a data mais nova
  (fica), o de 100 caracteres com a mais velha (anda), o "Segundo" só com a venda aberta e o "Teste" com a cancelada mais
  nova que tudo (não contam); a data nula também anda; a segunda rodada não muda nada; a leitura sem comentários (dois
  `UPDATE`, um em `vendas` só do convênio e um em `convenios` só da data, com a condição de só andar; só as temporárias
  saem); e os dois casos do lançamento contra o roteiro (o D738). **O roteiro inteiro pelo cliente do banco**, duas vezes,
  em dois bancos descartáveis montados pela receita do contrato (`descartavel_f136_contrato` e
  `descartavel_f136_pgcontrato`, apagados no fim; o ensaio em `estado-sessao-d5c2211c/scratchpad/ensaio-136/`, fora do
  repositório): `mysql` e `psql` saíram 0; a primeira rodada acertou 5 vendas e avançou 1 convênio (o de 01/10 para a venda
  concluída de 02/11, com a cancelada de 20/11 de fora), o de data mais nova ficou; a segunda, 0 e 0; o dinheiro e a
  situação das vendas iguais. Nada rodou no franquiabv_dev além da contagem.
- **A marca segue PREPARADO:** o código está no lugar; o que resta do título -- as vendas de antes e agora a "última venda"
  dos convênios -- é o roteiro pronto, que o dono roda.

### D633 — Nos Descontos, as variações das contagens do desconto (itens, vendas, faturamento delas, clientes) têm a cor de bom e ruim, e o desenho não diz se deviam — REGISTRADO em 28/10/2026 — CORRIGIDO em 29/10/2026

**Registrado na verificação da fatia 51 (REL-DESCONTOS-TELA), a D3 de `docs/f3-relatorios/spec-descontos.md`.** O item 3 da
vista da D3 diz que "Descontos concedidos" e a fatia são **neutros** (sem a cor verde ou vermelha da variação contra o mês
anterior): "desconto maior não é bom nem ruim", como o `Painel.tsx` faz com os descontos concedidos. O desenho não diz nada dos
outros quatro números do desconto: **Itens com desconto**, **Vendas com desconto**, **Faturamento das vendas com desconto** e
**Clientes com desconto**. O implementador os deixou neutros também (só o Faturamento e os Atendimentos com a cor), uma escolha
dele, sem desenho nem resposta; a verificação a achou, e a tela voltou ao desenho: os quatro têm a cor, como no painel (subir é
verde, descer é vermelho). O caso que o prova é o "as outras variações têm a cor, como o desenho" do
`frontend/src/telas/Descontos.teste.tsx`, e o mutante P51-X31 (o faturamento delas neutro de novo) o deixa vermelho.

**A pergunta foi ao lote** (`perguntas-do-lote.md`, "TELAS-RESTANTES [vendas] (28/10/2026) -- Descontos: a seta dos itens, das
vendas e dos clientes com desconto tem cor? (D633)"): mais itens, vendas ou clientes com desconto é bom (verde), ou é como o
próprio desconto, nem bom nem ruim? Não trava a tela e não muda número nenhum: é só a cor da seta. A troca, se o dono pedir, é o
`neutra` de cada cartão no `OsNumeros` do `Descontos.tsx`, e o caso acima.

**CORRIGIDO em 29/10/2026 (a fatia 57b, RESPOSTAS-270-271), pela resposta 270 (b) do dono (28/10/2026, "Sem cor nas três
contagens, com cor no faturamento delas").** Os **Itens com desconto**, as **Vendas com desconto** e os **Clientes com desconto**
ficam neutros (a seta e o sinal, sem a cor verde ou vermelha), como os "Descontos concedidos" e as fatias: as três contagens dizem
quanto desconto se deu. O **Faturamento das vendas com desconto**, dinheiro que entrou, continua com a cor (verde ao subir), como o
Faturamento e os Atendimentos. A troca é o `neutra: true` dos três cartões no `OsNumeros` do `frontend/src/telas/Descontos.tsx`;
o caso "as outras variações têm a cor, como o desenho" do `Descontos.teste.tsx` deu lugar a quatro, um por número (os três neutros
e o faturamento delas com a `painel-sobe`). Os mutantes P57b-X1 a X3 (cada contagem com a cor de volta) e o X4 (o faturamento
delas neutro) ficaram vermelhos, cada um no seu caso, pela asserção da classe. A prévia `Descontos` acompanha (renderiza a tela;
o comentário diz a regra). O glossário não fala da cor, e não mudou.

### D634 — As Vendas Canceladas da rede leem a base de dois meses: de 1,50 a 2,17 s fria e 238,5 MB de pico na V0 — REGISTRADO em 28/10/2026 — PARCIAL em 28/10/2026

**Medido na fatia 52 (REL-CANCELADAS-CONTAS, a V0 de `docs/f3-relatorios/spec-canceladas.md`), na cópia** (o `franquiabv_dev`,
buffer pool de 128 MB, em processo, sem HTTP, três processos de cada; o roteiro `medir-p52-contas.php` do rascunho da sessão),
as quatro leituras da V0 e as contas, de junho e julho de 2026 juntos (o mês e a comparação, o que a V1 vai pedir):

| Recorte | Canceladas | Itens delas | Finalizadas do dia | A base (itens concluídos) | Total | Pico |
|---|---|---|---|---|---|---|
| Unidade 1, fria | 0,21 a 0,33 s (122) | 0,09 a 0,27 s (169) | 0,003 a 0,004 s (98 clientes, 338 linhas) | 0,22 a 0,23 s (8.524) | 0,55 a 0,83 s | 62,0 MB (duas voltas) |
| Unidade 1, quente | 0,006 a 0,007 s | 0,008 s | 0,003 s | 0,22 s | 0,24 a 0,25 s | |
| Rede, fria (os seis processos) | 0,18 a 0,70 s (1.008) | 0,10 a 0,35 s (1.438) | 0,02 s (733 clientes, 3.286 linhas) | **1,06 a 1,19 s (60.160)** | **1,50 a 2,17 s** | **238,5 MB** (uma volta); 340,5 MB (duas voltas: ver abaixo) |
| Rede, quente | 0,01 a 0,02 s | 0,03 s | 0,02 a 0,03 s | **1,05 a 1,19 s** | 1,21 a 1,39 s | |
| Rede, só julho | 0,42 a 0,68 s (543) | 0,25 a 0,27 s (791) | 0,01 s (423, 1.181) | 0,56 a 0,62 s (31.898) | 1,38 a 1,63 s | 134,0 MB |

**As seis medidas frias da rede, como a saída as dá:** três processos de duas voltas (a primeira fria: 2,167, 1,504 e
1,964 s; a segunda quente, a linha "Rede, quente") e três de uma volta só (2,149, 1,986 e 2,098 s). A primeira redação deste
débito dava só os de uma volta (1,99 a 2,15 s); a verificação da fatia 52 mandou dar os seis. **Os 340,5 MB dos processos de
duas voltas são do medidor, e não de um pedido** -- medido na verificação, quatro processos na mesma cópia: com as leituras da
primeira volta ainda vivas enquanto a segunda lê (o roteiro como estava), 340,5 MB nas duas vezes; com elas soltas antes da
segunda (`unset` e `gc_collect_cycles`, o `medir-p52c-pico.php` do rascunho), 238,5 MB nas duas vezes, o mesmo dos processos de
uma volta. Um pedido da V1 lê uma vez: o pico dele é o de 238,5 MB. Esses quatro processos deram a rede fria de 2,06 a 2,39 s e
quente de 1,46 a 1,83 s, com a base de 1,29 a 1,55 s (o host mais carregado que na primeira medida; a conclusão é a mesma).

**A causa, medida:** a base -- `VendaRepository::relatorioItens` da rede, concluídas, normalizada pelo
`ContasDoPainelDaUnidade::normalizar` -- é a única leitura que não fica barata quente (1,05 a 1,19 s nas duas voltas): é o
volume (60.160 linhas hidratadas e a lista normalizada vivendo juntas), e não o índice. As três consultas novas da V0 ficam
abaixo de 0,7 s fria e de 0,03 s quente; a das finalizadas do dia, a que o desenho mandava vigiar ("se passar de 1 s, registre e
me diga"), ficou em 0,02 s na rede. O desenho já esperava a base cara (1,7 a 2,2 s por SQL em 02/10/2026) e decidiu um pedido
só, com a saída escrita (a escolha 6 da seção 6: se a V1 passar de 5 s na rede, a base vai a um `/canceladas/taxas` pedido
depois, pelo `usePedidoDeDepois`).

**O pico é o do D631, maior:** 238,5 MB com os dois meses, acima dos 128 MB de fábrica do PHP (o `docker/php/php.ini` do
repositório dá 512 MB). O dono, em 27/10/2026, sobre a Retirada: "Acredito que o servidor de produção permite 1024mb ou 2048mb
para o limite do php. mantenha a rede, siga o desenho" -- vale aqui pelo mesmo motivo, e o número da produção continua por ler
(o item 7 da etapa 1 de `docs/procedimento-de-corte-etapa-1.md`); se ele der 128 MB, este débito bloqueia a rede das Canceladas
antes do corte.

**O que não se fez, de propósito:** a V0 não tem rota, e a forma do pedido é da V1 (a 53). **O caminho**, se a medida da V1
pedir: a base da rede por unidade (uma consulta por unidade, o pico de uma), ou só as duas colunas que a base usa (a venda, a
unidade e o valor do item), sem a linha inteira do painel; e, na tela, a trava da rede por pessoa e a fila própria no
`filaDaRede.ts`, como a 48 e a 50.

**Medido de novo na fatia 53 (REL-CANCELADAS-TELA, a V1), pela resposta inteira da API** (o `CanceladasController` em processo,
sem HTTP, como administrador, no `franquiabv_dev` de 128 MB de buffer pool, `memory_limit` de 512M do contêiner, julho de 2026
com junho de comparação, três processos de cada; o `medir-p53-api.php` do rascunho):

| Recorte | Fria (o primeiro despacho do processo) | Quente (um pedido antes) | Pico do processo |
|---|---|---|---|
| Unidade 1 | 1,52 a 1,64 s | 0,24 a 0,29 s | 31,3 a 31,7 MB |
| Unidade 39 | 1,30 a 2,31 s | -- | 21,1 MB |
| Rede | 2,48 a 2,68 s | 1,31 a 1,33 s | **125,5 a 126,5 MB** |

**O pico caiu de 238,5 MB para 125,5 MB** porque a V1 lê a base um mês de cada vez, contado e solto antes do outro (o MV22 do
desenho; o `testABaseELidaUmPeriodoDeCadaVez` o prova pelas faixas das consultas, e o mutante P53-P4 o deixa vermelho). Ficou
abaixo dos 128 MB de fábrica do PHP, mas a 2,5 MB dele. **O tempo passa de 1 s**, e a causa foi medida por partes (o
`medir-p53-partes.php` do rascunho, a rede, quente): as canceladas, os itens delas, as finalizadas do dia e a classificação
somam 0,08 s; **a base é o resto** -- junho em 0,57 s (28.262 itens, +48 MB) e julho em 0,63 s (31.898 itens, +54 MB), mais
0,05 s de conta de cada. A fria soma ~1 s fixo do primeiro despacho em processo (a unidade 1 vai de 1,5 s fria a 0,25 s quente,
como os Descontos da 50). A rede está abaixo dos 5 s em que o desenho manda separar a base num pedido depois (a escolha 6).

**A trava da rede por pessoa e a fila `canceladas` entraram na V1** (o 429 com a frase do Parceiro; a fila do `filaDaRede.ts`),
como a 48 e a 50 -- e o caso do 429 (`testARedeEUmPedidoDeCadaVezPorPessoa`) e o da fila própria
(`a fila é a das canceladas`, no `VendasCanceladasLigado.teste.tsx`) as prendem.

**O que resta, e por isso PARCIAL:** o pico de 125,5 MB por pedido, perto do `memory_limit` de 128 MB de fábrica, com o número
da produção ainda por ler (o item 7 da etapa 1 de `docs/procedimento-de-corte-etapa-1.md`; o dono, em 27/10/2026, sobre a
Retirada: "mantenha a rede, siga o desenho"); e a base da rede em ~1,2 s quente. O caminho continua o de cima: a base da rede
por unidade, ou só as colunas que ela usa.

### D635 — A rotina marca como duplicada toda comanda do avulso — REGISTRADO em 28/10/2026 — CORRIGIDO em 07/11/2026

**O V-a de `docs/f3-relatorios/spec-canceladas.md` (seção 4), registrado na fatia 53 (REL-CANCELADAS-TELA, a V1).**
`VendaRepository::clienteTemVendaFinalizadaNoDia` não exclui os clientes 1 (o avulso) e 2 (o "SEM CADASTRO"), e a rotina
`CronJobService::comandasCancelar` grava `COMANDA_DUPLICADA` pela resposta dela. O avulso tem venda finalizada todo dia, e por
isso toda comanda dele cancelada pela rotina "é duplicada". Medido em 02/10/2026 pelo desenho: as 22 comandas canceladas com item
de sentinela em julho de 2026, na rede, seriam todas "duplicadas" pela pergunta da rotina (o desenho de antes dizia 19).
`scripts/medir-comandas-abandonadas.sql` tem a mesma pergunta, sem os sentinelas.

**O relatório não depende disso:** as Vendas Canceladas classificam na leitura (`ContasDasVendasCanceladas::classificar`), e
chamam essas de "do balcão" -- nunca de duplicada nem de corrigida. **O conserto, quando houver:** `AND ven.cliente NOT IN
(:sentinelas)` na pergunta da rotina, ou uma terceira frase para o balcão. A rotina roda em produção e grava a frase de hoje; a
mudança dela é escrita de produção e não entrou aqui.

**Corrigido em 07/11/2026 (a fatia 113, ROTINAS-NOVAS-E-CRONTAB).** Conferido antes que o defeito seguia (a pergunta da
linha 778 sem os sentinelas). `VendaRepository::clienteTemVendaFinalizadaNoDia` responde não para os dois marcadores do
sistema (`RegraDoCadastroDeCliente::SENTINELAS`, o 1 e o 2) antes de perguntar ao banco — o mesmo que o `AND ven.cliente
NOT IN (:sentinelas)` da proposta, já que a pergunta é por um cliente só —, e a rotina `comandasCancelar` grava neles a
frase da abandonada ("aberta há mais de dois dias e nunca paga"), que é o que se sabe da comanda do balcão. A terceira
frase para o balcão, a outra saída do V-a, não entrou: seria escolha do dono, e o relatório já os chama de "do balcão"
na leitura. **Provado:** `ComandaFantasmaTest::testAComandaDoAvulsoNaoViraDuplicada`, nos dois bancos — a comanda do 1 e a
do 2, com venda finalizada do mesmo marcador no mesmo dia, saem abandonadas; o cliente de verdade, no mesmo cenário, segue
duplicado. Mutante: o retorno antecipado arrancado deixa o caso vermelho nos dois bancos. Só vale na produção pelo corte
(a rotina de lá é o código de lá). `scripts/medir-comandas-abandonadas.sql`, que mede o passado, ficou como estava.

### D636 — A anotação do status zero dizia que era a comanda aberta — REGISTRADO em 28/10/2026 — CORRIGIDO em 28/10/2026

**O V-b de `docs/f3-relatorios/spec-canceladas.md` (seção 4), registrado e corrigido na fatia 53 (REL-CANCELADAS-TELA, a V1).** A
anotação estava em dois lugares: neste arquivo ("E UM FILTRO QUE ALGUÉM COMENTOU EM VEZ DE APAGAR", na seção do D97) e no
docblock de `tests/Caracterizacao/VendasCanceladasNoRelatorioTest.php`. As duas diziam que o zero de `vendas.status` "é também o
estado de comanda aberta". **Não é:** a comanda aberta é o status 3 (`RegraDaVendaNoPdv.php:31`); o 0 só sai de
`VendaService::cancelar` e da rotina `CronJobService::comandasCancelar`. O filtro comentado de `listagemIndexCanceladas` era para
separar a rotina do cancelamento à mão, e não a comanda aberta. As 484 canceladas sem motivo da cópia são todas de 2015 a 2019
(medido em 02/10/2026 pelo desenho), nenhuma desde 2025. **As duas anotações foram corrigidas neste commit**, com a data; o teste
não mudou de comportamento (ele grava o de hoje, e o de hoje está certo).

### D637 — O atalho de um mês espera 4,8 s pelo `DATE()` — REGISTRADO em 28/10/2026 — CORRIGIDO em 08/11/2026: a faixa meio-aberta no lugar do `DATE()`

**O V-c de `docs/f3-relatorios/spec-canceladas.md` (seção 4), registrado na fatia 53 (REL-CANCELADAS-TELA, a V1), com a medida
dela.** `VendaRepository::listagemIndexVendas` filtra com `DATE(ven.dataCriacao)`, e `GET /adm/api/vendas` a chama três vezes por
página (a lista e os dois totais, `Adm/VendasController::indexAction`). **Medido na cópia** (o `Adm\VendasController` em processo
como o gerente da unidade 1, `?de=2026-07-01&ate=2026-07-31&estado=canceladas`, três processos; o `medir-p53-vendas.php` do
rascunho): **4,78 a 4,87 s**, 74 vendas. O desenho tinha medido 5,3 a 6,6 s numa contagem simplificada com `DATE()`, contra 0,03 a
0,07 s com a faixa meio-aberta. **É o botão que a V1 pôs no relatório**, e é a única tela nova que abre Vendas Realizadas num mês
inteiro de canceladas. **O conserto**, numa fatia de Vendas Realizadas: a faixa meio-aberta no `listagemIndexVendas`, com os
testes de caracterização da listagem (`ListagemDeVendasTest`). Não é desta fatia.

#### CORRIGIDO em 08/11/2026 -- a fatia 121 (DATAS-NOS-FILTROS)

`VendaRepository::filtroDoPeriodo`, novo, no molde do `filtroDeDesconto`: `ven.dataCriacao >= '<início> 00:00:00' AND
ven.dataCriacao < '<dia seguinte ao fim> 00:00:00'`, o dia seguinte pelo calendário (`createFromFormat('!d/m/Y')` e `+1 day`).
As três consultas de Vendas Realizadas o leem -- a `listagemIndexVendas` (a lista e os dois totais) e as duas da exportação
(as três abas) --, e a do relatório de descontos fica como estava (não é deste débito). A data fora da forma continua pelo
`DATE()` com o `stringDate`, como antes: os dois chamadores já a trocam pela de hoje (o `dataValida` da tela antiga, a
`RegraDaListagemDeVendas` da `/adm/api`), e a faixa precisa de um dia de verdade.

**Medido** na cópia (o `Adm\VendasController` em processo como o gerente da unidade 1, `?de=2026-07-01&ate=2026-07-31&estado=canceladas`,
o `medir-p53-vendas.php` do rascunho, só leitura, no Mac): antes, 1,17 a 2,66 s no primeiro pedido e 1,08 a 1,11 s com o
processo aquecido; depois, 0,049 a 0,061 s e 0,009 s. A resposta igual byte a byte (74 vendas, os mesmos totais). O banco usa
o `idx_vendas_data_status`. O 4,8 s do registro era do Windows.

**Provado:** quatro casos novos no `ListagemDeVendasTest`, verdes nos dois bancos: a meia-noite do início e o último segundo
do fim dentro, o último segundo da véspera e a meia-noite seguinte fora, na lista, no total e nas três abas; o dia sozinho;
a virada do ano (31/12 não pega 01/01); e nenhuma consulta das Vendas Realizadas aplica função à data (`DATE(` no MySQL,
`CAST(... AS date)` no PostgreSQL). Mutantes vermelhos nos dois bancos: `<=` no fim (M10), sem o dia seguinte (M11), `>` no
início (M12), o início de volta ao `DATE()` (M13; a primeira redação do caso só procurava `DATE(` e ficava verde no
PostgreSQL, onde o `DateFunction` escreve `CAST`).

**A verificação (08/11/2026) achou dois buracos, consertados:**
- **o fim em 31/12/9999:** o `dataValida` e a regra da `/adm/api` aceitam o ano 9999, a faixa somava um dia e mandava
  `10000-01-01 00:00:00`, e o MySQL recusa esse DATETIME com 1525 (medido na cópia, só a contagem; antes, o
  `DATE() <= '9999-12-31'` respondia) -- a lista, os totais e a exportação davam 500 no MySQL. O fim cujo dia seguinte passa
  do ano 9999 fica no `DATE()`, que responde nos dois bancos; o início não tem o problema (`>= '0001-01-01 00:00:00'` responde);
- **o ramo da data fora da forma** não tinha caso: forçar a faixa nele (o `createFromFormat` devolve `false` e o `modify`
  dá fatal) ou apagá-lo (o período some) ficava verde.

Dois casos novos no `ListagemDeVendasTest`, nos dois bancos: `testADataForaDaFormaSegueOCaminhoAntigo` (o traço no lugar da
barra lê o mesmo período na lista, no total e nas três abas) e `testOFimEm31De12De9999Responde`. Mutantes, devolvidos pelo
sha1: sem o teto do ano (vermelho no MySQL; verde no PostgreSQL, que aceita o ano 10000, como deve), a faixa forçada na data
fora da forma, o ramo antigo do fim apagado e o do início apagado (vermelhos nos dois bancos).

### D638 — Sem item, a desfeita e a vazia ficam fora das consultas de item — REGISTRADO em 28/10/2026

**O V-d de `docs/f3-relatorios/spec-canceladas.md` (seção 4), registrado na fatia 53 (REL-CANCELADAS-TELA, a V1).** A comanda
vazia cancelada e a compra de crédito desfeita não têm linha em `vendas_produtos`, e por isso nenhuma consulta de item as traz
(`relatorioItens`, `painelItens`). Em julho de 2026, na rede, são 49 das 543 canceladas: 47 comandas vazias e 2 compras de
crédito (R$ 560,00), pelo desenho. **As Vendas Canceladas as contam em `foraDaRegua`** ("Fora da conta: N comandas vazias e M
vendas sem item (K compras de crédito, R$ V) não entram nos números.", só com as partes que têm número), pela consulta que traz a venda sem item
(`VendaRepository::canceladasVendas`). **Vendas Realizadas as soma junto** (o S23), e a frase ao lado do "ver as vendas" diz isso
("A lista de lá mistura as desfeitas, as comandas e as vendas sem item, e soma o total de cada venda."). Nada a consertar no
relatório; o débito é a diferença entre as duas telas, dita nas duas.

### D639 — Gerente com o papel de caixa: o botão promete o mês e a outra tela trava em hoje — REGISTRADO em 28/10/2026 — CORRIGIDO em 29/10/2026

**Achado na fatia 53 (REL-CANCELADAS-TELA, a V1).** Os relatórios novos tratam o grupo com as marcas de gerente E de caixa como
gerente ("vence a de gerente", a 1.1 do `docs/f3-relatorios/spec-comum.md`), e por isso ele vê as Vendas Canceladas da unidade.
Vendas Realizadas, ao contrário, trava quem tem a marca de caixa -- mesmo gerente -- nas vendas **ativas de hoje**
(`RegraDaListagemDeVendas::interpretar`, a regra 1, com `doCaixa` lido de `sessao->getUsuario('caixa')` no
`Adm\VendasController`). O "ver as vendas canceladas de <mês>" leva essa pessoa a uma lista que não é a prometida (a tela de lá
avisa: "Quem é do caixa vê só as vendas concluídas de hoje."). Os Descontos (a fatia 51) têm o mesmo caso com o "ver as vendas"
deles.

**Medido na cópia** (só contagens, sem as fixtures, o `p53-quemve.sql` do rascunho): **nenhum grupo real tem as duas marcas** (os
grupos 3 e 7 são gerente sem caixa; o 4, administrador e gerente sem caixa). Só o grupo de teste 90001001 (o `gerente.teste`) as
tem. Hoje não atinge ninguém; atinge quem um dia ganhar um grupo assim. **A tela não decide:** a pergunta foi ao lote
(`perguntas-do-lote.md`, "TELAS-RESTANTES [vendas] (28/10/2026) -- Vendas Canceladas: o gerente que também é do caixa, no "ver as
vendas" (D639)"). Até a resposta, o botão aparece a ele, como nos Descontos, e Vendas Realizadas diz o que aplicou.

**CORRIGIDO em 29/10/2026 (a fatia 57b, RESPOSTAS-270-271), pela resposta 271 (c) do dono (28/10/2026, "Em Vendas Realizadas,
a marca de gerente vence a de caixa").** A regra é a MESMA dos relatórios, e não uma cópia: o passo 4 do
`RecorteDoRelatorio::escopo` virou o `RecorteDoRelatorio::soDoCaixa` (o caixa sem a marca de gerente nem a de administrador, pelas
marcas da sessão), e Vendas Realizadas o chama nas DUAS telas -- a API (`Adm\VendasController::indexAction`, que passava
`!empty(getUsuario('caixa'))` à `RegraDaListagemDeVendas`) e a lista da tela antiga (`VendasController::indexVendasAction`, que
tinha a mesma trava pelo `$usuario['caixa']` cru: desligava as datas e a situação e as reescrevia para hoje). O gerente com a
marca de caixa escolhe o período e a situação; o caixa que não é gerente continua só com as concluídas de hoje (na tela nova, com
o aviso "Quem é do caixa vê só as vendas concluídas de hoje"). **O administrador com a marca de caixa** também escolhe, e isso
não é da letra da 271 (c), que só fala do gerente: vem da regra dos relatórios que a opção manda seguir ("como nos relatórios"),
cujo passo 1 trata o administrador antes do caixa; o `soDoCaixa`, por ser a mesma regra, o leva junto. Hoje não atinge ninguém
(o único grupo de administrador já tem a marca de gerente; nenhum tem a de caixa). O "ver as vendas" dos Descontos e das
Canceladas não mudou: ele já leva o período, e o do gerente com caixa passa a cair no mês inteiro.

**Quem muda de tela** (medido na cópia `franquiabv_dev` em 29/10/2026, só contagens e sem as fixtures, o `p57b-quemve.sql` do
rascunho): de 366 ativos, **ninguém**. Nenhum grupo com id fora das fixtures tem a marca de caixa junto com a de gerente ou a de
administrador (0 grupos, de qualquer status); os 61 ativos do único grupo de caixa (sem gerente) continuam travados; os 108
gerentes e os 10 administradores não têm a marca de caixa. Só o grupo de teste 90001001 (o `gerente.teste`) tem as duas.

**Provado:** o `D639GerenteComCaixaEmVendasRealizadasTest` (sete casos, as duas telas em processo: o gerente e caixa e o
administrador com caixa, com e sem a marca de gerente, recebem o período pedido; o caixa sem gerente, hoje e as ativas, com os
campos da tela antiga desligados; e, desde a verificação, o `testAAcaoDaListaChamaOSoDoCaixaENaoUmaCopia`, que lê sem os
comentários a ação da lista de cada tela e exige a chamada ao `soDoCaixa` e nenhuma marca lida à mão -- uma cópia da regra seria
equivalente hoje e divergiria na primeira mudança dela) e o `testSoDoCaixaEOCaixaSemGerenteNemAdministrador` do
`RegrasDoRecorteDoRelatorioTest`, nos dois bancos; os mutantes P57b-X5 a X11 e X13 a X15. Três ensaios HTTP diziam o comportamento antigo do `gerente.teste` (travado em hoje): o
passo 12b do `ensaiar-fluxo-adm-api.php`, o caso 9h do `ensaiar-recusas-adm-api.php` (que agora pede o ano corrente, e não 2026
escrito) e o comentário do `medir-adm-api-no-postgres.php`; os três dizem a regra nova e rodaram verdes nos dois bancos de contrato.

### D640 — O campo `quitada` das faturas de royalties nunca é escrito, e a rotina das 12:30 consulta no Asaas as que já foram pagas em dinheiro — REGISTRADO em 28/10/2026 — CORRIGIDO em 07/11/2026

**O débito 2 de `docs/f3-relatorios/spec-royalties.md` (seção 4), registrado na fatia 54 (REL-ROYALTIES, a R1).** O único
`setQuitada` (`RoyaltiesFatura.php`) não tem chamador, e `RoyaltiesFaturaRepository::selecionarPorTodasNaoQuitadas` compara só com
`RECEIVED`: as faturas `RECEIVED_IN_CASH` (8 na cópia em 02/10/2026) voltam ao Asaas todo dia às 12:30
(`docs/f1-6-agendamento.md`). **O relatório novo não lê o `quitada`:** a situação é a do Asaas (`asaas_status`), e o
`RelatorioDeRoyaltiesTest` semeia uma fatura com `quitada` verdadeiro que aparece como as outras (o mutante P54-X5, que passa a
ler o campo, fica vermelho). **O que falta, e não é desta fatia:** a rotina das 12:30 deixar de consultar a fatura paga em
dinheiro -- mexe na rotina que fala com o Asaas, que nenhuma fatia de tela roda.

**Corrigido em 07/11/2026 (a fatia 105, ROYALTIES-E-TRAVAS-DO-CRON).** Conferido antes: a consulta ainda comparava só com
`RECEIVED`. Agora `selecionarPorTodasNaoQuitadas` deixa de fora as duas situações que quitam
(`RoyaltiesFaturaRepository::SITUACOES_QUITADAS`: `RECEIVED` e `RECEIVED_IN_CASH`), e a rotina das 12:30 não pergunta mais
ao Asaas pela fatura paga em dinheiro. A de situação nula fica de fora como antes. **O campo `quitada` fica como está**:
ninguém o lê nem o escreve, e o `setQuitada` sem chamador não saiu (tirá-lo não muda nada para quem usa).

**Provado só no repositório, nunca na rotina** (`D640FaturaPagaEmDinheiroTest`, nos dois bancos): oito faturas, uma por
situação; a consulta devolve a aguardando, a vencida, a confirmada, a em cobrança e a pendente de emissão, e não a recebida,
a recebida em dinheiro e a sem situação. **Mutante:** a consulta de volta ao `!= 'RECEIVED'`, vermelho nos dois bancos.

### D641 — O período padrão da tela antiga dos Royalties se inverte em 7 dias do ano — REGISTRADO em 28/10/2026 — PARCIAL em 28/10/2026

**O débito 3 de `spec-royalties.md` (seção 4).** `FranquiasController::royaltiesAction` monta o padrão com
`strtotime('-1 month')` sobre o dia: em 29, 30 e 31 de março, 31 de maio, julho, outubro e dezembro (6 dias no bissexto) o
"16 do mês anterior" cai no próprio mês, o período sai invertido e a tela mostra tudo zerado. **Na tela nova está certo**
(`RegraDosRoyalties::ciclo`, pelo primeiro dia do mês anterior; os sete dias de 2026 e de 2028 no
`RegrasDosRoyaltiesTest::testOCicloPadraoNosDiasDoD209`, o mutante P54-P2). **Resta a tela antiga**, que sai na R5 (depois de
o dono aceitar a nova); até lá, ela só é aberta pelo administrador (a política, a fatia 54).

### D642 — A linha da rede da tela antiga dos Royalties soma as taxas do crédito das unidades — REGISTRADO em 28/10/2026 — PARCIAL em 28/10/2026

**O débito 4 de `spec-royalties.md` (seção 4).** `royalties.phtml` soma as porcentagens do crédito do Programa de Fidelidade de
cada unidade na linha da rede ("12,61%" em julho de 2026, quando o certo é 0,52%). **Na tela nova está certo:** a taxa da rede é
o total do crédito sobre o total do faturamento (`ContasDosRoyalties::rede`, e a `taxa` do painel no `RoyaltiesJson`; o caso
"1.5" do `RegrasDosRoyaltiesTest::testARede`, o mutante P54-P12). **Resta a tela antiga**, que sai na R5.

### D643 — A tela antiga dos Royalties conferia o privilégio, e o menu novo o papel — REGISTRADO em 28/10/2026 — CORRIGIDO em 28/10/2026

**O débito 5 de `spec-royalties.md` (seção 4).** O item "Royalties" do `layout.phtml` e a `royaltiesAction` abriam a quem tivesse
`relatorios/franquias/ler`; o menu novo exigia o administrador. **Corrigido na fatia 54, nos dois ERPs:** a ação e o item do
layout aplicam a política `royalties` (`RecorteDoRelatorio::escopo` e `::veOMenu`, só o administrador), como a API nova. Medido na
cópia (só contagens, ativos, sem as fixtures, o `p54-quemve.sql` do rascunho): de 366 ativos, viam a tela antiga 10 e veem 10 --
os administradores do grupo 4; **ninguém perde a porta** (nenhum não administrador tinha o privilégio). O gerente com o
privilégio recebe o 404 na tela antiga e o 403 `papel` na API (`RelatorioDeRoyaltiesTest`, os mutantes P54-P25, P54-X8 e
P54-X27).

### D644 — R$ 8.621,00 de receita fora de toda categoria do royalty desde 2024 — REGISTRADO em 28/10/2026

**O débito 8 de `spec-royalties.md` (seção 4), com o ponto P-R1 ao dono.** Tudo o que não é `ser`, `pac`, `probar`, `proemp` ou
`lavavip` (o insumo vendido com preço, `proins`, e outros) entra no faturamento e não paga royalty: `fat_total` menos as quatro
categorias do retrato somava R$ 8.621,00 desde 2024 (medido pelo desenho, 02/10/2026). **A tela nova o mostra** na coluna "Sem
alíquota" (só quando alguma linha tem valor nela) e no "Como se conta". **Não decidido:** se o insumo vendido paga royalty é do
dono e do contrato (o P-R1); a resposta mudaria só `RegraDosRoyalties::CATEGORIAS`. A fatia 54 não o resolveu, como o roteiro
mandou.

### D645 — A tela de Faturas do gerente deixa em branco duas situações do Asaas — REGISTRADO em 28/10/2026 — PARCIAL em 28/10/2026

**O débito 9 de `spec-royalties.md` (seção 4).** `Arrays::faturasStatus` não conhece `RECEIVED_IN_CASH` (8 faturas na cópia) nem
`DUNNING_REQUESTED` (1), e a tela antiga de Faturas as mostra em branco. **As palavras certas já existem** na tela nova
(`frontend/src/telas/textosDosRoyalties.ts`, `SITUACOES_DA_FATURA`: "Quitada em dinheiro" e "Em negativação", e o código como
veio para o que não estiver lá), e os Royalties as usam desde a fatia 54; **a tela de Faturas do gerente é a R2** (a fatia 55,
FATURAS-DO-GERENTE), que importa o mesmo `Record`.

**Na tela nova está certo desde a fatia 55 (FATURAS-DO-GERENTE, a R2; 28/10/2026):** o item Faturas do menu do usuário abre o
`FaturasLigado`, que diz a situação pelo mesmo `situacaoDaFatura` dos Royalties -- "Quitada em dinheiro", "Em negativação", o
erro do Asaas ao emitir ("Erro ao emitir no Asaas", com o texto gravado) e o código como veio para o que nenhuma das duas
conhece (`FaturasLigado.teste.tsx`, "as situações", e o mutante P55-X8; a API devolve o código, `FaturasDaUnidadeTest::
testOsCamposDaFatura`, nos dois bancos). **Resta a tela antiga** (`/mod-cadastros/faturas/index`, o `FaturasController` e o
`Arrays::faturasStatus`), que fica como está até o corte, como o desenho manda; o menu novo não a abre mais.

### D646 — Os Royalties da rede levam mais de 1 s no primeiro pedido, e um relatório da rede que passa da metade do `memory_limit` cai no cache de metadados do Doctrine — REGISTRADO em 28/10/2026

**Medido na fatia 54 (REL-ROYALTIES), em processo, na cópia** (o `franquiabv_dev`, MySQL, o `medir-p54-api.php` do rascunho, três
processos de cada, o primeiro pedido e um segundo no mesmo processo): **o ciclo `2026-07` da rede em 1,70 a 1,91 s frio e 0,64 a
0,87 s quente, com 75,9 MB de pico**; a unidade 1 em 1,05 a 1,19 s frio e 0,16 a 0,17 s quente, com 22,8 a 23,1 MB. Os outros
ciclos do oráculo: `2025-12` com 89,7 MB, `2024-10` com 84,3 MB. Acima do teto de 1 s do roteiro na rede fria (e na unidade fria,
pelo custo fixo do primeiro despacho, como nos Descontos); abaixo de 128 MB. **A trava da rede por pessoa** (`royaltiesDaRede-<id>`,
o 429) e a fila `royalties` do `filaDaRede.ts` entraram no mesmo commit, como na 48, na 50 e na 53.

**O pico foi baixado nesta fatia, e a causa foi medida:** a primeira versão lia os itens concluídos do ciclo inteiro da rede de
uma vez, como o D631 e o D634: **122,5 MB no `2026-07`, 139,1 MB no `2025-12` e 140,3 MB no `2024-10`** -- acima de 128 MB. Os
itens passaram a ser lidos em duas metades do ciclo (`ContasDosRoyalties::partes`) e somados por unidade e dia
(`::somarItens`), sem guardar a lista; os números são os mesmos (o oráculo, 2.182 conferências, 0 divergências, antes e depois).

**E um achado que vale para todo relatório da rede:** com `memory_limit = 128M`, o ciclo `2025-12` da rede caía num
`Laminas\Cache\Exception\OutOfSpaceException` ("Memory usage exceeds limit (67108864)") -- o cache de metadados e o das consultas
do Doctrine (o adaptador de memória do DoctrineModule) recusam guardar quando o processo já usa mais da metade do limite
(`memory_get_usage(true)`), e a classe ou a consulta nova carregada DEPOIS do pico dos itens derruba o pedido, mesmo com o pico
abaixo do limite. Nos Royalties, as leituras leves (as canceladas, o crédito, o retrato, as faturas e as unidades) passaram a
vir antes dos itens, e a rede passa com 128 MB nos três ciclos (75,9 a 89,7 MB). **Não medido nos outros relatórios da rede**
(a Retirada, os Descontos, as Canceladas): o `docker/php/php.ini` dá 512 MB (o limite do cache fica em 256 MB), e o dono disse
em 27/10/2026 que a produção dá 1.024 ou 2.048 MB (o D631) -- o número da produção ainda não foi lido (o item 7 da etapa 1).
Se der 128 MB, este débito e o D631 voltam a bloquear antes do corte.

### D647 — As Faturas do gerente passam de 0,5 s no primeiro pedido do processo — REGISTRADO em 28/10/2026

**Medido na fatia 55 (FATURAS-DO-GERENTE, a R2 de `spec-royalties.md`), em processo, na cópia** (o `franquiabv_dev`, MySQL, o
`medir-p55-api.php` do rascunho, um processo novo por medida, como gerente da unidade): **a primeira página de `GET
/adm/api/faturas` leva de 0,19 a 0,79 s no primeiro pedido do processo** (a unidade 5, 29 faturas: 0,24 a 0,39 s; a 13: 0,75 e
0,79 s; a 58, 7 faturas: 0,19 a 0,69 s; a segunda página da 5: 0,35 a 0,54 s) **e de 5 a 17 ms no segundo**, com 14,7 a 16,1 MB
de pico e quatro consultas, todas `SELECT` (as três do paginador da tela antiga -- a contagem, os ids da página e a página -- e
uma ao retrato da página inteira). O desenho manda registrar acima de 0,5 s. **A causa é o custo fixo do primeiro despacho** (os
metadados e os proxies do Doctrine, o mesmo da unidade fria nos Descontos e nos Royalties, o D646), e não as consultas: o segundo
pedido, com as mesmas quatro, é cem vezes mais rápido. Não se mexeu em nada: a tela antiga paga o mesmo custo, e o opcache e o
cache de metadados da produção (o item 7 da etapa 1) decidem o número de lá.

### D648 — Uma pesquisa de NPS por venda avaliada, e não por envio do formulário — REGISTRADO em 28/10/2026 — PREPARADO em 07/11/2026: o número do envio gravado e lido

**O D-n1 de `docs/f3-relatorios/spec-nps.md` (a seção 4), registrado na fatia 56 (REL-NPS-CONTAS, a N1).** O envio grava uma
linha de `nps` por venda avaliada, com as mesmas notas de recepção e de experiência (`PesquisaNpsService.php:20-45`), cada uma
com o relógio da própria construção (`Nps.php`, o `__construct`) e um `flush` próprio. Medido em 02/10/2026, na cópia: em julho
de 2026, **317 pesquisas para 296 envios na rede**; de janeiro a julho de 2026, 165 pares do mesmo cliente no mesmo segundo, 6
entre 1 e 5 s e 14 entre 6 e 60 s com as mesmas notas (o formulário enviado de novo); e 2 envios no mesmo segundo gravaram
pesquisas em duas unidades. Toda média por pesquisa conta o mesmo envio várias vezes -- a rotina de hoje faz isso
(`CronJobService::npsContabilizar`).

**O relatório novo conta pela cadeia de 60 s** (a decisão E1: `RegraDoNps::JANELA_DO_ENVIO`, `ContasDoNps::envios`; o caso de
duas unidades conta um envio em cada). O conserto é o id do envio gravado na pesquisa -- coluna nova em `nps`, que só acrescenta:
a resposta 83 (a) do dono a autoriza nas duas cópias locais, com a migração no laço do passo 0 de `docs/procedimento-de-corte.md`
e a escrita no `PesquisaNpsService`. Quando existir, `ContasDoNps::envios` agrupa por ela, e a cadeia fica só para as respostas
antigas. Não é desta fila de telas: espera quem tocar a pesquisa de satisfação.

**Preparado em 07/11/2026 (a fatia 114, ESQUEMA-QUE-SO-ACRESCENTA), pela resposta 83 (a) do dono.** Conferido antes: a `nps`
não tinha coluna de envio nos dois bancos da cópia (37.957 pesquisas), e o `PesquisaNpsService::salvar` gravava uma linha por
venda avaliada, sem nada que as ligasse. Entrou:

- **a coluna** `nps.envio varchar(32)`, nula, sem padrão, sem índice: `scripts/migracao-nps-envio.sql` (`ALGORITHM=INSTANT`,
  idempotente) na linha `| 0 | **Aplicar**`, no laço do passo 0 e no do ensaio geral; o `mysql.sql`; o `postgres/schema.sql`
  regerado; o `scripts/migrar-dados.sh` regerado (só a `nps` mudou nele: copia a coluna); nas duas cópias às 18:56 (o
  PostgreSQL por `postgres/migracao-nps-envio.sql`), as 37.957 com a coluna nula. **É condição de subir:** a entidade
  (`ISCadastro\Entity\Nps::$envio`) a mapeia, e sem ela toda leitura e gravação de `nps` falha -- dito na linha do passo 0
  e no passo 4 da etapa 1;
- **a escrita:** o `PesquisaNpsService::salvar` sorteia um número por chamada (`bin2hex(random_bytes(16))`, 32 algarismos
  hexadecimais) e o grava em toda linha que o envio cria. Sorteado, e não o id da primeira linha: cada linha nasce com ele,
  sem um `UPDATE` depois do `flush`;
- **a leitura:** o `NpsItemRepository::relatorioRespostas` devolve o `envio`, o `ContasDoNps::normalizar` o leva, e o
  `ContasDoNps::envios` junta as pesquisas pelo número (com o cliente e a unidade: o envio que gravou em duas unidades conta um
  em cada, como a decisão E1), a qualquer distância no relógio e com quaisquer notas. **A cadeia de 60 s ficou só para as de
  antes da coluna**, e só entre elas: a de antes depois de uma com número começa envio novo. O docblock da
  `RegraDoNps::JANELA_DO_ENVIO` diz isso.

Nenhum número do relatório muda nas pesquisas de antes (a cópia toda tem o número nulo). A rotina do NPS
(`CronJobService::npsContabilizar`) segue contando por pesquisa, como o corpo deste registro diz; não foi tocada.

**Os casos:** `D648UmEnvioPorFormularioTest` (3, nos dois bancos: as duas linhas de um envio com o mesmo número de 32
hexadecimais e os três itens; outro envio no mesmo segundo, outro número; o relatório lê o número e conta um envio com as
duas linhas a cinco minutos uma da outra) e o `RegrasDoNpsTest::testOEnvioPeloNumeroGravado` (sem banco: o mesmo número a 10
minutos, dois números no mesmo segundo, a de antes com a de número dos dois lados, duas unidades, e um envio com outro no meio
do relógio). **Os mutantes**, um por vez, devolvidos pelo sha1: sem o `setEnvio` no serviço (os três do D648 vermelhos, nos
dois bancos), o `envios` ignorando o número (o caso sem banco e o do relatório) e a consulta sem o `nps.envio` (o do
relatório, nos dois bancos); a migração com a coluna `int` (o controle do portão do passo 0).

**O que falta é da janela:** rodar a migração na produção, pelo laço (o passo 4 da etapa 1), antes do código novo.

**A correção da verificação (07/11/2026).** Quatro achados, consertados:

- **a migração rodada**, e não só lida: num banco descartável (apagado no fim), com a `nps` da cópia sem a coluna (37.957
  linhas), acrescenta em 0,07 s, todas com ela nula, e a segunda vez "já existe" em 0,04 s; e a cada suíte, no MySQL,
  `EsquemaQueSoAcrescentaTest::testAMigracaoDoEnvioAcrescentaAColunaUmaVez` (o mutante que inverte o `IF(@existe = 0` fica
  vermelho nele);
- **a de antes depois de uma que entrou pelo número**: a linha que faz da pesquisa juntada pelo número a anterior não tinha
  caso, e tirá-la deixava tudo verde (o mesmo cliente com `x`, uma de antes, `x` de novo e outra de antes, a 10 s cada: sem
  ela, a última de antes entrava na cadeia da primeira e saíam 2 envios, e não 3). O caso
  `RegrasDoNpsTest::testADeAntesDepoisDaQueEntrouPeloNumeroComecaEnvioNovo`; o mutante que tira a linha, vermelho nele nos
  dois bancos;
- **o texto que quem usa lê**: o glossário da tela (`nps.envio`, `frontend/src/telas/glossario.tsx`) ainda dizia só a
  cadeia de 60 s. Agora diz as duas: as pesquisas de um mesmo envio, marcadas pelo sistema, contam como um só na mesma
  unidade, e as antigas seguem a cadeia. O mesmo no comentário de `frontend/src/api/nps.ts`, no texto do glossário e na
  decisão E1 de `docs/f3-relatorios/spec-nps.md`. O caso é o do glossário (`glossario.teste.tsx`, a frase exata); o mutante
  com a frase de antes, vermelho nele;
- **o mutante do `envios` sem o número também no PostgreSQL** (antes só no MySQL): vermelhos o caso do relatório e os dois
  sem banco.

### D649 — O Yungas recebe 0 quando a unidade não tem pesquisa, e recebe a média de três médias — REGISTRADO em 28/10/2026

**O D-n2 de `spec-nps.md`.** `$data['nps'] = !empty($nps) ? round($nps->getNotaGeral(), 2) : 0;` (`CronJobService.php`, a
rotina do Yungas): a unidade sem resposta no mês aparece com nota zero -- a pior possível --, e a que tem resposta, com a média
das três médias da rotina, que não é nenhuma das três notas do relatório novo. A decisão fica para quando a rotina for aposentada
(a N4 do dono, de 01/10/2026). Nada mudou; registrado para não se perder.

### D650 — Vendas Realizadas mostra a `nota_media` da rotina do NPS, que não é nenhuma das três notas — REGISTRADO em 28/10/2026

**O D-n3 de `spec-nps.md`, sobra do S08.** A rotina grava em `vendas.nps` a média composta (`CronJobService::npsContabilizar`), e
a lista de vendas a mostra (`VendaJson`, o campo `nps`). O número não é a recepção, a experiência nem a nota do colaborador do
relatório novo (a decisão 8 do dono). Não mudou nesta fatia: a lista de vendas é outra tela, e a consolidação da loja escolhida
continua como as respostas 17 (b) e 44 (a) a deixaram (o D428).

### D651 — A tela antiga do NPS ignora `contabilizado` no detalhe da unidade e do colaborador — REGISTRADO em 28/10/2026

**O D-n4 de `spec-nps.md`**, anotado em `docs/anomalias-calculo.md`: `NpsRepository::unidadeDetalhes` e
`NpsItemRepository::colaboradorDetalhes` (que esta fatia não mudou, de propósito: são a tela antiga e a rotina) listam também a
resposta ainda não consolidada, e o cartão da mesma tela, não. A API nova lê a base consolidada
(`NpsItemRepository::BASE_DAS_RESPOSTAS`) nas três consultas. Morre com a tela antiga.

### D652 — O TXT antigo do NPS sai da lista guardada na sessão, e não do pedido — REGISTRADO em 28/10/2026 — PARCIAL em 28/10/2026: a API nova tira o TXT do pedido; a tela antiga até sair — PARCIAL em 29/10/2026: a tela nova no menu

**O D-n5 de `spec-nps.md`.** `PesquisasNpsController::txtAction` lê a lista que o último detalhe aberto guardou na sessão: duas
abas, ou o detalhe de um colaborador seguido do TXT da unidade, dão o arquivo de outra lista. **Na API nova (a fatia 56)** o TXT
(`GET /adm/api/relatorios/nps/exportacao?formato=txt`) sai do pedido, pelo mesmo recorte e pelo MESMO filtro da lista
(`NpsController::contatos` chama o `montarClientes` do `clientesAction`; o filtro passa pelo `guardasDoRelatorio`, e o ruim é
422 antes de soltar a sessão), provado no `NpsDoRelatorioTest` (`testOTxtPassaPeloFiltroDaLista`) e pelos mutantes P56-P18 e
P56-X12. **Resta a tela antiga**, que continua com o defeito até a 57 (a N2) trocar o item do menu, e até ela sair.

**Em 29/10/2026 (a fatia 57, a N2):** o item NPS do menu da tela nova abre a tela nova (`NpsLigado`), cujo TXT sai do pedido, com o
mesmo recorte e o mesmo filtro da lista na tela (`enderecoDoTxtDoNps`; o P57-P7 fica vermelho sem os filtros). A tela antiga
continua no menu do ERP antigo e pelo endereço -- desde a 57, só para o gerente e o administrador (a política nas quatro ações,
`PesquisasNpsEscopoTest`) --, com o TXT da sessão. **Resta ela sair.**

### D653 — As faixas antigas do NPS se sobrepõem no 8 e têm vãos na média — REGISTRADO em 28/10/2026 — PARCIAL em 28/10/2026: a API nova tem as faixas inteiras por pergunta; a tela antiga até sair — PARCIAL em 29/10/2026: a tela nova no menu

**O D-n6 de `spec-nps.md` (o S09).** A tela antiga põe a nota 8 em promotor e em passivo ao mesmo tempo
(`NpsRepository::unidadeDetalhes`), e a rotina aplica fronteiras inteiras a uma média fracionária (`CronJobService`), deixando
vãos (8,5 não é passivo nem promotor). **Na API nova** cada nota inteira cai numa faixa só -- 0 a 6, 7 a 8, 9 a 10
(`RegraDoNps::faixa`, o caso do 8 no `RegrasDoNpsTest`, o mutante P56-P1) --, e a faixa do cliente é a da PIOR nota, e não da
média (o P56-X3). **Resta a tela antiga**, até sair.

**Em 29/10/2026 (a fatia 57, a N2):** a tela nova mostra as faixas inteiras de cada pergunta ("0 a 6: 1 · 7 a 8: 3 · 9 a 10: 38") e
a faixa de cada cliente pela pior nota, no filtro da lista; o item NPS do menu da tela nova abre ela. A tela antiga continua no
menu do ERP antigo e pelo endereço, só para o gerente e o administrador desde a 57, com as faixas de antes. **Resta ela sair.**

### D654 — O contato do NPS sai no TXT sem olhar o consentimento de marketing do cliente — REGISTRADO em 28/10/2026

**O D-n7 de `spec-nps.md` (o S29), decidido pelo dono em 01/10/2026 (a pergunta 10, "como hoje").** O TXT
`primeiroNome;telefone` (mais `;email` para o administrador) não filtra `clientes.marketing`, nem na tela antiga nem na API nova
(`TextoDoRelatorio::contatos`, sem mudança). Medido em 02/10/2026: 3 dos 290 clientes da rede que responderam em julho de 2026
não aceitaram marketing. Nada a fazer sem nova decisão; registrado para a decisão não se perder.

### D655 — O NPS da rede passa de 1 s no primeiro pedido, e a primeira versão subia a 122 MB e caía com 128 MB — REGISTRADO em 28/10/2026

**Medido na fatia 56 (REL-NPS-CONTAS, a N1 de `spec-nps.md`), em processo, nas duas cópias** (o `franquiabv_dev` do MySQL e o do
PostgreSQL -- este pela conexão do `local.postgres.php.dist` posta só no processo, sem trocar o `local.php` --, o
`medir-p56-api.php` do rascunho, três processos de cada, o primeiro pedido e um segundo no mesmo processo, julho de 2026, como
administrador, com `memory_limit = 128M`): **o mês da rede (`GET /adm/api/relatorios/nps?unidade=todas`) em 1,60 a 2,27 s frio e
0,48 a 0,52 s quente no MySQL, 1,69 a 1,84 s e 0,37 a 0,43 s no PostgreSQL, com 73,6 MB de pico**; dezembro de 2025, o mês mais
pesado medido, com 94,5 MB. A unidade 1: 0,60 a 1,40 s frio e 0,11 s quente no MySQL, 0,73 a 1,25 s e 0,06 a 0,07 s no
PostgreSQL, 23,4 a 23,6 MB. Os 12 meses da rede (antes do conserto abaixo, que não os toca): 0,37 a 1,30 s frio, 0,04 a 0,05 s
quente, 23,2 MB; a lista da rede: 0,54 a 1,24 s frio, 0,01 a 0,03 s quente, 16,9 MB; o TXT da rede: 0,93 a 1,59 s frio, 16,8 MB.
Os tetos do desenho eram 1,5 s na unidade, 3 s e 128 MB na rede e 1 s nos 12 meses e na lista. **O mês da rede e a unidade ficaram
dentro dos tetos deles; os 12 meses e a lista da rede PASSARAM do teto de 1 s no primeiro pedido** (até 1,30 s e 1,24 s no MySQL,
1,04 s na lista do PostgreSQL), e o TXT da rede, que não tem teto próprio no desenho, chegou a 1,59 s. Quente, os três ficam entre
0,01 e 0,05 s.

**A causa dos 12 meses, da lista e do TXT, medida na verificação da fatia (29/10/2026, `medir-p56-api2.php` e
`medir-p56-fixo.sh` do rascunho, três processos de cada, nos dois bancos, julho de 2026, 128 MB): o custo fixo do primeiro pedido
do processo, e não a rede.** As consultas desses pedidos somam de 0,010 a 0,065 s (o `DebugStack`), contra 0,88 a 1,57 s do
pedido frio. Os 12 meses da UNIDADE 1, que leem uma fração das respostas, custam o mesmo frios (0,82 a 1,62 s no MySQL, 1,04 a
1,23 s no PostgreSQL) que os da rede (0,88 a 1,38 s e 1,04 a 1,27 s). A `unidades`, que só lê a lista do filtro, leva 0,35 a
0,69 s fria; despachada antes no mesmo processo, ela tira só parte do custo (os 12 meses da rede caem para 0,48 a 0,97 s; a lista
para 0,59 a 0,93 s), e o resto é o que o primeiro pedido de cada consulta nova paga uma vez -- inferido, e não medido parte a
parte: os metadados das entidades e a tradução da DQL. **Por isso nenhuma das três ganhou a trava da rede:** ela não tira um
milésimo do custo fixo, que é igual na unidade; só o mês da rede, cujo peso é a leitura dos itens do painel, trava. A host estava
carregada nessa rodada (os pedidos frios saíram até 0,3 s acima dos da implementação), e o número da produção depende do opcache
de lá.

**A causa do mês da rede, medida pelas consultas do pedido (o `DebugStack`):** o peso é a taxa de resposta, que precisa dos ATENDIDOS do mês --
os itens do painel da rede (`VendaRepository::painelItens`, 31.898 itens em julho de 2026, a base do D631). A consulta leva 0,19 s
no MySQL e 0,48 s no PostgreSQL frio; o resto do tempo e o pico são a hidratação e o `ContasDoPainelDaUnidade::normalizar` das
linhas, de que só sobra o conjunto dos clientes. As respostas do NPS (317 pesquisas em 387 linhas) custam menos de 0,01 s. A
unidade fria fica abaixo do teto dela (1,5 s), mas passa de 1 s pelo mesmo custo fixo do primeiro pedido (os metadados e os proxies
do Doctrine, o D646 e o D647): o segundo pedido é dez vezes mais rápido.

**A primeira versão lia os itens do mês inteiro de uma vez, antes das respostas: 122,2 a 123,5 MB de pico** (1,57 a 2,19 s frio no
MySQL, 1,50 a 2,59 s no PostgreSQL), **e com `memory_limit = 128M` o mês da rede caía** -- em julho de 2026, nos dois bancos, no
`Laminas\Cache\Exception\OutOfSpaceException` do cache do Doctrine (a consulta das respostas, carregada depois do pico, o achado
do D646); em dezembro de 2025, no "Allowed memory size of 134217728 bytes exhausted". **Consertado na mesma fatia, como os
Royalties:** as respostas e os nomes das unidades vêm antes, e os itens são lidos em duas metades do mês
(`ContasDosRoyalties::partes`), cada uma reduzida ao conjunto dos clientes antes da seguinte -- a união é o mesmo conjunto, e o
oráculo deu as mesmas 1.828 conferências sem divergência antes e depois. O `NpsDoRelatorioTest::testOsAtendidosEmDuasMetadesDepoisDasRespostas`
confere a ordem e as faixas que as consultas receberam (os mutantes P56-X25 e X26).

**O que entrou também:** a trava da rede por pessoa no `indexAction` (`npsDaRede-<id>`, o 429 com a frase do Profissional
Parceiro, depois de soltar a sessão), como a Retirada, os Descontos e as Canceladas; os 12 meses e a lista, que só leem as
respostas, não travam. A fila do mês da rede na tela é da 57 (a N2): o `filaDaRede.ts` ganha a `nps`. **O que fica:** o primeiro
pedido do mês da rede passa de 1 s (o teto do roteiro da fatia; o do desenho, 3 s, foi cumprido), e a leitura dos itens inteiros
da rede para um número só -- o caminho é uma contagem própria dos clientes distintos atendidos no banco (o `COUNT(DISTINCT)` que o
painel não tem). E o primeiro pedido dos 12 meses, da lista e do TXT, na rede E na unidade, passa do teto de 1 s pelo custo fixo
acima -- o mesmo do D646 e do D647, que o opcache e o cache de metadados da produção decidem (o item 7 da etapa 1), e não uma
consulta. O número da produção depende do opcache e do `memory_limit` de lá (o item 7 da etapa 1, o D631).

### D656 — A rede do NPS não mostra a taxa de resposta por unidade, que a consulta comum já permite — REGISTRADO em 28/10/2026 — PARCIAL em 29/10/2026: a taxa por unidade, sem a suíte — CORRIGIDO em 29/10/2026

**A decisão E4 de `spec-nps.md` (02/10/2026) tirou a taxa por unidade da rede SÓ pelo custo da consulta:** "`painelItens` não traz a
unidade de quem fechou a venda", e os atendidos por unidade pediriam uma consulta por unidade ou uma coluna nova -- o D-n9 do
desenho, com o caminho `IDENTITY(usu.unidade)` em `COLUNAS_DOS_RELATORIOS`. **Desde 13/10/2026 a coluna `unidade` (a de quem
fechou, `IDENTITY(usu.unidade)`) já está no SELECT comum** (o item 8 do bloco "Em dia" do `spec-comum.md`; o `normalizar` a
devolve), e por isso o D-n9 NÃO foi registrado: a taxa por unidade sai da mesma leitura que a taxa da rede já faz, sem consulta a
mais. **A fatia 56 seguiu o desenho** (a linha de cada unidade com os envios, os clientes, as faixas e as três médias, sem a
taxa), porque mostrar ou não a taxa de cada unidade é o dono quem decide -- a pergunta foi ao lote, "TELAS-RESTANTES [novas]
(28/10/2026) -- A taxa de resposta de cada unidade na rede do NPS (D656)". Uma ressalva que a pergunta leva: a unidade do
atendido é a de quem fechou a venda, e a da resposta é a `nps.unidade` -- medidas iguais na cópia em 30/09/2026 (3.223 de 3.223
respostas de agosto de 2025 a julho de 2026, `NpsItemRepository::profissionalPorColaborador`).

**A RESPOSTA 272 (b) DO DONO (29/10/2026), "A taxa também na linha de cada unidade", e a fatia 58b (RESPOSTAS-272-273).** Na
rede do NPS, cada linha de unidade traz a taxa dela, `taxaDeResposta`, no formato da linha "Toda a rede" ("1,8% · 40 de 2.235
clientes atendidos"), e a unidade com menos de 30 envios a traz apagada, como as médias. A conta é a da rede, unidade por
unidade (`ContasDoNps::taxasPorUnidade`, que chama a `taxaDeResposta` com a interseção): **o atendido pela unidade de quem
fechou a venda** (a coluna `unidade` do SELECT comum, `ContasDoNps::atendidosPorUnidade`) **e o respondente pela
`nps.unidade`**. Sai da mesma leitura dos itens do painel (as duas metades do D655): nenhuma consulta a mais, e o
`testATaxaDeCadaUnidadeNaRede` conta as duas leituras. A unidade com resposta e sem atendido sai com a taxa nula (o
travessão, a regra da rede); a ativa sem resposta sai apagada, com "0,0%" se atendeu alguém no mês e com o travessão se
não atendeu ninguém. Os casos: `RegrasDoNpsTest` (dois novos),
`NpsDoRelatorioTest::testATaxaDeCadaUnidadeNaRede` (a C5 atendida na B e respondendo na A, a C8 respondendo numa unidade sem
atendido) e o `Nps.teste.tsx` (a taxa de cada unidade, a apagada com o porquê, a nula com o travessão). **O que falta para
CORRIGIDO (29/10/2026):** o Docker Desktop estava fora durante a fatia, e nenhum agente o reinicia; por isso NÃO rodaram a
suíte nos dois bancos (os casos novos do servidor foram conferidos só nas partes puras, com o PHP do Windows), os mutantes do
servidor, o oráculo da taxa de cada unidade na cópia (julho de 2026, `medir-p58b-oraculo.py` no rascunho, pronto) e o medidor
da API do NPS. A tela e os mutantes dela rodaram.

**A verificação da 58b (29/10/2026), com o Docker ainda fora.** (1) A linha da rede sem a taxa calculada saía com a taxa da
unidade sem atendido -- o travessão "nenhum cliente atendido no mês" de uma unidade que atendeu --: o `NpsJson::mes` passou a
exigir a `taxasPorUnidade` (nula fora da rede), e a linha sem a dela lança `LogicException` (o caso novo no
`RegrasDoNpsTest`). (2) A chave `taxaDeResposta` arrancada só derrubava os dois casos pelo `ValueError` do `array_combine`: os
dois contam as linhas com a chave antes. (3) Os desenhos davam o D656 como CORRIGIDO; dizem PARCIAL. (4) A ativa sem resposta
mostra "0,0%" só se atendeu; sem atendido, o travessão. Os dois casos puros rodaram com o PHP do Windows e um dublê do
PHPUnit (`medir-p58b-dubl-phpunit.php` no rascunho; 22 de 22 no `RegrasDoNpsTest`), e os mutantes que as classes puras pegam
(P58b-X3, X4, X7, X16, X17 e X18) ficaram vermelhos pela asserção, um por vez, devolvidos pelo sha1. Continua faltando o mesmo:
a suíte nos dois bancos (com o `testATaxaDeCadaUnidadeNaRede`), o X2 integrado, o oráculo na cópia e o medidor da API.

**A prova (29/10/2026, com o Docker de volta).** A suíte das classes tocadas e vizinhas, 256 casos em cada banco, saída 0; os mutantes do servidor P58b-X2, X3, X4, X7, X16, X17 e X18, um por vez, devolvidos pelo sha1, vermelhos pela asserção nos dois bancos (o X2, a taxa da rede repetida em cada linha, pelo `testATaxaDeCadaUnidadeNaRede`); o oráculo em Python contra o JSON do controller em processo, julho e junho de 2026, nos dois bancos: **440 conferências, 0 divergências** (julho, a unidade 1 com 1,8%, 40 de 2.235; as mesmas 5 consultas da rede, pico de 74,1 MB); no navegador, contra o banco de contrato, fevereiro de 2026 na rede com a taxa embaixo de cada unidade, apagada abaixo de 30 envios com o porquê, e a linha "Toda a rede" acesa, em 375 px e nos dois temas. O ciclo inteiro da 58b (conta como a 58, par) ficou verde depois de um conserto: o D284PontosNoCancelamentoTest gravava a origem sem o acento, que a 273 (b) deixou de premiar, e passou a gravar a marca. **CORRIGIDO.**

### D657 — A marca da indicação tem duas leituras: o PDV aceita qualquer "X - N", o cadastro só "Indicação - N" — REGISTRADO em 29/10/2026 — PARCIAL em 29/10/2026: a marca no caixa, sem a suíte — CORRIGIDO em 29/10/2026

**O IND-D3 de `docs/f3-relatorios/spec-indicacao.md` (seção 4), registrado pela fatia 58 (REL-INDICACAO-CONTAS, a I0).** Os 10
pontos por compra do amigo sem a ligação leem quem indicou pela origem com `explode(' - ', ...)` e `is_numeric`
(`VendaPagamentoService::salvarPontuacao`): qualquer origem "X - N" daria os 10 pontos ao cliente N. O cadastro
(`FidelidadeService::extrairIdClienteIndicadorOrigem`) e o relatório (`ISCadastro\Regra\MarcaDaIndicacao::indicadorDoFormulario`,
nova nesta fatia, com o mesmo corpo, e o `RegrasDoProgramaDeIndicacaoTest` que confere que as duas dão o mesmo) aceitam só
"Indicação - N".

**Latente, medido na cópia** (`franquiabv_dev`, MySQL, 29/10/2026, só contagem): 183 clientes com uma origem "… - …", os 183
"Indicação - N"; nenhuma outra origem com " - número" no fim. O conserto que o desenho prepara é o PDV chamar a
`MarcaDaIndicacao::indicadorDoFormulario`; ele muda quem ganha os pontos (é regra dos pontos, e do dono). **A pergunta foi ao
lote** na verificação da fatia 58: "TELAS-RESTANTES [marketing] (29/10/2026) -- Programa de Indicação: quem o caixa premia pela
origem do cliente (D657)", com a recomendação (b), o caixa lendo só "Indicação - N". Medido de novo na cópia, só contagem: nenhuma
origem "Indicação-N" sem os espaços (que o cadastro lê e o PDV não), nenhuma " - número" fora das "Indicação - N". Nada mudou no
PDV; até a resposta, fica como está.

**A RESPOSTA 273 (b) DO DONO (29/10/2026), "O caixa lê só “Indicação - N”, como o cadastro e o relatório", e a fatia 58b.**
Os 10 pontos por compra do amigo sem a ligação (`VendaPagamentoService::salvarPontuacao`) leem quem indicou pela
`MarcaDaIndicacao::indicadorDoFormulario`, a mesma do cadastro e do relatório; o `FidelidadeService::extrairIdClienteIndicadorOrigem`
delega a ela (o item da I3, adiantado). "Promoção - N" e "Indicacao - N" sem o acento deixam de dar os pontos; "Indicação-N" sem
os espaços passa a dar. O resto do parágrafo não mudou: a ligação (`getIndicacao() === null`), o `premiaQuemIndica` na hora da
venda (a 145 (c)), a unidade da venda (a 92 (b)), o histórico. Os casos: `D304EtapaSeisDaAdesaoTest::testOCaixaLeQuemIndicouSoPelaMarcaDaIndicacao`
(quatro origens), o `RegrasDoProgramaDeIndicacaoTest` (duas marcas novas, e o código do caixa e do cadastro lido sem os
comentários). **O que falta para CORRIGIDO (29/10/2026):** o Docker Desktop estava fora durante a fatia; NÃO rodaram a suíte nos
dois bancos, os mutantes do servidor nem a contagem na cópia de quantas origens mudam de resultado (`medir-p58b-273.py` no
rascunho, pronto; a pergunta mediu zero em 29/10/2026).

**A verificação da 58b (29/10/2026), com o Docker ainda fora.** O comentário do caixa afirmava "a troca não muda ninguém hoje"
sem a recontagem com a regra exata do `explode` (que premiava também o primeiro número de "Indicação - 12 - 3"); diz agora o
que a pergunta mediu e que a recontagem não rodou. O desenho dava o D657 como CORRIGIDO; diz PARCIAL. O docblock do ajudante
`literais` do `RegrasDoProgramaDeIndicacaoTest` voltou para cima dele. Com o PHP do Windows e um dublê do PHPUnit, o
`RegrasDoProgramaDeIndicacaoTest` passou inteiro (28 de 28) e os mutantes que ele pega (P58b-X10, X11, X12 e X15) ficaram
vermelhos pela asserção, um por vez, devolvidos pelo sha1. Continua faltando o mesmo: a suíte nos dois bancos (o D304 com as
quatro origens), os mutantes integrados X13 e X14 e a contagem na cópia.

**A prova (29/10/2026, com o Docker de volta).** A suíte das classes tocadas e vizinhas, 256 casos em cada banco, saída 0 (o D304 com as quatro origens); os mutantes P58b-X10 a X15, um por vez, devolvidos pelo sha1, vermelhos pela asserção nos dois bancos (o X13, a ligação arrancada, e o X14, o `premiaQuemIndica` arrancado, pelos casos que já existiam); a recontagem na cópia (`medir-p58b-273.py`, só contagem, nos dois bancos): **das 217.540 origens preenchidas, o caixa lia quem indicou em 183 e lê agora nas mesmas 183; nenhuma muda de resultado** -- ninguém ganha nem perde pontos hoje. O ciclo inteiro da 58b (conta como a 58, par) ficou verde depois de um conserto: o D284PontosNoCancelamentoTest gravava a origem sem o acento, que a 273 (b) deixou de premiar, e passou a gravar a marca. **CORRIGIDO.**

### D658 — O convite do aplicativo muda de unidade na primeira compra do convidado — REGISTRADO em 29/10/2026 — PARCIAL em 30/10/2026: o relatório calcula a unidade do convite; a escrita e o aplicativo, não

**O IND-D4 de `docs/f3-relatorios/spec-indicacao.md` (seção 4), registrado pela fatia 58.** A linha de `indicacoes` nasce na unidade
do cadastro de quem convidou (`AplicativoService`) e a `VendaPagamentoService::gerenciarComissaoIndicacaoPrimeiraVenda` a muda para
a unidade da venda na primeira compra, onde o crédito da comissão entra. O relatório lê a unidade da linha
(`IndicacaoRepository::programaLinhas`, `ind.unidade`; o caso `testALinhaQueMudouDeUnidadeEAFaixaDoMesNoBanco` e o mutante
P58-X2, a unidade do cadastro de quem convidou, o prendem), e por isso o convite de setembro que comprou noutra unidade em
outubro sai da lista de setembro da unidade de origem. A tela antiga faz o mesmo, e o aplicativo lê a unidade da linha. **No
relatório, a I2 resolve** (a unidade do convite pela última compra antes do convite); a escrita continua, porque é a unidade do
crédito. Desde 10/10/2026 (a 53 (c)) vale também para o amigo do formulário com a ligação.

**PARCIAL em 30/10/2026 (a fatia 59, REL-INDICACAO-TELA; a I2 do desenho).** No relatório novo, a unidade do convite é
CALCULADA: a da última venda finalizada de quem convidou antes do instante do convite, pela unidade de quem a fechou
(`VendaRepository::programaUltimaUnidadeAntes`); quem nunca tinha comprado, a do cadastro dele; quem indicou e não existe mais, a
gravada (`ContasDoProgramaDeIndicacao::unidadeDoConvite`). O convite de setembro que comprou noutra unidade em outubro fica na lista
de setembro de quem convidou, e a primeira compra do convidado não o muda mais (o `UnidadeDoConviteTest`, nos dois bancos; os
mutantes P59-P15 a P59-P18 e P59-X6, X7, X10, X11 e X16). **A unidade é conferida a cada consulta, e não congelada** (a correção da
fatia 59, o achado 1 da verificação): se a compra que a decidia for cancelada depois, vale a compra anterior (ou o cadastro); e o
cadastro que vale é o de hoje. O texto do desenho e a primeira versão do "Como se conta" diziam "ela não muda depois", o que o código
não faz nesses dois casos; o glossário (`indicacao.unidade`) passou a dizê-lo, e o caso
`UnidadeDoConviteTest::testACompraCanceladaDepoisEOCadastroDeHojeMudamAUnidade` o prende (os mutantes P59-X10 e P59-X22). Congelar a
unidade pediria gravá-la no convite -- coluna nova, que a I2 não cria; se um dia for preciso, é a porta da I3. **O que resta, e por que fica:** a escrita continua -- a linha muda para a unidade da venda na primeira
compra, porque é a unidade do crédito da comissão, e a comissão e a adesão do relatório continuam lendo a unidade gravada, como o
desenho manda --; e a tela antiga (`IndicacoesController`) e o aplicativo (`AplicativoController`) continuam lendo a unidade da
linha. A tela antiga sai na I4; o aplicativo não é relatório.

### D659 — As regras do Programa de Indicação que o relatório mostra e não decide — REGISTRADO em 29/10/2026

**O IND-D1 de `docs/f3-relatorios/spec-indicacao.md` (seção 4), registrado pela fatia 58**, com o que andou depois de 02/10/2026:

1. **A base da comissão** é o `valor_total` da primeira venda finalizada de qualquer tipo, com a compra de crédito, e não o
   líquido da estreia (no desenho, a unidade 1 em julho de 2026 daria R$ 1.606,80 contra R$ 1.564,80). **Fora da fatia 58 pela
   fila** ("Fora: ... o item 1 do IND-D1"): o relatório mostra a comissão gravada, e a regra fica como está. REGISTRADO.
2. **O cancelamento não estorna a comissão:** DECIDIDO pelo dono em 11/10/2026, a 68 (c), "como está" (o D459). O relatório a
   mostra na parcela "na venda cancelada" (`ContasDoProgramaDeIndicacao::CANCELADA`); o mutante P58-X6 (o cancelamento zerando a
   comissão) fica vermelho no `D304EtapaSeisDaAdesaoTest`.
3. **O prêmio de quem indicou pela adesão do amigo nunca paga** (a etapa 3 chama `salvarPontuacaoCadastro($indicador, 6)`, que
   acha o histórico do próprio envio): DECIDIDO pela 53 (c) em 08/10/2026 (o D446) -- o prêmio é a comissão da primeira compra,
   pela ligação, e não os pontos da adesão. A chamada continua sem efeito, como o D446 conta.
4. **O amigo do formulário dava pontos, e não crédito:** DECIDIDO pela 65 (b) e pela 145 (c) -- o amigo com a ligação paga a
   comissão e não dá os 10 pontos; o sem a ligação dá os 10 só a quem está ativo na hora da venda (o D446, o D466). O relatório
   conta os pontos lançados (`PontuacaoHistoricoRepository::programaPontosDasVendas`).

Resta o item 1, sem pergunta nesta fatia: a fila o põe "Fora" do bloco 58, e a pergunta dele está prevista na seção "## 3. As
perguntas que faltam fazer" da fila das telas que faltam (`telas-fila-corrigida.md`, no rascunho do orquestrador), em "Não
travam" > "Outras" ("o item 1 do IND-D1"). Até ela ir ao lote, o relatório mostra a comissão gravada e a regra fica como está.

### D660 — Os clientes novos por indicação da rede levam de 18 a 20 s no MySQL da cópia, e o primeiro pedido de cada resposta passa de 1 s — REGISTRADO em 29/10/2026 — PARCIAL em 30/10/2026: a trava e a fila da rede; o custo continua, e a I2 o levou à unidade

**Achado na medida da fatia 58 (REL-INDICACAO-CONTAS, a I0 de `docs/f3-relatorios/spec-indicacao.md`).** As duas respostas do
Programa de Indicação (`ISRelatorio\Service\ProgramaDeIndicacao::numeros` e `::clientes`; a rota é da I1, a fatia 59), medidas em
processo no `franquiabv_dev`, julho de 2026, `memory_limit = 128M`, três rodadas frias (um processo cada) e três quentes (depois
de uma de aquecimento):

| | MySQL frio | MySQL quente | PostgreSQL frio | PostgreSQL quente | pico |
|---|---|---|---|---|---|
| `numeros`, rede | 1,66 a 1,95 s | 0,86 a 0,98 s | 0,60 a 1,89 s | 0,03 a 0,04 s | 15,5 MB |
| `numeros`, unidade 1 | 1,34 a 1,79 s | 0,70 a 0,77 s | 0,98 a 1,29 s | 0,02 s | 15,3 MB |
| `clientes`, rede | **19,4 a 20,2 s** | **17,8 a 18,5 s** | 1,38 a 2,15 s | 0,87 a 0,93 s | 45,0 a 45,5 MB |
| `clientes`, unidade 1 | 3,05 a 3,63 s | 2,19 a 2,24 s | 0,64 a 1,03 s | 0,12 s | 18,6 a 19,1 MB |

**Onde vai o tempo, medido** (`medir-p58-novos.php`, no rascunho): no `clientes` da rede, a pergunta do primeiro serviço dos
13.212 candidatos de junho de 2026 (`VendaRepository::painelPrimeiroServicoOuCredito`, a peça comum dos novos que o Profissional
Parceiro e o painel também usam) levou 27,9 s no MySQL e 9,3 s no PostgreSQL na primeira vez; os itens da rede, 1,5 s e 1,0 s.
No `numeros`, a consulta dos amigos do formulário (`ClienteRepository::programaFormulario`) lê a tabela `clientes` inteira -- sem
índice em `data_criacao` nem em `origem`, o custo que o desenho já media (4,3 a 9,5 s com 128 MB em 02/10/2026) e que a I3 (a
fatia 75) tira com a coluna da porta.

**O número do MySQL é dominado pelo ambiente:** o `innodb_buffer_pool_size` do contêiner é de 128 MB, o padrão de fábrica (medido
nesta fatia), e o PostgreSQL da mesma cópia faz o mesmo trabalho em 0,9 a 2,2 s. A medida não variou o parâmetro (mudaria o
ambiente de todos); o número publicável é a matriz acima, e não uma célula.

**Achado e consertado na mesma medida:** a primeira versão do `clientes` lia os itens em duas metades com a parte normalizada
inteira, e a rede de julho de 2026 caía com 128 MB no cache de memória do Doctrine (`OutOfSpaceException`, o D646: o adaptador
compara o `memory_get_usage(true)`, que não desce, com a metade do limite -- 62 MB de memória real depois dos itens). Agora os
itens vão em quatro partes, reduzidos linha a linha ao serviço de cada cliente em cada dia e à venda, e os novos são contados
uma vez sobre o mês: o pico é de 45,0 MB em julho e 52,6 MB em dezembro de 2025.

**O que a I1 faz com isto:** a trava da rede por pessoa (o 429, `CONSULTA_DA_REDE_EM_ANDAMENTO`) nas duas ações da rede, como o
NPS, a Retirada, os Descontos e as Canceladas, e a fila `indicacao` no `filaDaRede.ts`. O custo do primeiro serviço é da peça
comum, e não deste relatório: o mesmo vale para os clientes da rede do Profissional Parceiro.

**PARCIAL em 30/10/2026 (a fatia 59, REL-INDICACAO-TELA; a I1 e a I2).** Feito: a trava da rede por pessoa nas DUAS ações, numa
trava só (`ProgramaDeIndicacaoController::TRAVA_DA_REDE`, o 429; `ProgramaDeIndicacaoTest::testARedeEUmPedidoDeCadaVezPorPessoaNasDuasAcoes`,
nos dois bancos, e os mutantes P59-X1 e X2), e a fila `indicacao` da tela com os dois pedidos da rede (o
`ProgramaDeIndicacaoLigado.teste.tsx`, e os mutantes P59-X8 e X13). **Medido de novo pelo caminho do controller** (`medir-p59.php`
no rascunho; julho de 2026, `memory_limit = 128M`, três rodadas frias, um processo cada, e três quentes depois de uma de
aquecimento):

| | MySQL frio | MySQL quente | PostgreSQL frio | PostgreSQL quente | pico |
|---|---|---|---|---|---|
| números, rede | 3,47 a 7,10 s | 1,98 a 2,07 s | 1,88 a 1,98 s | 0,10 a 0,11 s | 17,3 MB |
| números, unidade 1 | 4,16 a 7,80 s | 3,82 a 4,63 s | 2,00 a 2,51 s | 0,09 a 0,13 s | 17,5 MB |
| clientes, rede | **29,2 a 33,0 s** | **28,4 a 35,0 s** | 3,58 a 4,04 s | 1,96 a 2,11 s | 45,7 a 46,4 MB |
| clientes, unidade 1 | 6,06 a 6,22 s | 3,39 a 3,82 s | 2,04 a 2,46 s | 0,20 a 0,22 s | 19,9 a 21,1 MB |

Dezembro de 2025, o mês mais cheio, na rede: os números em 3,81 s e 15,4 MB no MySQL, os clientes em 38,5 s e 53,3 MB (no
PostgreSQL, 1,47 s e 4,53 s) -- longe do teto de 128 MB.

**O que piorou, e por quê:** os números da UNIDADE passaram de 0,70 a 0,77 s quentes no MySQL (a fatia 58, pelo serviço) para a
mesma ordem dos da rede, porque a I2 lê os convites da rede inteira para achar a unidade de cada um -- e os amigos do formulário da
rede são a consulta sem índice em `clientes.data_criacao` (`ClienteRepository::programaFormulario(null, ...)`), que na unidade usava
o índice da unidade. É o preço da regra do dono (o convite gravado numa unidade pode ser de outra), e a I3 (a fatia 75) tira a
consulta: o formulário passa a ser uma linha de `indicacoes`. No PostgreSQL, 0,1 s quente.

**A conta acima separada, peça por peça** (a correção da fatia 59, os achados 5 e 9 da verificação; `medir-p59-partes.php` no
rascunho: a unidade 1 e a rede ALTERNADAS no mesmo processo, julho de 2026, três rodadas, `memory_limit = 128M`). A tabela de cima
pôs a unidade 1 em 3,8 a 4,6 s e a rede em 2,0 a 2,1 s, medidas em processos e horas diferentes; alternadas, as duas ficam juntas:

| | MySQL, unidade 1 | MySQL, rede | PostgreSQL, unidade 1 | PostgreSQL, rede |
|---|---|---|---|---|
| os números inteiros (`ProgramaDeIndicacao::numeros`) | 5,97 a 6,06 s | 5,53 a 6,02 s | 0,12 a 0,55 s | 0,12 a 0,22 s |
| dos quais os amigos do formulário da rede (`programaFormulario(null)`) | 5,35 a 6,91 s | 5,06 a 6,23 s | 0,06 a 0,44 s | 0,06 a 0,08 s |
| a última compra antes, 33 pares (`programaUltimaUnidadeAntes`) | 0,21 a 0,76 s | 0,18 a 0,27 s | 0,05 a 0,58 s | 0,05 a 0,10 s |
| as linhas gravadas da unidade (`programaLinhas(1)`) | até 0,006 s | -- | até 0,006 s | -- |

Quase todo o tempo é a mesma consulta, nos dois recortes; o que a I2 pôs a mais na unidade (a última compra e as linhas dela) fica
abaixo de 0,8 s. A diferença da tabela de cima (cerca de 2 s) é do host e do buffer pool de 128 MB, que nesta noite deixaram a
mesma consulta em 5 a 7 s -- não da I2. Os clientes da unidade 1 também subiram (de 2,2 s quentes na 58 para 3,4 a 3,8 s), e a I2
não toca o caminho deles (só o `programaContatos` ganhou a coluna da unidade): também o host, e esta medida não o separa.

**A trava passou a valer nos números da unidade** (a correção da fatia 59, o achado 9): eles leem a mesma rede que os da rede, e
ficavam sem trava. Agora o `indexAction` trava SEMPRE, na mesma trava por pessoa (o 429 com a frase deste relatório na unidade,
`CONSULTA_EM_ANDAMENTO`, que não fala da rede; com a do Parceiro na rede), e o `clientesAction` só na rede -- os clientes da unidade
não leem a rede. A tela põe os números de qualquer recorte na fila `indicacao`, e os clientes só com a rede
(`ProgramaDeIndicacaoTest::testARedeEUmPedidoDeCadaVezPorPessoaNasDuasAcoes` e o `ProgramaDeIndicacaoLigado.teste.tsx`; os mutantes
P59-X19 a X21, X17 e X18). O preço: o administrador que troca para uma unidade com os clientes da rede no ar espera a leitura que
está lá, como já esperava ao trocar de mês na rede. Os clientes da rede no MySQL saíram mais lentos que
na 58 (28 a 35 s contra 18 a 20 s) com a mesma consulta: a máquina e o buffer pool de 128 MB variam de uma rodada para a outra (o
oráculo da mesma tarde mediu 59 s no primeiro pedido) -- o número publicável é a faixa, e não uma célula.

**O que resta:** o custo em si -- o primeiro serviço dos candidatos da rede (a peça comum) e os amigos do formulário sem índice
(até a I3), agora também nos números da unidade. A trava protege o servidor, e não quem espera.

### D661 — A lista do formulário do Programa de Indicação mostra o contato de quem indicou de fora do recorte — REGISTRADO em 29/10/2026 — CORRIGIDO em 30/10/2026: a unidade do convite é a de quem convidou

**Achado na verificação da fatia 58 (REL-INDICACAO-CONTAS, a I0 de `docs/f3-relatorios/spec-indicacao.md`).** A origem do
cliente é texto livre (`RegraDoCadastroDeCliente` só exige que não seja vazia, e não a confere contra o catálogo). Quem tem o
cadastro na unidade U pode criar ou editar no mês um cliente da U com a origem "Indicação - N", em que N é cliente de outra
franquia: o `ClienteRepository::programaFormulario(U)` traz o amigo pela `cli.unidade`, a
`ContasDoProgramaDeIndicacao::linhasDoFormulario` lê o N pela `MarcaDaIndicacao::indicadorDoFormulario`, e o
`ClienteRepository::programaContatos` devolve o nome e o telefone do N pelos ids, sem a unidade, como o desenho manda; o
`ProgramaDeIndicacaoJson::numeros` os põe no `indicador` da lista. O mesmo vale para o convite que mudou de unidade na primeira
compra (o D658): quem convidou continua no cadastro da unidade de origem.

**Não abre dado novo:** o cliente é da rede (o GET do cadastro mostra qualquer cliente a quem tem o cadastro, e a busca do PDV
acha a rede inteira por nome ou telefone), e o PDV já premia o N de qualquer "X - N" (o D657). A tela antiga
(`IndicacoesController`) mostra o nome e o telefone de quem convidou pela unidade da linha, sem olhar a de quem convidou, e não
lista o amigo do formulário. **Hoje nada chega por rota** (a I0 não tem rota). O caso
`ConsultasDaIndicacaoTest::testOContatoDeQuemIndicouVemDeForaDoRecorte` prende o comportamento (o amigo da U com a marca de um
cliente da F traz o nome e o telefone dele), para que a I1 (a fatia 59) o mude só de propósito. Se o dono um dia quiser o
contato de quem indicou só quando ele for da unidade do recorte, é uma pergunta nova; a pergunta 10 ("como hoje") não a faz.

**CORRIGIDO em 30/10/2026 (a fatia 59, REL-INDICACAO-TELA), pela I2, que é a regra do dono (o ponto I2, "a da última compra antes
do convite").** O convite deixou de ser da unidade gravada no amigo: é da unidade da última compra de quem convidou antes do
convite, ou da do cadastro dele. O amigo da U com a marca de um cliente da F que nunca comprou aparece na F, e não na U: o gerente
da U não vê mais o nome e o telefone de quem é da F por esse caminho (`UnidadeDoConviteTest::testOGerenteNaoVeOConviteQuePassouParaAF`,
o K6, nos dois bancos; o mutante P59-X6, a unidade gravada de volta, fica vermelho nele). O `ConsultasDaIndicacaoTest::
testOContatoDeQuemIndicouVemDeForaDoRecorte` continua verde, porque prende a consulta (o `programaContatos` pelos ids), e não a
resposta. **O espelho, e por que não é débito:** o convite que passou para a F leva o convidado, cujo cadastro pode ser da U -- é a
regra que o dono escolheu para a unidade do convite, e o convidado é parte do convite; quem convidou continua sendo de onde ele
comprou ou se cadastrou.

### D662 — A recusa pelo papel diz "gerente e administrador" onde o caixa também vê — REGISTRADO em 30/10/2026 — CORRIGIDO em 08/11/2026: a frase do servidor e a da tela dizem o caixa onde a política o deixa ver

**Achado na fatia 59 (REL-INDICACAO-TELA).** A política `indicacao` é a única em que o caixa que não é gerente vê a unidade da
sessão (`caixa => 'unidade'`, a resposta 3 do dono), e a do Caixa e a da Cota-parte dão o dia ao caixa (`caixa => 'dia'`). Mas a
frase do 403 `papel` é uma só, na base: `RelatorioAdmController::recorteDoRelatorio` responde "<relatório>: quem não é gerente nem
administrador não vê este relatório.", e a tela, pela `RecusaDoRelatorio`, diz "<relatório> é do gerente da unidade e do
administrador." -- nos três relatórios a frase omite o caixa, que vê. Quem é recusado (o colaborador) não é enganado sobre si, mas
lê uma regra incompleta. O desenho da Indicação manda a frase da base (a I1, "Guardas e escopo", item 3), e por isso a fatia não a
mudou. **O conserto** é da parte comum: a frase pela política (`caixa !== 'outros'` acrescenta "nem caixa" no servidor e "do caixa"
na tela), com os casos das três políticas e o texto que o `ProgramaDeIndicacaoTest` e o `ProgramaDeIndicacaoLigado.teste.tsx` leem.
Não é escolha do dono: é dizer a regra que ele já decidiu. REGISTRADO.

**CORRIGIDO em 08/11/2026 (a fatia 122, RELATORIOS-ARQUIVO-FRASE-E-MEMORIA).** A frase do 403 `papel` sai da política, num lugar
só: `RecorteDoRelatorio::fraseDoPapel($politica)` -- "só o administrador" nas que são só dele; "quem não é gerente, caixa nem
administrador não vê este relatório" nas duas em que o caixa que não é gerente vê alguma coisa e os outros são recusados (`caixa`
diferente de `outros` e `outros` nulo: o Caixa, o dia; a Indicação, a unidade); a de antes nas outras. O
`RelatorioAdmController::recorteDoRelatorio` a usa. Na tela, a `RecusaDoRelatorio` ganhou a prop `tambemDoCaixa` ("<relatório> é do
gerente da unidade, do caixa e do administrador."; o "só do administrador" vence), passada pelo Caixa (a lista e o detalhe) e pela
Indicação. **A Cota-parte v1 não diz o caixa (a correção da fatia 122):** ela dá o dia ao caixa, mas os outros veem a própria linha
sem marca (`outros => 'proprio'`, `marca => null`), e o `escopo` nunca dá nulo a quem entrou -- o 403 `papel` não acontece, e
"quem não é gerente, caixa nem administrador não vê" seria falso ali (o colaborador vê). A primeira montagem a punha com o caixa,
no servidor e na tela (`tambemDoCaixa={!v2}` na Cota-parte e no extrato); a frase dela voltou à geral, que nunca aparece, e a tela
não passa a prop. Os casos: `RegrasDoRecorteDoRelatorioTest::testAFraseDoPapelSegueAPolitica` (as quinze políticas, a lista
escrita; no Atendimentos e na Cota-parte v1, nenhum dos usuários que entraram é recusado; nas outras, o colaborador sem marca é
recusado e o caixa sem gerente tem escopo se, e só se, a frase o diz), o `ProgramaDeIndicacaoTest` e o `RelatorioDeCaixaTest` com a
frase nova, nos dois bancos; no front, `componentesDoRelatorio` (com as duas props ligadas, o "só do administrador" vence),
`RelatorioDeCaixaLigado`, `RelatorioDeCaixa` (o detalhe), `ProgramaDeIndicacaoLigado`, e `CotaParteLigado` e
`ExtratoDaCotaParteLigado` (a v1 com a frase geral, sem o caixa; a v2 só do administrador). Mutantes, devolvidos pelo sha1: a regra
sem o caixa e o controller com a frase fixa (vermelhos nos dois bancos); a regra com o caixa também na Cota-parte (o N4, vermelho
nos dois bancos); a `RecusaDoRelatorio` sem a prop, com a ordem do ternário trocada (o F8), e cada tela sem passá-la ou passando-a
na Cota-parte e no extrato (o F6 e o F7), vermelhos no vitest. Não visto num navegador. CORRIGIDO.

### D663 — O saldo do caixa não desconta o troco devolvido, e a gaveta esperada desconta — REGISTRADO em 31/10/2026

**Registrado na fatia 60 (REL-CAIXA-CONTAS; o D-cx-c da seção 4 de `docs/f3-relatorios/spec-caixa.md`, o S16 de
`docs/f3-relatorios.md`).** As contas do relatório de caixa moram agora em `ISVenda\Regra\ContasDoCaixa`: o **saldo** (todas as
formas) é `inicial + recebido − retiradas ativas`, e a **gaveta esperada** (só a forma 1, Dinheiro) é `inicial + dinheiro −
retiradas ativas − troco`. O `CaixaJson` passou a chamar o mesmo `ContasDoCaixa::saldo`, sem mudar valor nenhum. Mas a finalização
sem destino do troco grava na coluna `valor_final + recebido − troco` (`VendaPagamentoService`), e o `CaixaService::atualizar` e o
`CaixaJson` recalculam sem o troco: a coluna e a conta divergem pelo troco. A diferença é a definição -- o troco saiu em dinheiro, e
por isso sai da gaveta e não do saldo de todas as formas --, e hoje o troco é zero por construção (as duas portas do PDV exigem
destino). Medido na cópia, julho de 2026: troco devolvido R$ 0,00 na unidade 1 e na rede. Fica escrito porque a gaveta nova o
desconta e o saldo não, e quem comparar o relatório com a coluna `valor_final` de um caixa antigo com troco verá a diferença. O
relatório lê os lançamentos, e não a coluna. REGISTRADO.

### D664 — Os caixas esquecidos abertos aparecem no relatório de caixa, e fechá-los é escrita na produção — REGISTRADO em 31/10/2026

**Registrado na fatia 60 (REL-CAIXA-CONTAS; o D-cx-b da seção 4 de `docs/f3-relatorios/spec-caixa.md`).** A consulta nova
`CaixaRepository::relatorioAbertosAntes` traz os caixas ainda abertos de antes de hoje, o bloco fixo do relatório (o "fica para
depois" que o dono aceitou), e a CX3 os mostra. Medido na cópia em 31/10/2026, só contagem: 139 na rede e 12 na unidade 1 -- os
137 do D28 (`scripts/medir-caixas-abertos.sql`, que deixa de fora os ids de fixture; 117 de antes de 2026, 101 de operador
inativo, o mais antigo de 20/10/2015) mais os dois caixas de fixture (90001000 e 90001100) que o `franquiabv_dev` tem. O relatório
só os mostra: nada aqui os fecha, e fechar é escrita na produção, que se decide à parte (o `UPDATE` comentado no fim do
`medir-caixas-abertos.sql`). REGISTRADO.

**A fatia 72 (03/11/2026, PDV-GAVETA-CONTADA) muda o custo de fechá-los, e o título não muda.** O fechamento pelas duas telas passa a
pedir o dinheiro contado na gaveta, e o relatório mostra a quebra dos caixas fechados com a contagem a partir da data. Dois efeitos
nos esquecidos: (1) o `UPDATE` comentado do `medir-caixas-abertos.sql` não grava `valor_contado` -- ninguém contou aquelas gavetas --,
e os caixas que ele fechar depois da data aparecem "sem registro", sem quebra (o glossário `caixa.quebra` diz "o aberto, o de antes
e o fechado sem contagem"; a nota está no próprio arquivo); (2) fechá-los um a um pela tela pediria uma contagem, e o zero digitado
numa gaveta que ninguém conferiu daria um "faltou" com a gaveta esperada inteira, um número falso. Quando o dono decidir fechá-los,
o caminho que não inventa número é o `UPDATE`, sem contagem.

### D665 — O relatório de caixa passa de 1 s no primeiro pedido de cada processo, na unidade e na rede — REGISTRADO em 31/10/2026

**Achado na medida da fatia 60 (REL-CAIXA-CONTAS, a CX1 de `docs/f3-relatorios/spec-caixa.md`).** As consultas por conjunto
novas e as contas de `ContasDoCaixa`, compostas como a CX2 as vai compor (os caixas do período e os esquecidos abertos, as cinco
consultas uma vez para os dois, a gorjeta pelas finalizadas), medidas em processo no `franquiabv_dev` (`medir-p60.php` no
rascunho), julho de 2026, `memory_limit = 128M`, rede e unidade alternadas, cada célula com um processo frio (o primeiro pedido) e
um quente (um pedido de aquecimento e três rodadas). **Duas medidas, cinco amostras frias por célula:** a da fatia, duas voltas
(`medir-p60-tempo.sh`, `p60-medida.txt`), e a da verificação, três voltas (`medir-p60c-tempo.sh`, `p60c-medida.txt`, com a carga
do contêiner anotada antes de cada pedido frio, de 0,62 a 2,89 -- a própria medida a sobe):

| | MySQL frio | MySQL quente | PostgreSQL frio | PostgreSQL quente | pico |
|---|---|---|---|---|---|
| rede (1.068 caixas e 139 abertos), 1ª medida (2 voltas) | **2,24 e 2,54 s** | 0,73 a 1,05 s | **2,15 e 2,37 s** | 0,54 a 0,75 s | 49,4 a 52,3 MB |
| rede, 2ª medida (3 voltas) | **2,45 a 2,89 s, e 4,02 s** (a 1ª volta, logo depois da suíte) | **0,74 a 1,16 s** | **2,68 a 2,76 s** | 0,58 a 0,92 s | 49,4 a 52,3 MB |
| unidade 1 (64 caixas e 12 abertos), 1ª medida | **1,58 e 1,75 s** | 0,17 a 0,22 s | **1,70 e 1,86 s** | 0,10 a 0,18 s | 18,8 a 19,7 MB |
| unidade 1, 2ª medida | **2,08 a 2,37 s** | 0,17 a 0,25 s | **1,98 a 2,33 s** | 0,09 a 0,12 s | 18,8 a 19,7 MB |

**Passa de 1 s mais do que o primeiro pedido:** a rede QUENTE no MySQL chegou a 1,05 s na 1ª medida e a 1,16 s na 2ª (quatro das
nove rodadas quentes da 2ª acima de 1 s), e a rede quente no PostgreSQL a 1,11 s na medida alternada. **E a soma do frio da rede
passa de 2 s no MySQL** em todas as amostras (o desenho manda registrar e dizer: registrado aqui, e dito no relatório da fatia).

**Amostras frias maiores, fora da faixa, e a causa provável:** os processos novos do oráculo do mesmo dia (`p60-oraculo-1.txt`
a `-3.txt`, um processo por célula) tiveram primeiros pedidos de 1,69 a 3,03 s na unidade e de 2,54 a 4,56 s na rede no MySQL, e
de 2,24 a 3,32 s e de 2,25 a 7,22 s no PostgreSQL (o 7,22 s, a rede de junho). O oráculo-2 e o oráculo-3 rodaram três minutos
depois de rodadas de mutantes de banco (as suítes nos dois servidores de banco), e o 3 talvez junto do `conferir-tudo.sh` em segundo
plano: host carregado, não medido à parte na hora. A 2ª medida
mostra o mesmo desenho com o host quieto: a 1ª volta, a primeira leitura da cópia depois das duas suítes, levou 4,02 s, com as
vendas (1,24 s) e os descontos (0,73 s) duas a três vezes mais lentos que nas voltas seguintes -- as páginas da cópia saíram do buffer
pool de 128 MB, que o MySQL do contêiner divide com os bancos do CI. **Não é a faixa do relatório na produção**, mas é o pior caso
que se viu aqui, e por isso fica escrito.

**Onde vai o tempo, medido, com o host separado** (a medida alternada no mesmo processo, o molde da 59): começando pela unidade, o
primeiro pedido levou 1,82 s no MySQL e 2,09 s no PostgreSQL, e os seguintes 0,12 a 0,20 s na unidade e 0,60 a 0,81 s na rede;
começando pela rede, 2,59 s e 2,28 s, e os seguintes 0,74 a 0,83 s e 0,59 a 1,11 s. O primeiro pedido custa cerca de 1,5 s a mais
que os outros **com qualquer volume**: é o custo do processo, e não das consultas -- a primeira consulta (os caixas do período)
leva de 0,57 a 0,98 s fria e de 0,01 a 0,08 s quente, nos dois bancos e nos dois recortes; é o mesmo custo dos metadados e dos
proxies do Doctrine no primeiro pedido do D646. Quente, a rede se divide entre as vendas dos caixas (0,21 a 0,43 s, cerca de vinte
mil linhas) e os descontos (0,08 a 0,31 s); a soma das consultas da rede no MySQL quente fica entre 0,74 e 1,16 s, e a do desenho
(cerca de 1,5 s, medida em 02/10/2026 com `IN` literal) não se repetiu. O pico fica abaixo da metade do `memory_limit` (o D646). O
recebido por forma não se mede na cópia (o D181): o `EXPLAIN` parte de `vendas` por `FK_VENDAS_caixa` e chega a
`vendas_pagamentos` por `FK_VENDAS_PAGAMENTOS_venda`, o plano do desenho.

**O que a CX2 (a fatia 61) faz com isto:** a trava da rede por pessoa e a fila da tela, como os outros relatórios da rede -- a rede
passa de 1 s também quente no MySQL. O primeiro pedido acima de 1 s na unidade é o do D646, comum a todos os relatórios, e não
deste. REGISTRADO.

**Medido de novo pela API em 31/10/2026 (a fatia 61, REL-CAIXA-API; a CX2), e o que a trava resolve.** O mesmo relatório, agora
pelo caminho do `Adm\RelatorioDeCaixaController` (as guardas da base, o `popularCombobox` da unidade, as consultas da 60, as
contas e o `RelatorioDeCaixaJson`), em processo no `franquiabv_dev` (`medir-p61.php` e `medir-p61-tempo.sh` no rascunho),
`memory_limit = 128M`, três voltas por célula, com a carga do contêiner anotada antes de cada pedido frio (de 1,01 a 1,38 no
minuto):

| | MySQL frio | MySQL quente | PostgreSQL frio | PostgreSQL quente | pico |
|---|---|---|---|---|---|
| unidade 1, um dia (15/07/2026) | **2,55 a 3,16 s** | 0,10 a 0,12 s | **2,12 a 2,88 s** | 0,05 a 0,06 s | 16,0 a 17,3 MB |
| unidade 1, julho de 2026 (64 caixas e 12 abertos) | **2,23 a 3,18 s** | 0,20 a 0,27 s | **2,33 a 2,73 s** | 0,11 a 0,16 s | 19,7 a 21,8 MB |
| rede, julho de 2026 (1.068 caixas e 139 abertos) | **3,16 a 3,54 s** | 0,76 a 1,03 s | **2,93 a 3,54 s** | 0,57 a 0,92 s | 50,1 a 51,2 MB |

O pico é o maior de cada célula, contadas as rodadas frias e as quentes (a correção da fatia 61: a unidade no mês dizia 19,7 MB,
o da rodada fria, e chega a 21,8 MB nas quentes; a rede dizia 51,7 MB, que é o da medida alternada). A medida alternada, num
processo só, chega a 51,7 MB porque o PHP 8.1 não zera o pico entre os pedidos (o `memory_reset_peak_usage` é do 8.2).

**Separado o host, na medida alternada no mesmo processo** (unidade, rede, unidade, rede): o primeiro pedido -- sempre o da
unidade no mês -- levou de 2,41 a 2,74 s no MySQL e de 2,45 a 2,71 s no PostgreSQL, e os seguintes 0,20 a 0,26 s na unidade e 0,77 a
1,11 s na rede (MySQL), 0,13 a 0,21 s e 0,70 a 1,04 s (PostgreSQL). Na unidade, o frio de um dia só (dois caixas) custa o mesmo que o
do mês: **o primeiro pedido da unidade passa de 2 s com qualquer período**, o custo do processo (os metadados e os proxies do D646,
agora com o controller, o JSON e a lista do filtro a mais), e não das consultas. O frio subiu de 0,3 a 0,9 s contra a medida da 60
(que media as consultas sem o controller).

**Na rede, o frio é o do processo MAIS o das consultas (a correção da fatia 61, com a ordem invertida).** A alternada acima sempre
começou pela unidade, e não dizia nada do frio da rede. Medida de novo com as duas ordens lado a lado na mesma janela, cada uma num
processo novo, três voltas por banco (`medir-p61c-par.sh` no rascunho, `p61c-medida-par.txt`), com o host mais carregado (carga de
1,41 a 2,28 no minuto): começando pela unidade, o primeiro pedido levou de 3,33 a 3,68 s no MySQL e de 3,23 a 3,46 s no PostgreSQL;
começando pela rede, de 4,24 a 4,54 s e de 4,05 a 4,11 s -- de 0,6 a 1,2 s a mais, o que a rede quente custa na mesma janela (1,02 a
1,40 s no MySQL e 0,81 a 1,23 s no PostgreSQL). Uma rodada antes, só na ordem invertida (`p61c-medida-invertida.txt`, carga de 0,42 a
1,44), deu de 4,22 a 6,01 s no MySQL (o 6,01 s na primeira leitura depois das suítes da correção: o buffer pool, como na 60) e de
3,99 a 4,30 s no PostgreSQL.

**A rede fria passa de 3 s em todas as amostras do MySQL** (3,16 a 3,54 s) **e em duas das três do PostgreSQL** (3,54, 3,10 e
2,93 s), **e de 4 s em todas as da correção**, com o host mais carregado; **e passa de 1 s quente** no MySQL (uma das nove rodadas
quentes, 1,03 s, e a alternada até 1,11 s; na correção, até 1,41 s): o desenho manda registrar e dizer -- registrado aqui e dito no
relatório da fatia. O pico fica abaixo da metade do
`memory_limit` (o D646): 51,7 MB.

**O que a trava da rede resolve, e o que não:** o `index` da rede leva a trava por pessoa (`RelatorioDeCaixaController::TRAVA_DA_REDE`,
depois do `soltarASessao`, o 429 com a frase do Profissional Parceiro, solta no `finally`), como as Canceladas, os Descontos e o
NPS: a mesma pessoa não empilha pedidos da rede de cerca de 3 s (o segundo volta na hora com o 429, e a tela da 62 põe a rede na fila
`caixa` do `filaDaRede.ts`), e duas pessoas pedem cada uma a sua. **Não resolve o tempo:** o primeiro pedido de cada processo
continua acima de 2 s na unidade e perto de 3 s ou mais na rede (de 2,93 a 3,54 s com o host quieto, de 4,0 a 6,0 s carregado), e o
quente da rede encosta em 1 s ou passa. O detalhe (um caixa só) e as unidades do filtro não travam. **A trava fica presa durante
as consultas**: o `RelatorioDeCaixaTest::testATravaDaRedeFicaPresaDuranteAsConsultas` (a correção da fatia 61) a tenta a cada
consulta, por um registro de SQL, e a exige livre antes de soltar a sessão e presa depois (os irmãos não têm o caso: o D666).
REGISTRADO.

### D666 — A trava da rede dos outros relatórios não tem caso que a veja presa durante as consultas — REGISTRADO em 31/10/2026 — CORRIGIDO em 08/11/2026: os sete irmãos veem a trava presa

**Achado na verificação da fatia 61 (REL-CAIXA-API).** A trava da rede por pessoa (`TravaDeExecucao::obter`, depois do
`soltarASessao`, solta no `finally`) é vista pelos testes só ANTES do pedido (tomada por fora, o pedido dá 429) e DEPOIS dele (o
teste a obtém de novo). Uma `TravaDeExecucao::liberar($trava)` posta logo depois do `if ($trava === false)`, antes das consultas,
passa em todos esses casos -- e a proteção (um pedido da rede de cada vez por pessoa) cai sem aviso. No Caixa, a correção da 61
acrescentou o caso que a tenta a cada consulta por um registro de SQL (`RelatorioDeCaixaTest::testATravaDaRedeFicaPresaDuranteAsConsultas`;
o mutante P61-X15, a `liberar` antes da lista, ficou vermelho por ele nos dois bancos). **Os irmãos seguem sem ele**, conferido
pela leitura em 31/10/2026 (as chamadas a `TravaDeExecucao::obter` de cada teste, só antes e depois do pedido):
`DescontosTest` (:406 e :432), `NpsDoRelatorioTest` (:723 e :749), `ProgramaDeIndicacaoTest` (:342, :366 e :385),
`RelatorioDeRoyaltiesTest` (:690 e :711), `RetiradaDeProdutosTest` (:411 e :437), `VendasCanceladasTest` (:460 e :481) e
`ProfissionalParceiroTest` (:1161, :1186 e :1193; as duas travas, a da rede e a dos clientes da rede). O código deles tem o mesmo
molde do Caixa (a trava numa variável local, solta no `finally`); o defeito é só de teste: um conserto futuro que mova a `liberar`
não fica vermelho.

**O "sem o `finally`" é equivalente** e não pede caso: o handle é uma variável local da ação, e o PHP fecha o arquivo -- e com ele o
`flock` -- quando ela volta (o P61-X16, o `try`/`finally` arrancado no Caixa, ficou verde nos dois casos da trava, como previsto).

**Como fechar:** em cada irmão, o caso do Caixa, com o registro de SQL apontado para o controller dublê de cada um (o contador de
soltas já existe em todos), e o mutante da `liberar` antes das consultas, vermelho pela asserção, nos dois bancos. REGISTRADO.

#### CORRIGIDO em 08/11/2026 -- o caso do Caixa nos sete irmãos (a fatia 126, PORTOES-E-COBERTURA)

O caso do Caixa foi para o trait `tests/Caracterizacao/TravaPresaNasConsultas.php` (`tentarAsTravasACadaConsulta` e
`assertTravasPresasDuranteAsConsultas`): um registro de SQL na conexão tenta cada trava a cada consulta e a solta na hora,
anotando se a sessão já tinha sido solta; antes de soltar, a trava está livre; depois, as consultas da rede (um mínimo medido
por relatório, e alguma delas da tabela dele) a acham presa; e a ação a solta ao terminar. A tabela é "alguma das consultas", e
não "a primeira": as leituras de `colunas_gravadas` e do cadastro da unidade saem do cache quando outro caso já as fez.

`testATravaDaRedeFicaPresaDuranteAsConsultas` entrou no `DescontosTest` (os números e a lista), no `NpsDoRelatorioTest`, no
`ProgramaDeIndicacaoTest` (os números e os clientes, que dividem a trava), no `RelatorioDeRoyaltiesTest`, no
`RetiradaDeProdutosTest`, no `VendasCanceladasTest` e no `ProfissionalParceiroTest` (as duas travas: a dos clientes da rede no
`clientes`, a do melhor da rede no `destaques`). **Os mutantes** T1 a T9, a `TravaDeExecucao::liberar($trava)` posta logo depois
do `if ($trava === false)` em cada um dos nove pontos dos sete controllers (dois no Descontos, dois no Parceiro), ficaram
vermelhos nos dois bancos pela asserção "a trava da rede caiu no meio da consulta", e foram devolvidos pelo sha1. Os casos de
trava de antes (os do 429) seguem verdes com os mesmos mutantes -- era o defeito.

#### A correção depois da verificação (08/11/2026, a mesma fatia)

O trait não exigia, como o molde do Caixa (`assertNotSame([], $antes, ...)`), que as guardas consultassem antes de soltar a
sessão: com `$antes` vazio, a metade "livre antes de soltar" passava sem afirmar nada. Medido com a exigência posta: o
`destaques` do Parceiro tem `$antes` vazio DE VERDADE (o privilégio e a marca vêm da sessão; nada vai ao banco antes de soltar).
O trait ganhou o argumento `$guardasConsultam` (padrão `true`): com ele, `$antes` não pode ser vazio; com `false` (só o
`destaques`, com o motivo), tem de ser vazio -- e a guarda que passar a consultar fica vermelha até alguém olhar a trava nela.
Mutantes, um por vez e devolvidos pelo sha1, nos dois bancos: G1, no `index` dos Descontos, a sessão solta e a trava tomada
ANTES das guardas -- vermelho ("as guardas consultam antes de soltar a sessão"), e VERDE com o trait de antes da correção; G2, o
mesmo no `clientes` do Parceiro -- vermelho, e verde com o trait de antes; G3, só a trava tomada antes das guardas (a sessão solta
no lugar) -- vermelho ("antes de soltar a sessão, a trava ainda não é da ação"); G4, uma consulta posta nas guardas do
`destaques` -- vermelho ("as guardas desta ação leem só a sessão"). O aviso do D666 no `spec-caixa.md` foi atualizado.

### D667 — O Atendimentos lê o ano inteiro de uma vez: 146 MB de pico na maior franquia, e a estreia dos candidatos demora até 27 s — REGISTRADO em 01/11/2026 — PARCIAL em 01/11/2026: a A2 lê o ano mês a mês e faz a grade numa passada; 2025 ainda passa de 128 MB, e a estreia é da A3 — PARCIAL em 01/11/2026: a estreia agrupada deu o mesmo e é mais lenta, e não entrou — CORRIGIDO em 08/11/2026: o mês guardado com 16 chaves e as leituras leves antes do ano; 2025 cabe em 128 MB

**Medido na fatia 63 (REL-ATENDIMENTOS-CONTAS; a A0 e a A1 de `docs/f3-relatorios/spec-atendimentos.md`), em processo, na cópia**
(o `franquiabv_dev`, buffer pool de 128 MB no MySQL; o `medir-p63.php` do rascunho, só leitura): a composição que a A2 vai fazer --
`painelItens` da busca do ano inteiro (do 1º de dezembro do ano anterior ao fim do último mês que já chegou),
`painelComprasDeCredito` do ano, `painelPrimeiroServicoOuCredito` dos candidatos do ano desde 1º de janeiro, e as contas puras da
`ContasDosAtendimentos` --, na unidade 1 (a maior da rede em itens), 2026 (36.205 itens) e 2025 (55.486), duas voltas nas duas
ordens, três rodadas por processo:

- **o pico:** 146 MB em 2026 e 214,5 MB em 2025 (com os 512M do `docker/php/php.ini` do contêiner; 2025 fica a 41,5 MB da
  metade, 256 MB). **Com `memory_limit = 128M` os dois anos caem num erro fatal** no `ContasDoPainelDaUnidade::normalizar` (o
  resultado cru e a lista normalizada vivem juntos, como no D631); **com 256M, os dois caem no `OutOfSpaceException`** do cache de
  metadados do Doctrine (o achado do D646: o adaptador de memória recusa acima da metade do limite). Rodado de novo na verificação
  (01/11/2026, 05:29, uma rodada por ano e por limite, no MySQL), com as saídas guardadas em `p63c-limite.txt` do rascunho;
- **frio e quente** abaixo: o frio é a primeira de três rodadas de cada processo (quatro por banco) e o quente, a segunda e a
  terceira (oito por banco), separados em `p63c-medida-frio-quente.txt`. O frio é o do PROCESSO (o cache do Doctrine, o opcache do
  CLI), **e não o buffer pool frio**: o processo anterior já o tinha aquecido, e nenhuma rodada mediu o banco recém-iniciado;
- **o primeiro serviço ou crédito** (`painelPrimeiroServicoOuCredito`, cerca de 4.700 candidatos): **no MySQL, frio de 11,9 a
  26,7 s e quente de 11,4 a 20,8 s** (o 26,7 s é o primeiro processo de 2025); no PostgreSQL, frio de 0,94 a 1,35 s e quente de
  0,51 a 0,83 s;
- os itens: no MySQL, frio de 3,5 a 11,4 s e quente de 1,6 a 11,2 s; no PostgreSQL, frio de 2,1 a 2,85 s e quente de 0,96 a
  1,7 s. As compras de crédito: no MySQL de 0,29 a 5,5 s frio e de 0,36 a 5,8 s quente (2025 sempre acima de 4,3 s), abaixo de
  0,1 s no PostgreSQL;
- **as contas puras** (lista, linhas, fora, unidade e categorias): no MySQL, frio de 0,97 a 2,50 s (o 2,50 no primeiro processo de
  2025, logo depois do de 2026) e quente de 0,79 a 1,65 s; no PostgreSQL, frio de 1,03 a 1,49 s e quente de 0,79 a 1,53 s; os novos,
  de 0,31 a 1,05 s nos dois;
- **a bancada sintética do desenho (53 mil itens, 18 colaboradores, 13 meses) NÃO fica abaixo dos 2 s pedidos.** O "1,495 s" da
  implementação não estava em saída guardada nenhuma; rodada oito vezes na verificação (`MEDIR_ATENDIMENTOS=1`,
  `p63c-bancada.txt`), deu 2,34, 2,61, 1,97, 1,83, 2,56, 2,32, 2,12 e 2,65 s -- seis acima de 2 s -- e 193 MB de pico, com o
  contêiner a carga 1,3 em 20 núcleos. Pedaço a pedaço (`medir-p63c-bancada.php`, `p63c-bancada-pedacos.txt`, três rodadas): a
  `unidade` leva de 1,05 a 1,27 s (o `dosColaboradores` sem lista e o `porCategoria` de cada mês e do ano, sobre os mesmos itens
  que as `linhas` já percorreram), as `linhas` de 0,52 a 1,03 s, o `foraDaLista` de 0,21 a 0,32 s, a `lista` de 0,14 a 0,25 s.

**Por que não se conserta aqui:** a fatia é só das contas puras, sem rota; a forma da leitura é da A2 (os números) e da A3 (os
clientes novos, "num pedido à parte" -- e é lá que mora o `painelPrimeiroServicoOuCredito` de 11 a 27 s). **O caminho**, numa
delas: ler os itens mês a mês e normalizar cada mês antes do seguinte (o cru de um mês por vez, como os Royalties fizeram com as
metades do ciclo no D646), e as leituras leves antes dos itens; e, para os novos, a trava por pessoa e a fila da tela, como os
clientes do Parceiro (o D311). **Para a bancada:** a `unidade` e as `linhas` do mesmo período fazem o `dosColaboradores` e o
`porCategoria` duas vezes sobre os mesmos itens; uma passada só por período (a linha da unidade somada das linhas de todos, como o
numerador das médias já é) é o primeiro candidato, provado pelo oráculo de novo. O teto de tempo e de memória do pedido do ano é
decisão da A2, com estes números. REGISTRADO.

**PARCIAL em 01/11/2026 (a fatia 64, REL-ATENDIMENTOS-API; a A2).** O que entrou: o `Adm\AtendimentosController` lê o ano
MÊS A MÊS (`ContasDosAtendimentos::faixasDaBusca`: dezembro do ano anterior e cada mês que já chegou, o cru de um mês por vez,
normalizado antes do seguinte) e faz a grade NUMA PASSADA por período (`ContasDosAtendimentos::grade`: o `dosColaboradores` de
todos e o `porCategoria` uma vez em cada mês e no ano, a lista do ano tirada da mesma conta; o `RegrasDosAtendimentosTest`
prova que dá os mesmos números das quatro funções da A1, e o oráculo na cópia também). Medido em processo, pelo caminho do
controller (`medir-p64.php`; `p64-medida.txt`, `p64-limite.txt` e `p64-bancada.txt` do rascunho), unidade 1:

- **o pico:** 88 a 90 MB em 2026 (era 146) e 131 a 133 MB em 2025 (era 214,5). Com `memory_limit = 128M`, 2026 passa e **2025
  ainda cai** (o erro fatal nas contas, `ContasDoPainelDaUnidade.php:836`); com 256M, os dois passam (antes, os dois caíam);
- **o tempo, MySQL com 128 MB de buffer pool**, duas voltas nas duas ordens (o frio é a primeira de três rodadas do processo):
  2026 de 4,6 a 6,6 s frio e de 2,6 a 3,8 s quente; 2025 de 6,5 a 10,2 s frio e de 4,3 a 7,2 s quente. Com 2 GB (e de volta a
  128 MB, conferido): 2026 de 4,9 a 5,2 s frio e 2,6 a 2,8 s quente; 2025 de 6,6 a 6,8 s frio e 4,0 a 4,9 s quente. No
  PostgreSQL: 2026 de 3,9 a 4,5 s frio e 1,6 a 1,8 s quente; 2025 de 5,1 a 5,4 s frio e 2,7 a 3,1 s quente. Nenhum acima de 15 s;
- **a bancada sintética** (53 mil itens): a grade numa passada de 0,64 a 0,83 s em seis rodadas; as quatro funções da A1 em
  sequência, no mesmo processo, de 1,61 a 2,14 s. O pico da bancada (261,5 MB) é o dos 53 mil itens sintéticos inteiros.

**O que resta:** o pico de 2025 acima de 128 MB (o ano normalizado inteiro fica na memória: a coluna "Ano" é o ano como um
período só; o caminho é guardar só as chaves que as contas leem, ou contar o ano por acumulação, mês a mês), e o
`painelPrimeiroServicoOuCredito` dos candidatos, que é da A3 (os clientes novos, a fatia 65): a A2 não o chama. A trava por
pessoa entrou (`atendimentos-<id>`, o 429 com a frase da Indicação), e a fila `atendimentos` da tela. PARCIAL.

**A estreia NÃO foi tentada na 64 (a verificação dela, 01/11/2026).** As notas da fatia pediam à A2 também "procurar a forma da
estreia que o oráculo da 63 achou em segundos" (duas junções no lugar do `EXISTS` por cliente, a forma do `medir-p63-oraculo.py`
do rascunho), sem mudar o resultado. Não foi feito: o `VendaRepository::painelPrimeiroServicoOuCredito` está como a 63 o deixou, e
a estreia dos candidatos da unidade 1 continua de 11 a 27 s no MySQL com 128 MB de buffer pool (os números da 63, não medidos de
novo). Nada da A2 fica lento por isso -- os clientes novos não estão no `AtendimentosJson::NUMEROS` nem no controller --, mas a A3
(a 65) começa por ela: a forma nova, o oráculo da 63 nos dois bancos contra a de hoje, e a medida com o buffer pool dito. Segue
PARCIAL.

**PARCIAL em 01/11/2026 (a fatia 65, REL-ATENDIMENTOS-TELA; a A3).** **A estreia rápida foi tentada primeiro, e NÃO entrou.** A
forma nova é a do oráculo da 63: duas perguntas agrupadas por cliente -- o `MIN` da compra de crédito e o `MIN` do serviço pela
junção nos itens -- e o menor dos dois, só para quem não estreou antes do ano. Lado a lado com o
`VendaRepository::painelPrimeiroServicoOuCredito` de hoje, no mesmo processo, sobre os candidatos do ano da unidade 1 (4.647 em
2026, 5.443 em 2025) (`medir-p65-estreia.php`, `p65-estreia.txt` do rascunho): **o resultado é o mesmo onde foi comparado** -- no
PostgreSQL, 513 clientes em 2026 e 697 em 2025, o mesmo sha1 nas duas formas, nas duas ordens; no MySQL, SÓ 2026 (513, o mesmo
sha1), numa ordem só (a de hoje e depois a nova); julho de 2026, 57, o do painel --, e **a forma nova é mais lenta nos dois**: no
MySQL com 128 MB de buffer pool, 302 e 305 s contra 22,8 e 33,8 s da de hoje (2026); no PostgreSQL, de 6,3 a 13,5 s contra 0,7 a
2,5 s. **O 2025 do MySQL e a segunda ordem dele não foram medidos** (a verificação da fatia 65): o roteiro não tinha corte por
tempo, e a forma nova rodou DUAS vezes acima de 300 s antes de o 2025 começar -- o corte dos 120 s das notas veio uma rodada e um
ano tarde, à mão, às 14:45 (o PHP morto por `/proc` e a consulta, só de leitura, por `KILL QUERY`). O `medir-p65-estreia.php` do
rascunho ganhou o corte depois (para na primeira forma acima de 120 s, saída 3, e o `.sh` para o resto). O agrupado lê a história inteira de cada candidato, e o primeiro passo de hoje
para no primeiro serviço ou crédito de antes do ano (o `OR` que impede o semi-join). A consulta não mudou; o painel, o histórico,
os novos do recorte e a Indicação, que a chamam, não mudaram.

**Os clientes novos do ano** (`GET .../atendimentos/clientes`, a A3) pelo caminho do controller, em processo (`medir-p65.php`,
`p65-medida.txt`), unidade 1, três rodadas por processo (a primeira é a fria do PROCESSO; o buffer pool não foi esvaziado -- o
banco não pode ser reiniciado), nas duas ordens: no **MySQL com 128 MB de buffer pool**, 2026 de 20,5 a 27,7 s e 2025 de 33,3 a
40,1 s; no PostgreSQL, 2026 de 2,6 a 6,6 s e 2025 de 3,7 a 7,7 s. O pico do PHP, 78 MB em 2026 e 118,5 MB em 2025. **Na rodada
do oráculo da cópia** (o mesmo caminho, uma rodada por recorte, às 15:20 e 15:25, com os mutantes do front rodando no mesmo host;
`p65-oraculo-copia.txt`), o MySQL deu **50,4 s em 2026 e 58,5 s em 2025** -- a faixa acima não as tinha (a verificação da fatia
65); a carga do host é a causa provável, não provada. Nenhum recorte passou dos 120 s, e o maior, 58,5 s, é acima dos 32 s do
teto do desenho (3.8 do `spec-atendimentos.md`) -- é a família do D311: o caminho é a estreia gravada. A trava por pessoa dos clientes é própria (`atendimentosClientes-<id>`), e a tela os pede depois dos
números, com "…" nas células enquanto contam.

**A exportação de 2025 da unidade 1** (os números e os clientes no mesmo pedido): o XLS em 46,0 s no MySQL e 11,9 s no
PostgreSQL, o PDF em 41,6 s e 10,9 s; o pico, de 133 a 139 MB (a medida lê mais um `index` no mesmo processo, para trocar o nome
de cada linha pelo id; o `index` sozinho é de 131 a 133 MB); com `memory_limit` 512M (`ini_get`, o do `docker/php/php.ini` do
contêiner) e 256M os dois arquivos saem; **com 128M os dois caem** no erro fatal do `ContasDoPainelDaUnidade.php:836`, o mesmo do
`index` de 2025. O limite não subiu.

**A exportação fora da trava podia parar o ERP (a verificação da fatia 65, 01/11/2026).** Entregue sem trava, cada pedido do
arquivo ocupava um dos cinco workers do PHP-FPM do contêiner (`pm.max_children = 5`, o D67; o `fastcgi_read_timeout` é de 300 s)
por uns 40 s, e o PDF é um link que só se desliga enquanto os números releem: quem abre a tela (os números e os clientes) e clica
no PDF, no XLS e no PDF de novo ocupa os cinco, e o ERP e o aplicativo param para todos. E o arquivo lia os itens do ano DUAS
vezes (os números e os clientes, cada um os seus: 13 mais 12 leituras de um mês). **Consertado:** a base ganhou o gancho
`RelatorioAdmController::travaDoArquivo` (pedido DEPOIS de soltar a sessão; o 429 com a frase das consultas por pessoa; solto no
`finally` da `exportacaoAction`), e o Atendimentos devolve nele a trava dos NÚMEROS da pessoa -- cada pessoa ocupa no máximo dois
workers: os números ou o arquivo, e os clientes; e o `documento` lê os itens uma vez (`itensPorMes`) para as duas montagens. Os
casos `testAExportacaoAndaNaTravaDosNumeros` e as 13 leituras no `testAExportacao`; os mutantes P65-V5, V7, V8 e V9 vermelhos, e o
V6 (sem soltar a trava) equivalente -- o handle é local, e o PHP o fecha ao sair da função, o que solta o `flock`. **Medido de novo**
(`medir-v65-tempo.sh`, `v65-medida.txt`; unidade 1, 2025, buffer pool de 128 MB): o XLS em 31,5 e 33,3 s e o PDF em 28,1 e 33,5 s
no MySQL (eram de 41,3 a 46,0 s), 8,2 e 8,3 s e 7,2 e 7,8 s no PostgreSQL (eram de 10,9 a 11,9 s); o pico, de 133 a 139 MB, igual;
com 128M os dois ainda caem no `ContasDoPainelDaUnidade.php:836`. No PDF, o 429 da segunda aba é a página do erro em JSON.

**O que resta:** o pico de 2025 acima de 128 MB (o ano normalizado inteiro na memória, nos números, nos clientes e no arquivo), e
os clientes do ano no MySQL acima de 30 s (a estreia gravada, o D311). PARCIAL.

**CORRIGIDO em 08/11/2026 (a fatia 122, RELATORIOS-ARQUIVO-FRASE-E-MEMORIA): o pico de 2025 cabe em 128 MB.** A causa medida: a
linha de `painelItens` sai do `normalizar` com 17 chaves, uma acima das 16 em que a tabela de um arranjo do PHP dobra (de 16 para
32 posições); 60 mil itens de 17 chaves ocupam 90 MB no contêiner, e de 16, 53 MB (`medir-p122-chaves.php` do rascunho). O ano
inteiro precisa ficar na memória -- a coluna "Ano" é o ano como um período só, e os clientes distintos não saem da soma dos meses --,
então cada mês é guardado SEM A CHAVE QUE NENHUMA CONTA LÊ: `ContasDosAtendimentos::paraOAno` tira a `unidade` de quem fechou (só o
`porDiaDaSemana` do painel a lê; o grep das contas do Parceiro, do painel, da fidelização e do histórico), por `array_diff_key`, que
monta o arranjo do tamanho certo (o `unset` deixaria a tabela de 32: medido, o mutante cai com 128M). E os clientes leem as leituras
leves antes do ano (`AtendimentosController::leiturasLevesDosClientes`): as compras de crédito e as duas consultas da estreia
perguntadas uma vez com o cliente 0, que não existe -- o cache das consultas do Doctrine recusa guardar acima da metade do limite (o
D646), e a primeira delas lida depois do ano derrubava os clientes e o arquivo com o `OutOfSpaceException`. **Medido em processo,
só leitura, na cópia** (`medir-p122-atendimentos.sh` do rascunho, pelo `medir-p65.php`; unidade 1, hoje 2026-11-01; `p122-atendimentos.txt`):
com 512M, os números de 2025 de 131 para 103 MB, os de 2026 de 90 para 78 MB, os clientes de 118,5 para 94,5 MB, o XLS de 139 para
111 MB; **com 128M, os números, os clientes, o XLS e o PDF de 2025 passam, nos dois bancos** (antes, o erro fatal no
`ContasDoPainelDaUnidade.php:898` e o `OutOfSpaceException`). O oráculo: o sha1 do corpo dos números (2025 e 2026), dos clientes
(2025) e das células do XLS (2025) igual antes e depois no MySQL, e igual no PostgreSQL. Os casos:
`RegrasDosAtendimentosTest::testOAnoGuardadoSemAChaveQueNinguemLe` (17 chaves para 16, e a grade, a lista, os candidatos e os novos
iguais com e sem a chave, na unidade e no `proprio`), `AtendimentosTest::testAsLeiturasLevesDosClientesVemAntesDoAno` (nos
clientes e no arquivo, as compras e as duas consultas da estreia antes do primeiro mês) e
`AtendimentosTest::testCadaMesGuardadoSemAChaveQueNinguemLe` (a correção da fatia 122: nos números, nos clientes e no arquivo,
tantas consultas dos itens quantos meses passaram pelo `itensDoMes`, e cada item sem a `unidade` e com 16 chaves ou menos), nos dois
bancos. **A leitura do mês num lugar só (a correção da fatia 122):** a primeira montagem chamava o `paraOAno` em dois lugares do
controller (o `itensPorMes` e o `montarClientes`), e trocar as duas chamadas de volta pelo `Contas::normalizar(...)` puro deixava
a suíte verde -- a regra pura era testada, a chamada não; agora os dois leem o mês pelo `AtendimentosController::itensDoMes`
(protegido, para o caso contar), e o `conferir-recorte-por-unidade.php` vigia o `painelItens` nele e a chamada a ele nos dois.
Mutantes, devolvidos pelo sha1: o ano com as 17 chaves, a estreia sem a pergunta de antes e as compras depois do ano (vermelhos nos
dois bancos); o `montarClientes` e o `itensPorMes` lendo o mês sem o `itensDoMes` (o N1 e o N2) e o `itensDoMes` sem o `paraOAno`
(o N3), vermelhos nos dois bancos; o `unset` no lugar do `array_diff_key` (verde na suíte, vermelho no medidor: os números de 2025
caem com 128M -- o caso conta as chaves, e o `unset` deixa 16 numa tabela de 32). **O tempo dos clientes:** os
20 a 58 s do MySQL eram do Windows; no Mac, com o mesmo buffer pool de 128 MB, os clientes de 2025 levam de 1,4 a 3,5 s no MySQL e
de 0,8 a 3,0 s no PostgreSQL (uma rodada por processo, a primeira do processo), abaixo dos 32 s do desenho; a trava por pessoa
(`atendimentosClientes-<id>`) é a da família do D311. Não medido: a produção (o `memory_limit` dela segue por ler, o D631).
CORRIGIDO.

### D668 — O XLS antigo do Atendimentos põe em cada colaborador os clientes da unidade inteira — REGISTRADO em 01/11/2026

**Registrado na fatia 64 (REL-ATENDIMENTOS-API; o D-at1 da seção 4 de `docs/f3-relatorios/spec-atendimentos.md`).** O
`Xls/Atendimentos.php` chama `colunasTotalizadores` em cada bloco de colaborador, e ela escreve, para os clientes atendidos e os
novos, o número da UNIDADE (`$totalClientes[$mes]`, lido do `dashboard_faturamento` pelo `AtendimentosController::totalClientes`
antigo). Na cópia, o colaborador de maior faturamento da unidade 1 saía em julho de 2026 com 2.236 clientes e 45 novos, no lugar
de 568 e 9 (`rel-desenho-0.md`). O PDF não erra: lê a linha do colaborador. **A tela nova (a A3, a fatia 65, com a exportação
gerada da mesma resposta) é o conserto, e o XLS antigo morre na A4.** Até lá, o escopo `proprio` não recebe mais o XLS (a A2,
o D670). REGISTRADO.

### D669 — O XLS antigo com um colaborador escolhido imprime um TOTAL misturado — REGISTRADO em 01/11/2026

**Registrado na fatia 64 (o D-at2 da seção 4 do `spec-atendimentos.md`).** O TOTAL do `Xls/Atendimentos.php` sai sempre; com
`?usuario=`, ele soma só aquele colaborador nos serviços, nos produtos e nas vendas, e escreve os clientes da unidade inteira. O PDF
já pula o TOTAL nesse caso. Morre na A4, com o arquivo antigo; o gerente e o administrador continuam podendo pedi-lo até lá (o
próprio, não: a A2 o manda ao PDF). REGISTRADO.

### D670 — O Atendimentos antigo mostrava a unidade inteira a quem não é gerente — REGISTRADO em 01/11/2026 — PARCIAL em 01/11/2026: o arquivo do próprio é só o PDF do bloco dele; o formulário antigo ainda lista os nomes dos colegas

**Registrado e tratado na fatia 64 (o D-at3 da seção 4 do `spec-atendimentos.md`; a pergunta 3 do dono, "só a si mesmo").** Na
cópia, 2 colaboradores e 1 caixa com o privilégio viam, pelo arquivo antigo, o "Total em vendas" dos colegas e da unidade
(`rel-desenho-0.md`). Desde a A2: a tela nova mostra ao escopo `proprio` só a linha dele (o servidor lê só os itens dele); o item
do menu antigo passa pela mesma política (`veOMenu`), e o `gerarAction` antigo pede o arquivo pela função pura
`AtendimentosController::pedidoDaExportacao` -- no `proprio`, o usuário da sessão e o PDF, qualquer que seja o `?usuario=` ou o
`?formato=` (o `AtendimentosAntigoTest`; a vigia no `conferir-recorte-por-unidade.php`). **O que resta:** o formulário antigo
(`Form/Atendimento.php`) continua listando os nomes da unidade na lista de colaboradores para quem é `proprio` -- só nomes, sem
número --, e a tela morre na A4. PARCIAL.

### D671 — A cota-parte tem quatro contas nos quatro arquivos de hoje — REGISTRADO em 02/11/2026 — PARCIAL em 02/11/2026: o servidor novo tem uma conta só; a exportação nova e a aposentadoria do PDF antigo restam — PARCIAL em 02/11/2026: o item do menu abre a tela nova; a exportação nova e o PDF antigo restam — PARCIAL em 02/11/2026: o PDF e a planilha novos saem da mesma resposta; os arquivos antigos restam no ERP até a aposentadoria (o D306)

**Registrado na fatia 66 (REL-COTA-PARTE-SERVIDOR; o D-cp1 da seção 4 do `docs/f3-relatorios/spec-cota-parte.md`).** Cada
arquivo da Cota-parte antiga faz a sua conta: o PDF V1 recalcula o avulso sem a extra e deixa a sessão de pacote gravada
(`PdfVendasVersao1.php`); o PDF V2 grava o avulso e recalcula a sessão pelo preço médio (`PdfVendasVersao2.php`); o XLS V1
recalcula os dois (`Xls/VendasVersao1.php`). Na rede, em julho de 2026 (o desenho, 02/10/2026): R$ 654.910,48 de gravada, R$
656.925,74 no PDF V1 e R$ 656.929,50 no XLS V1. **O que a fatia 66 fez:** o servidor novo (`ISRelatorio\Regra\ContasDaCotaParte`,
`GET /adm/api/relatorios/cota-parte` e a v2) tem UMA conta, por item e em centavos: a gravada, e a recalculada com o percentual de
hoje mais a extra de hoje, com a sessão de pacote gravada (`A_SESSAO_RECALCULA = false`, a resolução do `spec-ordem.md`, seção 2,
item 3). O oráculo em Python, nas duas cópias, dá na rede em julho de 2026 R$ 654.911,28 na gravada e R$ 656.926,57 na recalculada
-- o número do PDF V1 com o arredondamento por item e a extra (que em julho não muda item nenhum). **O que resta:** a exportação
nova (a CP3, a fatia 68) sai da mesma resposta; os quatro arquivos antigos continuam no ERP até a CP4 aposentá-los. PARCIAL.

**A frase de "desde quando vale" já descreve a conta nova (a verificação da fatia 66).** A
`RegraDaCotaParte::FRASE_DE_QUANDO_VALE` (as duas telas de Usuários) passou a dizer que a Cota-parte refaz com o percentual de hoje e
a extra de hoje, com a sessão de pacote gravada -- a conta do servidor novo, que ninguém abre antes da CP2. Até a CP2 e a CP4, o item
do menu abre os arquivos antigos, que a frase não descreve: o PDF V1 refaz sem a extra, e o XLS V1 refaz também a sessão. Contado na
cópia (só SELECT, 02/11/2026): itens concluídos de serviço ou produto com a extra do par, 2 em 01/2026, 2 em 02, 3 em 03, 2 em 04, 1
em 06 e nenhum em 07, e 39 em 2025; 369 sessões de pacote em julho de 2026. A troca foi pedida pelas notas da fatia (a frase descreve
o relatório novo); fecha junto com o resto deste débito. PARCIAL.

**A tela nova abre pelo menu (02/11/2026, a fatia 67, a CP2).** O item "Cota-parte" do menu da tela nova abre a tela nova (a
recalculada, com a conta única do servidor), e a frase de "desde quando vale" de Usuários passou a descrever o que o menu abre ali.
No ERP antigo, o item do `layout.phtml` continua levando ao formulário que abre o PDF V1 e o XLS V1 (e o caixa, ao PDF do dia), e
quem os abre pela URL antiga continua vendo as contas velhas: a exportação nova (a CP3, a fatia 68) e a aposentadoria dos arquivos
antigos (a CP4) fecham o resto. PARCIAL.

**O arquivo novo (02/11/2026, a fatia 68, a CP3).** O PDF e a planilha `.xlsx` da Cota-parte e da v2 saem em
`GET /adm/api/relatorios/cota-parte/exportacao` (e `cota-parte-v2/exportacao`) das MESMAS respostas que a tela recebe, já
escondidas (`CotaParteJson::documento`): uma conta só, também no arquivo. O oráculo da planilha contra o JSON do mesmo pedido, na
cópia, 21.662 conferências em cada banco, 0 divergências. **O que resta:** os quatro arquivos antigos continuam no ERP antigo
(desde a 68 pela política, o D376) até a aposentadoria deles, que é do D306, e quem os abre ainda vê as contas velhas. E as colunas
do PDF antigo seguem pelo id do grupo: o grupo 9 (0 ativos e 1 inativo na cópia), no `proprio` desde a 68, ainda recebe no
`PdfVendasVersao1` o "Valor Total" com a própria produção (o cabeçalho e os totais, o ramo `grupo == 2 || grupo == 9`) e não a cota;
a planilha antiga, pelas marcas, não (a verificação da fatia 68). E o grupo que só tem a marca de caixa sem ser o 2 (nem o 9, nem
gerente) recebe o PDF antigo QUEBRADO: o `cabecalhoTabela` não acha ramo, a tabela fica sem largura, e o PHP escreve avisos no
corpo, com 200 e `text/html` no lugar do PDF -- visto no navegador da 68, no banco de contrato, com um caixa num grupo de id
90009902; o mesmo caixa no grupo 2 recebe o PDF do dia, igual com ou sem colaborador e datas na URL. Não nasce na 68 (o
`PdfVendasVersao1` não mudou), e na cópia ninguém cai nele: os 58 do escopo `dia` são todos do grupo 2. Sai com o PDF antigo. PARCIAL.

### D672 — O portão de grupos por id não vê `getUsuario("grupo")` com aspas duplas, nem `===` — REGISTRADO em 02/11/2026 — PARCIAL em 02/11/2026: o sítio do `SessaoJson` saiu (a CP2); o do `layout.phtml` e as agulhas restam — CORRIGIDO em 02/11/2026: o sítio do `layout.phtml` saiu e as agulhas se alargaram (a CP4)

**Registrado na fatia 66 (o D-cp2 da seção 4 do `spec-cota-parte.md`; achado pelo desenho em 02/10/2026).** Dois sítios comparam o
id do grupo e escapam das agulhas do `scripts/conferir-grupos-por-id.php`: o item da Cota-parte no `layout.phtml`
(`$sessao->getUsuario("grupo") != 2`, o PDF do caixa) e o `SessaoJson::menu` (`'vendasExportadas' => $grupo === 2`). As
agulhas casam a forma com aspas simples e o `==`. **Como fechar:** a CP2 (a fatia 67) tira o segundo ao fundir as duas formas do
item no menu da tela nova; a CP4 tira o primeiro e alarga as agulhas, com o mutante de cada forma. REGISTRADO.

**O segundo sítio saiu (02/11/2026, a fatia 67, a CP2).** A chave `vendasExportadas` (e a `$grupo` que só ela lia) saiu do
`SessaoJson::menu`: a Cota-parte é um item só no menu da tela nova, guardado pela `relatorioCotaParte` da política. O
`SessaoApiTest` exige a chave fora (o mutante P67-X11, a chave de volta, fica vermelho por ele). O `if ($sessao->getUsuario("grupo")
!= 2)` do `layout.phtml` continua -- o PDF do dia do caixa no ERP antigo, até a CP4 --, e as agulhas do portão continuam as de
antes. PARCIAL.

**Corrigido em 02/11/2026 (a fatia 68, a CP4).** O `if ($sessao->getUsuario("grupo") != 2)` do item da Cota-parte no
`layout.phtml` virou o escopo `dia` da política (`RecorteDoRelatorio::escopo(...) !== RecorteDoRelatorio::DIA`; o mesmo conjunto: na
cópia, as 58 pessoas do PDF do dia são as 58 do grupo 2, e ninguém mais). As agulhas do `scripts/conferir-grupos-por-id.php` se
alargaram: a do `$grupo` aceita `===` e `!==`, e a do `getUsuario('grupo')` aceita as aspas duplas, o `===` e o `$sessao` sem o
`this->`. E o portão passou a se provar: uma lista de formas conhecidas (as duas que escaparam inclusive) tem de ser achada inteira
por alguma agulha, e a agulha que encolher fica vermelha (saída 1) mesmo sem a forma no código. A entrada do grupo 9 do
`VendasController` saiu do inventário (o D376). Mutantes: o `!= 2` de volta no layout, o `'vendasExportadas' => $grupo === 2` de
volta no `SessaoJson` e a agulha nova sem o `===`, cada um vermelho no portão. CORRIGIDO.

### D673 — O arredondamento por item da Cota-parte depende do pré-arredondamento do `round` do PHP 8.1 — REGISTRADO em 02/11/2026

**Registrado na fatia 66 (o D-cp4 da seção 4 do `spec-cota-parte.md`).** A cota gravada vem do `double` (`20.295` é guardado como
20,29499...), e `(int) round(20.295 * 100)` dá 2030 no PHP 8.1 do contêiner só por causa do pré-arredondamento; a recalculada do
mesmo item (4510 × 45%) dá 2029,5 → 2030. Em julho de 2026, 205 dos 31.898 itens da rede caem exatamente em meio centavo (o
desenho). Se uma versão nova do PHP mudar o `round`, a v2 se encheria de avisos falsos de um centavo. **O que prende:** o
`RegrasDaCotaParteTest::testOMeioCentavoSobeNasDuasReguas` (o `normalizar` sobre a linha crua com `comissao = 20.295`, sem
aviso) fica vermelho antes da v2; o mutante P66-P4 (a gravada truncada) o deixou vermelho. **Como fechar, se o PHP mudar:** somar a
cota em décimos de milésimo de real (`(int) round($comissao * 10000)` e base × percentual) e arredondar uma vez por número
mostrado. REGISTRADO.

### D674 — A gorjeta dos arquivos velhos da Cota-parte soma venda não concluída e ignora a franquia de quem fechou — REGISTRADO em 02/11/2026

**Registrado na fatia 66 (o D-cp5 da seção 4 do `spec-cota-parte.md`).** `GorjetaRepository::selecionarGorjetasPorColaboradorPeriodo`,
que os arquivos antigos da Cota-parte usam, soma as gorjetas de `status != 0` -- a comanda aberta (3) entra -- e não filtra a
unidade de quem fechou. O desenho mediu, em julho de 2026, 116 gorjetas e R$ 2.330,85 nas duas contas, sem diferença. **A
Cota-parte nova** conta só as gorjetas das vendas da base (concluídas, da unidade do recorte), pelo colaborador da gorjeta: no
`CotaParteTest`, a gorjeta da venda aberta e a da cancelada ficam fora. **Como fechar:** morre com os arquivos antigos (a CP4).
REGISTRADO.

### D675 — A Cota-parte da rede em julho fica até 49% acima do Profissional Parceiro no frio, e a meta era 20% — REGISTRADO em 02/11/2026 — PARCIAL em 02/11/2026: medido de novo por consulta; o frio dentro da meta em 7 de 8 pares, o quente do MySQL acima numa rodada — PARCIAL em 08/11/2026: com a máquina calma, 26% a 30% no quente, e o mesmo com o buffer pool em 1 GB; as linhas por produto pesam 1%, e a meta e o conserto previsto foram ao dono — ENCERRADO em 08/11/2026: o dono aceitou a medida, e a meta da Cota-parte da rede passa a até 30%

**Registrado na verificação da fatia 66 (REL-COTA-PARTE-SERVIDOR; a CP1 de `docs/f3-relatorios/spec-cota-parte.md`, "O que
medir": "A Cota-parte do mês tem de ficar até 20% acima dele", do Profissional Parceiro da rede em julho).** Medido em 02/11/2026
(`p66-medida.txt` do rascunho da sessão; memory_limit 512M, buffer pool de 128 MB antes e depois, três rodadas por processo, a
primeira fria), lado a lado na mesma sessão: no MySQL, a Cota-parte da rede em julho 4,21 s frio contra 2,83 e 3,09 s do Parceiro
(de 36% a 49% acima); quente, 1,64 e 1,37 s contra 1,11, 1,32, 1,43 e 1,31 s (a média 17% acima, a pior rodada da Cota-parte 48%
acima da melhor do Parceiro). No PostgreSQL, 4,20 s frio contra 5,69 e 3,48 s, e 2,18 e 2,53 s quente contra 1,86 a 3,00 s: abaixo
nas médias. O pico é o mesmo (134 MB). A causa do frio não foi medida: a Cota-parte lê a mais as extras
(`UsuarioComissaoExtraRepository::percentuaisExtras`, a tabela inteira), uma entidade que o Parceiro não carrega (o cache de
metadados do Doctrine no primeiro pedido, como no D646), e faz as contas das duas réguas. **Como fechar:** a CP2 mede de
novo, separando o frio por consulta (as extras, as gorjetas, os itens), e põe o que passar no primeiro pedido do processo abaixo da meta, ou leva ao dono a meta do frio. REGISTRADO.

**Medido de novo na fatia 67 (a CP2, 02/11/2026, antes de abrir a tela), POR CONSULTA** (`p67-medida.txt` do rascunho; o
`medir-p67.php` despacha o controller em processo, só leitura, com um `DebugStack` na conexão; memory_limit 512M; buffer pool de
128 MB antes e depois, nunca reiniciado; três rodadas por processo, a primeira fria; a Cota-parte e o Parceiro da rede em julho
lado a lado, duas vezes cada, na mesma sessão; duas sessões em cada banco -- a primeira da manhã, com a máquina carregada, e a
segunda depois dos ensaios). **O frio** (a primeira rodada do processo), Cota-parte contra o Parceiro de cada par: no MySQL,
6,43 contra 5,76 s (+12%) e 6,05 contra 5,32 (+14%), depois 5,01 contra 4,81 (+4%) e 5,05 contra 4,44 (+14%); no PostgreSQL,
8,50 contra 8,00 (+6%) e 7,55 contra 7,48 (+1%), depois 5,58 contra 5,85 (−5%) e 6,59 contra 5,35 (+23%). **O quente** (as duas
rodadas seguintes, em média): no MySQL, 3,02 contra 2,34 s (+29%) na sessão carregada e 1,88 contra 1,65 (+14%) na outra; no
PostgreSQL, 4,05 contra 3,67 (+10%) e 3,02 contra 2,80 (+8%). O pico, 140 MB contra 134 no MySQL e 136 contra 134 no
PostgreSQL. **Onde o tempo vai:** o SQL da Cota-parte é o do Parceiro, na mesma faixa dele (a mesma leitura dos itens, de 0,534 a 1,184 s
no MySQL e de 1,223 a 2,543 s no PostgreSQL, contra 0,519 a 1,006 s e 1,059 a 3,215 s no Parceiro; as gorjetas em 21 lotes, de
0,078 a 0,235 s; as extras, até 0,006 s; as colunas do PDV, de 0,001 a 0,013 s -- as faixas por consulta tiradas da saída pelo
`medir-p67c-faixas.py`, em `p67c-faixas-d675.txt`, na correção da fatia: o texto anterior dizia 0,6 a 1,2 s e 1,5 a 2,5 s, que a
saída não dá); a diferença é o PHP (o "resto": 1,169 a 2,17 s contra 0,947 a 1,556 s no quente do MySQL), e as partes refeitas à mão dão as
contas das duas réguas e das linhas por produto (`ContasDaCotaParte::doRecorte`) em 0,2 a 0,6 s por mês, e o `normalizar` em 0,14 a
0,32 s, como o do Parceiro. **O frio do primeiro pedido do processo** cai na primeira consulta (nas partes, as extras levam de 0,8
a 1,7 s frias e 0,001 s quentes: é o custo de abrir a conexão e o Doctrine, e não a tabela). Os 36% a 49% da fatia 66 não se
repetiram: o Parceiro daquela medida estava mais rápido no frio (2,83 a 3,09 s) do que nas quatro desta. **O que não se fez:** o
ganho candidato que não muda número nenhum é a lista não montar as linhas por produto, que só o extrato lê (uma parte dos 0,2 a 0,6
s do `doRecorte`); ele mexe nas contas da CP1, no oráculo e nos mutantes dela, e não foi forçado. A tela abriu (o dono: "mantenha a
rede, siga o desenho"). **Como fechar:** medir com a máquina calma (o par acima de 20% é de uma sessão com o Parceiro frio
variando de 4,4 a 8,0 s entre as quatro) e, se o quente do MySQL seguir acima, montar as linhas por produto só no extrato. PARCIAL.

**Medido de novo com a máquina calma (08/11/2026, a fatia 122, RELATORIOS-ARQUIVO-FRASE-E-MEMORIA).** No Mac (o contêiner com 10
núcleos e a carga de 0,3 a 0,7 durante a medida, anotada antes de cada processo), a rede em julho de 2026, três rodadas por
processo, as duas ordens, duas voltas (`medir-p122-cota.sh`, `p122-cota.txt` do rascunho; as medianas do quente pelo
`medir-p122-resumir-cota.py`): **o quente da Cota-parte, na mediana, 0,394 e 0,397 s contra 0,311 e 0,312 s do Parceiro no MySQL (+27% nas
duas sessões; as rodadas de 0,389 a 0,403 s contra 0,304 a 0,380 s)**, e 0,354 s contra 0,272 s no PostgreSQL (+30%; de 0,344 a
0,378 s contra 0,265 a 0,283 s). **O buffer pool variado (a correção da fatia 122):** com o do MySQL em 1 GB (`SET GLOBAL`, sem
reiniciar, e devolvido aos 128 MB depois), 0,386 s contra 0,307 s (+26%), e na volta aos 128 MB, na mesma sessão, 0,405 contra
0,313 s (+30%): a razão não se move com a memória do banco, porque o SQL das duas pesa 0,12 a 0,14 s e quase igual -- a diferença
é o PHP. O `shared_buffers` do PostgreSQL (128 MB) não foi variado: muda só reiniciando o servidor, que a fatia não faz; o SQL de
lá pesa 0,08 a 0,09 s nas duas, e a diferença é a mesma do PHP. Os segundos são do Mac e não valem para a produção, que não foi
medida; a razão, que não mudou com o pool, é o que esta medida diz. **O ganho
candidato foi feito e medido, e não entrou:** a lista sem as linhas por produto (a montagem refeita à mão, `medir-p122-partes.php`)
leva de 0,041 a 0,045 s no `doRecorte` contra 0,046 a 0,049 s com elas -- 4 ms de 390, 1% --, e o quente da tela não mudou (0,39 a
0,41 s); o código voltou ao do HEAD, conferido pelo sha1. O peso está na leitura: o `relatorioItens` lê 31 colunas e leva de 0,27 a
0,30 s com a hidratação (o SQL, de 0,12 a 0,13 s), e o `painelItens` do Parceiro lê 17. Chegar aos 20% pediria uma leitura curta só
para a lista, uma segunda consulta para o mesmo dado; a escolha entre aceitar a meta em "até 30%", fazer a leitura curta ou medir
na produção foi ao lote do dono ("A Cota-parte da rede pode ficar um quarto mais lenta que o Profissional Parceiro?", recomendando
aceitar). **O conserto previsto também foi ao dono (a correção da fatia 122):** as notas da fatia mandavam fazer a lista sem as
linhas por produto se o quente passasse dos 20%, e quem o desfez pelo 1% foi a fatia, não o dono; a pergunta ganhou a opção (d),
"fazer o conserto previsto mesmo assim", com a recomendação de não fazê-lo. PARCIAL.

#### ENCERRADO em 08/11/2026 -- o dono aceitou a medida, e a meta da Cota-parte da rede passa a até 30% (a fatia 138, RESPOSTAS-DE-08-11-API-E-CADASTRO)

**A resposta 375 do dono** (08/11/2026) foi a **(a)**: *"Aceitar como está: a meta dos 20% vira 'até 30%' para a Cota-parte da
rede; nada muda no sistema, e o débito fecha com esta medida."* A medida que fecha é a de cima (a fatia 122): no quente, na
mediana, 0,39 s contra 0,31 s do Parceiro, de 26% a 30% acima nos dois bancos, e a mesma razão com o buffer pool do MySQL em 1 GB
-- a diferença é o PHP da leitura das 31 colunas, e não o banco. **Nada mudou no código**: nem a leitura curta (a (b)) nem a lista
sem as linhas por produto (a (d), que a recomendação desaconselhava e o dono não escolheu). **A meta nova está no desenho**
(`docs/f3-relatorios/spec-cota-parte.md`, o "O que medir" e o item 8 da CP2): até 30% acima do Parceiro, só para a Cota-parte da
rede; o resto do "O que medir" fica como estava. **Nenhum medidor tinha os 20% escritos** (procurado em `scripts/` e nos roteiros
do rascunho, o `p48-medir-relatorios-pg.sh` e o `medir-p122-*`, em 08/11/2026): os números eram lidos à mão contra o desenho, e por
isso não há limite de código a trocar. Os segundos são do Mac; a produção segue sem medida (a (c) não foi escolhida).

### D676 — O "Vendidos" da tela antiga de Configurações ainda conta a cortesia, e o da tela nova do Catálogo não — REGISTRADO em 03/11/2026

**Registrado na fatia 69 (REL-PRODUTOS-REDE; a F4 de `docs/f3-relatorios/spec-produtos.md`, e o D-p4 da seção 4 dele).** Desde
esta fatia, a lista do catálogo de produtos da tela nova (`GET /adm/api/produtos?catalogo=produtos`) mostra o "Vendidos" desde
sempre SEM a cortesia, e as cortesias numa coluna ao lado: o `ProdutosController::vendidosSemCortesia` do ISConfiguracao lê
`VendaProdutoRepository::catalogoVendidosPorPreco` (a quantidade de cada produto da página por preço na venda, só as vendas
finalizadas) e separa os grupos pela regra do painel (`ContasDoPainelDaUnidade::ehCortesia`). A tela antiga de Configurações
(`module/ISConfiguracao/view/is-configuracao/produtos/index.phtml:170`) continua lendo a subconsulta de
`ProdutoRepository::listagemIndexProdutos` (`SUM(venpro.quantidade)`, sem olhar o preço), que **fica** de propósito: o desenho a
mantém para a tela antiga, e a tela antiga sai no corte.

**O que isso faz com quem usa:** as duas telas do catálogo mostram números diferentes para o mesmo produto. Contado na cópia (só
SELECT, só contagem; `p69/quem-ve.txt` do rascunho): na unidade 39, 39 dos 125 produtos ativos mudam de "Vendidos" (12.144
unidades vão para "Cortesias") e 3 dos 99 inativos (16 unidades); na unidade 1, nenhum. Os serviços não mudam (o serviço a preço
zero não é cortesia).

**A porta do PDV fica com o número de antes** (a correção da fatia 69, 03/11/2026): a mesma lista também serve a quem entra só
pela segunda porta do D168 (o `vendas/comandasNova/ler`, sem `configuracoes/produtos/ler`: o operador do PDV monta a suspensa do
que se vende). Para ele o `vendidosSemCortesia` não roda -- como o filtro de etiqueta, as cortesias são número da tela de
configuração --, e a linha sai como antes da fatia: o `quantidadeVendida` da subconsulta, com a cortesia, e `cortesias` nulo, sem a
consulta a mais por página. O `ProdutoApiTest::testOVendidosDoCatalogoNaoContaACortesia` pede pelas duas portas.

**Como fechar:** quando a tela antiga de Configurações sair (o corte), este débito sai com ela; ou, antes disso, a subconsulta da
tela antiga passa à mesma regra -- o que muda o número que ela mostra hoje, e por isso não entrou sem o dono. REGISTRADO.

### D677 — A leitura das canceladas pelo instante do cancelamento lê a tabela de vendas inteira — REGISTRADO em 03/11/2026

**Registrado na fatia 70 (PDV-CANCELAMENTO; a seção 3 de `docs/f3-relatorios/spec-canceladas.md` e a 4.1 de
`docs/f3-relatorios/spec-ordem.md`, "Sem índice novo nesta fatia: medem primeiro").** O bloco "Cancelamentos no período" das Vendas
Canceladas lê os itens das vendas canceladas pelo `cancelado_em` (`VendaRepository::canceladasPeloCancelamento`), de qualquer data
de venda. Não há índice na coluna: o MySQL lê `vendas` inteira (o `EXPLAIN` dá `type ALL`, 2,25 milhões de linhas) e o PostgreSQL
também. **Medido na cópia em 03/11/2026** (128 MB de buffer pool no MySQL, a tabela quente logo depois das migrações, três rodadas;
a contagem pela mesma junção e o mesmo filtro, novembro de 2026): a unidade 1 e a rede de 0,34 a 0,42 s no MySQL e de 0,13 a
0,40 s no PostgreSQL -- abaixo de 1 s, e por isso o índice NÃO entrou (a regra da 4.1). O frio não se mediu: esvaziar o buffer
pool pede reiniciar o servidor, e isso não se faz aqui.

**O que pode piorar:** a leitura cresce com a tabela, e não com o número de canceladas; com a tabela fria no servidor de produção
ela pode passar de 1 s. **Como fechar:** medir o frio no ensaio geral; se passar de 1 s, um índice `(status, cancelado_em)` (ou só
`cancelado_em`) numa migração que só acrescenta, pela regra das entregas depois da janela -- é esquema novo, e entra com a
autorização da 83 (a) nas cópias e com a do dono no passo 0. REGISTRADO.

### D678 — A base do teste do BVIP não tem as oito colunas da fatia do PDV, e o código novo as mapeia — REGISTRADO em 03/11/2026

**Registrado na fatia 70 (PDV-CANCELAMENTO; a P0 de `docs/f3-relatorios/spec-ordem.md`).** As quatro migrações da fatia
acrescentam oito colunas (`vendas.cancelado_em` e `cancelado_por`, `vendas_produtos.desconto_lancado_por`, `vendedor_do_pacote` e
`cota_do_vendedor`, `caixas.valor_contado`, `produtos_estoque.motivo` e `produtos.cota_parte_pacote`), e as entidades `Venda`,
`VendaProduto`, `Caixa`, `ProdutoEstoque` e `Produto` passam a mapeá-las (a nota da fatia: "os campos e os getters/setters, nada
mais"). Nas duas cópias locais e nos bancos da suíte e do contrato elas existem. **A base do teste do BVIP não as tem até a
próxima cópia exportada**, e com elas mapeadas toda leitura de venda, de item, de caixa, de produto e de estoque falha lá
("Unknown column") -- é o caso que o D473 descreveu para o cupom do dia, que ficou fora do mapeamento por isso.

**O que segura:** o `scripts/deploy-teste.sh` recusa subir commit que muda o esquema (o `ESQUEMA_RE` acha `mysql.sql` e as
`scripts/migracao-*.sql` no diff) sem o `ACEITAR_ESQUEMA=sim`, e manda aplicar o `ALTER` à mão antes -- as quatro migrações, nessa
ordem, que são idempotentes. **Como fechar:** na próxima entrega ao BVIP, o dono roda as quatro migrações na base de lá (ou recarrega
a base com a cópia nova), e só então o deploy com o `ACEITAR_ESQUEMA=sim`. A linha `cancelamento` de `colunas_gravadas` vai depois
do código, pelo `scripts/colunas-gravadas-cancelamento.sql`. Em produção, o passo 0 do corte. REGISTRADO.

**A fatia 71 (03/11/2026, PDV-DESCONTO-LANCADO-POR) lê e escreve mais uma, e NÃO muda o esquema:** o lançamento de item no PDV
(as duas telas) passa a gravar `vendas_produtos.desconto_lancado_por`, e a consulta dos relatórios (`COLUNAS_DOS_RELATORIOS`) a lê.
Como o commit dela não toca `mysql.sql` nem `scripts/migracao-*.sql`, o `ESQUEMA_RE` do `deploy-teste.sh` NÃO o segura sozinho:
a guarda continua sendo a do commit da 70, que vem antes. Quem subir ao BVIP o commit da 70 com as migrações aplicadas leva
também o desta; a linha `descontoManual` vai depois do código, pelo `scripts/colunas-gravadas-desconto-manual.sql`. O título
não muda: nada aqui se fecha.

**A fatia 72 (03/11/2026, PDV-GAVETA-CONTADA) escreve mais uma, e também NÃO muda o esquema:** o fechamento do caixa (as duas
telas) passa a gravar `caixas.valor_contado`, e a consulta do relatório de Caixa (`CaixaRepository::SELECT_DO_RELATORIO`) a lê. O
commit dela não toca `mysql.sql` nem `scripts/migracao-*.sql`; a guarda continua sendo a do commit da 70. A linha `valorContado` vai
depois do código e depois da do cancelamento, pelo `scripts/colunas-gravadas-valor-contado.sql`. O título não muda.

**A fatia 73 (03/11/2026, PDV-MOTIVO-DA-RETIRADA) escreve mais uma, e também NÃO muda o esquema:** a saída pela tela de estoque (as
duas telas e a API) passa a gravar `produtos_estoque.motivo`, e a retirada grava `uso`; a consulta da Retirada de Produtos
(`ProdutoEstoqueRepository::retiradasDoPeriodo`) o lê. O commit dela não toca `mysql.sql` nem `scripts/migracao-*.sql`; a guarda
continua sendo a do commit da 70. A linha `motivoDaRetirada` vai depois do código, pelo
`scripts/colunas-gravadas-motivo-da-retirada.sql`. O título não muda.

### D679 — O cadastro novo de produto, serviço e pacote do ERP antigo hidrata o formulário inteiro — REGISTRADO em 03/11/2026 — CORRIGIDO em 07/11/2026

**Registrado na correção da fatia 70 (PDV-CANCELAMENTO), pela verificação dela.** `ProdutoService::insert`, `ServicoService::insert`
e `PacoteService::insert` fazem `new Produto($data)` com o `$request->getPost()->toArray()` do `novoAction` de cada tela, e o
construtor de `Produto` passa tudo ao `ClassMethodsHydrator`: toda chave cujo nome bate com um setter o chama (o hidratador tira o
`_`, e o PHP acha o método sem olhar a caixa). O formulário valida os campos dele (`$form->isValid()`), mas a chave a mais não é
recusada, e vai ao setter: pelo nome dos setters, `id`, `quantidade` (o estoque sem a movimentação), `status`, `visivel_app`,
`linha_vip`, `insumo`, `area`, `tag`, `periodos` e `pacote_validade_dias` -- os que cada serviço não regrava depois. Quem pode é quem
tem o "novo" daquela tela (o gerente da unidade, por exemplo). **Não medido** quais desses o formulário de cada tela já manda, nem o
que o `id` posto à mão faz com o gerador do Doctrine. A EDIÇÃO não tem o furo: o **D202** a fez passar só a lista `CAMPOS_DA_EDICAO`.

**O que a fatia 70 fechou:** só a coluna dela -- `produtos.cota_parte_pacote` (o **D310**), que a fatia 74 vai ler para pagar a cota
de quem vendeu o pacote: o construtor tira a chave em qualquer grafia antes de hidratar, e o pacote nasce com 0 (o
`ColunasDoPdvTest::testACotaDoPacoteNaoVemDoPostCru`, pelo `PacoteService::insert` contra o banco, nos dois). A fatia 74 grava a cota
pelo setter, depois da regra de 0 a 100. **Como fechar o resto:** o `insert` dos três com uma lista de campos, como o `atualizar` (o
molde do D202), e um caso por tela com a chave a mais. REGISTRADO.

**Fechado na fatia 119 (CATALOGO-CADASTRO-SEM-POST-CRU, 07/11/2026).** Confirmado antes do conserto, contra o banco, nos dois:
pelo `novoAction` de cada tela, o mesmo POST com chaves que a tela não manda gravava `quantidade` 999, `linha_vip` e `insumo` 1 e
`pacote_validade_dias` 77 no serviço; `visivel_app` 1, `periodos` 3 e `pacote_validade_dias` 77 no produto; `quantidade` 999,
`categoria` e `periodos` 3 no pacote -- inclusive com a chave em outra caixa (`Linha_Vip`, `INSUMO`, `PERIODOS`). O `id` posto à
mão (o de uma linha que já existe) não escolhia a linha nem a tocava: o gerador do Doctrine dá o id novo. **Medido nos dois bancos
só na verificação** (os mutantes M1 a M3, o POST cru de volta, que é o código de antes): na primeira rodada a conferência da linha
do `id` vinha depois da comparação das colunas, que caía antes dela -- o "não a tocava" era deduzido. A verificação a pôs antes,
e nas seis rodadas (três telas, dois bancos) ela passa com o POST cru e a queda é na comparação das colunas. Nenhum mutante da
fatia a deixa vermelha: é guarda de regressão, não prova do conserto.
**O conserto**, no molde do D202: cada `insert` passa ao construtor de `Produto` só a sua lista `CAMPOS_DO_CADASTRO`
(`array_intersect_key`, chave exata) -- `ServicoService`: estado, tipo, categoria, nome, os dois valores e o "visível no app" (a
área, a etiqueta e os períodos continuam abaixo, pela referência e pelo setter); `ProdutoService`: os da `CAMPOS_DA_EDICAO` e a
quantidade inicial (o D388); `PacoteService`: estado e nome (o resto o serviço já escrevia pelo setter). A lista de cada tela foi
conferida no `novo.phtml` das três (nenhum `.js` acrescenta campo; o `mod-configuracoes.js` só zera insumo e linha VIP do
insumo) e na API (`RegraDoCatalogo::paraOCadastro` e o `criarAction` dos pacotes mandam as mesmas chaves). A guarda da cota no
construtor (a fatia 70) fica, para quem construir a entidade com outro arranjo. **Provado** nos dois bancos
(`D679CadastroComOsCamposDaTelaTest`, 7 casos): por tela, o que o `novo.phtml` desenha, com valores fora do padrão, chega inteiro
ao banco (a lista não esqueceu campo); o POST com as chaves a mais grava a mesma linha que o limpo, coluna a coluna, e a linha do
`id` forjado fica como estava (conferida antes das colunas); as listas. O `ColunasDoPdvTest` da cota continua verde. Mutantes, vermelhos nos dois bancos: o
POST cru de volta em cada um dos três serviços (um por tela), a `quantidade` fora da lista do produto e o `visivel_app` fora da do
serviço. **Não visto:** o formulário de cada tela num navegador -- a lista saiu das views e do `.js`, e o caso posta o que elas
desenham. **O que o D679 não fecha** (a verificação da fatia 119): a lista filtra as CHAVES; o VALOR de cada campo da lista ainda
é o do POST cru, sem a regra da API -- a categoria fora da lista, a quantidade, os booleanos. Está no D741.

### D680 — O "CUPOM VIP:" digitado à mão no motivo do PDV antigo tira o desconto manual do "Por quem lançou" — REGISTRADO em 03/11/2026 — CORRIGIDO em 03/11/2026 (278 a)

**Registrado na correção da fatia 71 (PDV-DESCONTO-LANCADO-POR), pela verificação dela.** No ERP antigo, numa venda sem convênio, quem
lança um item com desconto digitado e escreve no motivo um texto com a marca `CUPOM VIP:` (o PDV antigo não reserva a marca:
`RegraDoItemDaComanda::recusaDoDescontoNoPdvAntigo`, "A MARCA DO CUPOM NAO E RESERVADA AQUI", porque é o próprio PDV que a escreve ao
escolher o produto do cupom, e recusá-la desligaria o cupom no balcão) passa pelo ramo do desconto digitado do
`VendaProdutoService::calcularLinha`, e a coluna `desconto_lancado_por` grava quem lançou. Mas o Descontos classifica pela marca
(`ContasDosDescontos::origem`, desde a fatia 51): o item é "cupom", a parte manual é zero, e ele sai do grupo "Por quem lançou (só o
manual)", do filtro, e a coluna "Lançado por" mostra o traço (`DescontosJson::itens` só preenche `lancadoPor` com parte manual). O banco
tem o nome; a tela o esconde. Qualquer pessoa que lança no PDV antigo tira um desconto do grupo só pelo texto do motivo. **Contado na
cópia** (só SELECT, só contagem, vendas finalizadas de 2026): 21 itens com desconto e a marca, 17 deles sem convênio -- pouco, mas
nenhum se distingue do cupom de verdade. A classificação pela marca já existia; o que a 71 traz é o nome gravado e escondido.
**Como fechar** depende da escolha do dono (a pergunta "TELAS-RESTANTES [vendas] (03/11/2026) -- Descontos: o item com a marca do
cupom mostra quem o lançou? (D680)" no lote): mostrar quem lançou também no item do cupom, na coluna (sem entrar no grupo "só o
manual"); ou o servidor conferir, no PDV antigo, que o número do cupom no motivo é de um cupom válido do cliente para o produto;
ou deixar como está. REGISTRADO.

**A resposta 278 (a) do dono (03/11/2026), feita na fatia 74c (RESPOSTAS-278-A-280):** "Mostrar quem lançou também no item do
cupom: a coluna 'Lançado por' diz o nome em todo item que tem o registro, o cupom inclusive; o grupo 'só o manual' continua
sem o cupom. Nada muda no caixa." O `DescontosJson::itens` passou a dar o `lancadoPor` de todo item pelo
`ContasDosDescontos::lancadoPor` (o id gravado, do dia da data em diante), e não só do com parte manual; a classificação
(`ContasDosDescontos::origem`), os totais, o grupo `porQuemLancou` e o filtro `lancadoPor` não mudaram -- seguem só a parte
manual. A tela já escrevia o nome sempre que o servidor o trazia (`quemLancouNaLinha`); mudaram o glossário
(`descontos.quemLancou`, que é também o "Como os números são contados") e os comentários. Mostram o nome: o "CUPOM VIP:"
escrito à mão e o cupom aplicado no PDV antigo sem convênio (os dois passam pelo ramo do digitado e gravam quem lançou, e
continuam indistinguíveis); ficam com o traço o cupom da comanda nova e o do convênio, que não gravam autor, e o de antes da
data. **Provado** no `DescontosTest::testQuemLancouODescontoManual` (o cupom 90006581 com o autor mostra o nome, o 90006583
sem autor fica nulo; o grupo e o filtro sem eles), nos dois bancos, e no front (`listaDosDescontos.teste.ts` e
`Descontos.teste.tsx`); os mutantes e o oráculo do contrato (pelo PDV antigo, com o motivo "CUPOM VIP: teste") estão no
parágrafo da 74c do `docs/roadmap.md`. **Contado na cópia** (só SELECT, só contagem, as duas): nenhum item com
`desconto_lancado_por` ainda, o cupom inclusive -- a linha `descontoManual` é de hoje. CORRIGIDO.

### D681 — O fechamento do caixa no ERP antigo dizia "Caixa fechado com sucesso" quando outra sessão já o tinha fechado, e a aba velha do fechamento abria um caixa novo — REGISTRADO em 03/11/2026 — CORRIGIDO em 03/11/2026

**Achado e consertado na fatia 72 (PDV-GAVETA-CONTADA; a pergunta 15 (c) do dono), e ampliado na verificação dela.** Três
caminhos do mesmo envio, o da aba que mostrava o fechamento de um caixa que já não está aberto (outra aba, outro aparelho, a tela
nova, o envio duplo -- o cenário do D228), todos de antes da 72; com ela o fechamento grava o dinheiro contado, e cada um passava a
perder ou a mudar uma contagem:

- **A corrida dentro do pedido:** o `CaixasController::fechar` chamava o `CaixaService::fecharComSaldoRecalculado` e ignorava o
  retorno; o serviço responde `false` quando, sob a trava, acha o caixa já fechado, e a tela dizia "Caixa fechado com sucesso"
  sobre um fechamento que não houve.
- **O caixa fechado antes do envio (o caminho comum):** o `gerenciarAction` não acha caixa aberto e chama o `abrir()` com o POST do
  fechar. O filtro de abrir só pede o `valor_inicial`, que o formulário de fechar traz preenchido (só leitura), e o token `security`
  é o mesmo (a sessão do `Csrf` do Laminas é a do nome do elemento, igual nos dois formulários): abria um caixa NOVO com o valor
  inicial do velho, dizia "Caixa aberto com sucesso", mandava ao PDV, e a contagem sumia.
- **O caixa fechado e outro aberto antes do envio:** o `gerenciar` acha o caixa novo e o `fechar()` o fechava com a contagem
  digitada para o velho.

**O conserto:** o formulário de fechar leva o id do caixa que mostra (o campo escondido `caixa`, `Form/CaixaFechar.php` e
`fechar.phtml`); o `abrir()` recusa o POST que traz o `caixa` ou o `valor_contado` (só o fechar os tem), o `fechar()` recusa o id
que não é o do caixa aberto, e o `false` do serviço é lido. Nos três, a frase `CaixasController::JA_ESTAVA_FECHADO` ("Este caixa já
estava fechado: a contagem digitada não foi gravada."), a volta ao gerenciar, e nada gravado -- nem a contagem, nem caixa novo. O
POST sem o id (o formulário de antes da 72) segue para o filtro, que o recusa pela falta do contado. **Provado** no
`ValorContadoNoFechamentoTest`, nos dois bancos: `testAAbaVelhaDoFechamentoNaoAbreCaixaNovo` (o formulário inteiro, sem o id, sem
o contado), `testAAbaVelhaNaoFechaOCaixaAbertoDepois` e `testOErpDizQueJaEstavaFechadoQuandoOServicoRecusa` (o serviço de verdade
com o caixa fechado por SQL logo antes da trava), e o id no HTML em `testATelaDoErpMostraOCampoEARecusaAoLado`; os mutantes C72-1 a
C72-6, um por vez, vermelhos. A API não tem o problema: o endereço leva o id do caixa, e o fechado é o 409. Na cópia, não se mede:
nada registra o envio repetido. CORRIGIDO.

### D682 — A tela antiga da Retirada de Produtos soma o acerto de inventário, a perda e o vencimento como retirada — REGISTRADO em 03/11/2026 — ANOTADO em 03/11/2026 (280 b)

**Registrado na fatia 73 (PDV-MOTIVO-DA-RETIRADA; a pergunta 9 (a) do dono: "acerto fora do total").** Desde a fatia 73, a saída
pela tela de estoque grava o motivo (perda, vencimento, acerto de inventário), e a Retirada de Produtos da tela NOVA os separa, com
o acerto fora do "saiu sem receita". A tela ANTIGA do relatório (`ISRelatorio\Controller\ProdutosController::retiradasAction`, pelo
`ProdutoEstoqueRepository::relatorioRetiradas`) soma a quantidade de toda saída ativa do período por produto, sem olhar o motivo:
um acerto de inventário de 50 unidades aparece lá como 50 retiradas. Era assim antes (ela já somava a saída pela tela de estoque
com a retirada de uso); o que muda é que agora o motivo existe e só a tela nova o usa. **Medido na cópia:** nenhuma linha tem motivo
ainda (as 33.576 nulas, antes da linha `motivoDaRetirada`); o defeito só aparece com saídas lançadas depois dela. **Por que não foi
consertado aqui:** a nota da fatia deixa a tela antiga como está (aposentar telas antigas está fora), e a nova é a que o menu novo
abre. **Como fechar:** aposentar a tela antiga no corte, ou -- se ela ficar -- excluir `motivo = 'acerto'` da soma dela. Não bloqueia.
REGISTRADO.

**A verificação da 73 (03/11/2026) achou a segunda leitura e a escolha.** O histórico por produto da mesma tela antiga
(`retiradasHistoricoAction`, pelo `ProdutoEstoqueRepository::historicoRetiradasPorProduto`) também lista toda saída ativa como
"retirada", o acerto inclusive, e sem a coluna do motivo. As duas leituras ficam como estão (o desenho da F0 diz que o
`relatorioRetiradas` "fica para a tela antiga sem mudar", e a tela antiga não perde dado nenhum assim): tirar o acerto dela, ou
deixá-la como está até ser aposentada no corte, é do dono, e foi ao lote ("Retirada de Produtos antiga: o acerto de inventário
sai da soma e do histórico?").

**A resposta 280 (b) do dono (03/11/2026), registrada na fatia 74c (RESPOSTAS-278-A-280):** "Deixar a tela antiga como está até
ser desligada no corte: quem abrir a tela antiga depois da data vê o acerto como retirada; a tela nova é a que o menu novo
abre." Nada mudou no código: o `relatorioRetiradas` e o `historicoRetiradasPorProduto` seguem contando toda saída ativa. O
débito fica adiado por decisão do dono -- fora do "de pé" do `contar-debitos.php`, que lê o ANOTADO como adiado --, e fecha
quando a tela antiga for desligada no corte. ANOTADO.

### D683 — A cota-parte do pacote vai a quem o lançou mesmo com a origem desfeita, e a quem saiu da empresa — REGISTRADO em 03/11/2026 — CORRIGIDO em 03/11/2026 (281 a, 282 a)

**Registrado na verificação da fatia 74 (PDV-COTA-DO-PACOTE; a pergunta 16 (b) do dono).** Desde a 74, a finalização de toda sessão
de pacote grava quem vendeu -- o colaborador do item `pac` da venda que criou o pacote do cliente (`clientes_pacotes.venda`,
`VendaProdutoRepository::vendedorDoPacote`) -- e a cota dele; a Cota-parte a credita. O desenho não olha duas coisas, e as duas são
regra de negócio:

- **A venda de origem cancelada.** O cancelamento da venda não desfaz o pacote do cliente: na cópia (só SELECT, 03/11/2026), 232 dos
  3.475 pacotes de cliente nasceram de venda hoje cancelada, 118 deles ainda ativos e 159 com sessão restante; em 2026, 38 sessões
  usaram um deles (132 em todo o histórico). O código grava como vendedor quem lançou o pacote na venda cancelada, como a de
  qualquer outra.
- **Quem vendeu e já saiu.** Dos 23 colaboradores que venderam pacote em 2026, 5 estão inativos hoje; 104 sessões de 2026 usaram
  um pacote vendido por eles. O código grava o vendedor e a cota do mesmo jeito, e a Cota-parte mostra a linha dele (com o nome do
  cadastro).

Com 0% em todos os pacotes (a cópia inteira), as duas cotas são zero, e nada aparece na lista nem no "fora". **Por que não foi
decidido aqui:** é quem recebe dinheiro. Ficou o padrão do desenho, que não perde dado (o vendedor gravado; quem decide pode
ignorar a linha). As duas perguntas foram ao lote ("Cota de quem vendeu: o pacote de uma venda cancelada" e "Cota de quem vendeu:
quem já saiu da empresa"). Não bloqueia o corte. REGISTRADO.

**As respostas do dono, de 03/11/2026 (a fatia 74d, RESPOSTAS-281-E-282).** **281 (a)**: "Sem cota quando a venda de origem foi
cancelada: a sessão grava o vendedor (fica o registro), mas a cota sai zero, e a Cota-parte não a paga. Se a venda foi refeita, o
pacote novo é o que paga." **282 (a)**: "Como está: a cota continua sendo de quem vendeu, ativo ou não, e aparece na Cota-parte
para o gerente acertar com ele."

- **A 281 (a), no código.** A venda de origem cancelada é a de status de cancelada (`RegraDaVendaNoPdv::VENDA_CANCELADA`, o que
  as duas telas de cancelamento gravam e as Canceladas leem; não o `cancelado_em`, que a venda cancelada antes da fatia 70 não
  tem), numa regra pura só (`RegraDaCotaDoPacote::semCotaPelaOrigem`). **A porta** (`VendaPagamentoService::atualizarValorComissaoPacote`,
  a da comanda e a da agenda) grava o vendedor e a cota ZERO quando a venda que criou o pacote do cliente está cancelada na hora
  da finalização; a comissão de quem executou não muda. **A leitura** (a `MontagemDaCotaParte`, pela consulta nova
  `ClientePacoteRepository::comAVendaDeOrigemCancelada`, uma por mês lido, só com sessão com vendedor) zera a cota da sessão
  cuja origem está cancelada HOJE, nas duas réguas -- também a gravada antes do cancelamento, para a Cota-parte e a v2 dizerem o
  mesmo; a sessão fica no bloco "Pacotes que vendeu, por sessão usada" com a quantidade e o valor, a cota zero e a marca "N
  sessões com a venda de origem cancelada, sem cota" (o `origemCancelada` da linha do extrato, ao lado do produto na tela, depois
  do nome do item no PDF e na planilha); quem só vendeu um pacote assim fica sem cota, fora da lista e fora do "fora da lista",
  como com 0%. O pacote da venda refeita é outro pacote do cliente, de outra venda, e paga normalmente QUANDO A SESSÃO SAI DELE:
  o cancelamento deixa ativo o pacote antigo, e a sessão sai do que vence antes -- na prática, do antigo, sem cota, até ele acabar
  (a verificação da 74d; qual pacote a sessão gasta é do dono, o D686, ao lote). O glossário
  (`cota.quemVendeu`, o "Como os números são contados") e a nota do arquivo (`CotaParteJson::NOTA_DE_QUEM_VENDEU`) dizem a
  regra. O portão `conferir-escritores-do-pacote.php` ganhou a afirmação 4b (a porta passa pela `semCotaPelaOrigem`). **Provado**
  no `CotaDoPacoteGravaQuemVendeuTest` (a comanda e a agenda com a origem cancelada gravam zero, a refeita paga 8,00; a consulta),
  no `RegrasDaCotaParteTest` (as duas réguas, a outra sessão do mesmo vendedor paga, quem só vendeu fora da lista e do fora, a
  soma das fatias) e no `CotaParteTest` (a lista, a v2, o extrato, a planilha e o PDF), nos dois bancos; os mutantes e o oráculo
  do contrato estão no parágrafo da 74d do `docs/roadmap.md`. **Contado na cópia** (só SELECT, só contagem, 03/11/2026): 38
  sessões de 2026 usaram um pacote de venda de origem hoje cancelada (132 em todo o histórico), e NENHUMA tem a cota de quem
  vendeu gravada (nenhuma sessão da cópia tem: a linha `cotaDoPacote` é de hoje).
- **A 282 (a), só nos docs.** Nada no código: a cota continua de quem vendeu, ativo ou não no cadastro, e a Cota-parte mostra a
  linha dele, com o nome do cadastro, para o gerente acertar com ele -- "como está, por decisão do dono". Escrito no
  `spec-cota-parte.md`.
- **A verificação da 74d (03/11/2026)** confirmou seis achados, tratados: (1) a frase "o pacote da venda refeita paga
  normalmente" quase nunca vale -- o cancelamento deixa ativo o pacote antigo, e o `ClientePacoteRepository::selecionarPacote`
  gasta o que vence antes; na cópia (só SELECT), 30 das 38 sessões de 2026 de origem cancelada, de 18 pacotes, saíram do antigo
  com o cliente já tendo o da venda refeita. O código segue a letra da 281 (a cota); qual pacote a sessão gasta foi ao lote (o
  D686), e o glossário, a nota do arquivo, o spec e este corpo dizem como é hoje. (2) O glossário e a nota não diziam que quem só
  vendeu pacotes assim sai da lista e do "fora" -- agora dizem (`RegrasDaCotaParteTest`). (3) O "% gravado" da v2 ficava diluído
  pela sessão de origem cancelada (a base do produto a recebia: "gravado a 5,0%" com a sessão que paga a 10%, e 0,0% com todas
  canceladas) -- a base do produto agora é só a das sessões que pagam, e o "% gravado" sai nulo sem nenhuma (`RegrasDaCotaParteTest`
  e `CotaParteTest`, pela API). (4) A leitura repetia a constante no DQL em vez de chamar a regra pura -- a
  `comAVendaDeOrigemCancelada` agora traz o status e chama a `semCotaPelaOrigem`, e a "regra pura só" passou a ser verdade.
  (5) O status nulo sem caso -- a regra pura com o 0, o '0', o nulo e os outros status, e a consulta com a origem de status nulo
  (`CotaDoPacoteGravaQuemVendeuTest`). (6) A marca "sem cota" chegava ao caixa (o `dia`), que não vê cota -- o `origemCancelada`
  entrou no `CotaParteJson::ESCONDIDOS['dia']` (e no `ESCONDIDOS_NA_TELA`, na tabela da 1.4, no portão
  `conferir-recorte-por-unidade.php`), e o item do arquivo do caixa sai sem ela.

CORRIGIDO.

### D684 — A margem do produto sem custo no cadastro é o faturamento inteiro — REGISTRADO em 03/11/2026 — CORRIGIDO em 03/11/2026 (283 b)

**Achado na fatia 74b (PDV-CUSTO-DO-ITEM; a resposta 224 (b) do dono).** O escritor grava o custo que o cadastro tem
(`produtos.valor_compra`), sem conserto: o produto de estoque com custo zero grava zero, e o Produtos / Serviços mostra a margem
dele igual ao faturamento inteiro (o caso `CustoDoItemGravadoTest::testOCadastroSemCustoGravaZero`, e o
`RegrasDosProdutosVendidosTest::testAMargemSoDosItensComOCustoGravado`, o Óleo). Na cópia, em 03/11/2026 (só SELECT): **441 dos
2.392 produtos de estoque ativos têm o custo zero** (nenhum nulo) -- 380 com preço, 61 sem; **78 deles venderam com preço em
2026**, e em julho de 2026 foram 175 itens e R$ 10.345,90 de faturamento que entrariam como margem inteira. A Retirada já trata o
custo zero como "sem custo" (fica fora do valor e é contado à parte), e desde a correção da 74b a cortesia gravada com zero
também -- sem custo lá, e NUNCA pelo custo de hoje, mesmo com o cadastro corrigido depois (a verificação achou a primeira versão
indo pelo custo de hoje, o que reescrevia o passado; `RegrasDaRetiradaTest::testACortesiaGravadaComZeroFicaSemCusto`). E 131 produtos ativos têm o custo maior que o preço (a margem negativa; 116 deles de preço zero, a cortesia de
balcão); 2 têm o custo 10 vezes o preço ou mais -- o defeito dos 100 vezes (o D624) não aparece nos produtos (0 com 50 vezes).
**A escolha é do dono** (a pergunta no lote, "O produto sem custo no cadastro", D684): mostrar a margem inteira, como hoje, ou
tratar o zero como "sem custo" também no Produtos / Serviços. O dado gravado é o mesmo nas duas: a escolha é só de leitura, e
nada se perde. Não bloqueia.

**A resposta 283 (b) do dono (03/11/2026), na fatia 74e (RESPOSTAS-283-E-284):** "Custo zero conta como 'sem custo', como na
Retirada: esses itens ficam fora do custo e da margem, e o relatório diz quantos ficaram de fora." **O que entrou:** a regra é
uma só para os dois relatórios, a pura `ContasDosProdutosVendidos::situacaoDoCusto` (sem o registro, "sem registro"; com ele e o
custo do item maior que zero, "com custo"; com ele e zero, "sem custo no cadastro"), chamada pelo `contar` do Produtos /
Serviços e pela `ContasDaRetirada::cortesias`, que já tratava o zero assim e não mudou o que faz. No Produtos / Serviços, o item
gravado com zero fica fora do custo e da margem nas três gavetas com custo (Produtos vendidos, Cortesias e Outros) e é contado
na chave nova `semCustoNoCadastro` (na gaveta e na linha; nula fora delas), separada do `semCustoGravado` (o "sem registro"): a
tela diz "N sem custo no cadastro na venda" ao lado de "N sem registro", e "sem custo no cadastro na venda" na linha só com
zero; o glossário `produtos.custoEMargem` diz a regra. A margem do item com custo maior que zero não muda. O zero é o do
centavo: o `custoDoItem` arredonda o unitário antes da quantidade, e o custo de 0,004 (gravado como 0,004) fica "sem custo". O
escritor não mudou (o cadastro sem custo grava zero). **Provado** no `RegrasDosProdutosVendidosTest` (a situação, o 0,004 e o
0,005, as três gavetas, a rede) e no `ProdutosVendidosTest` (contra o banco, na unidade e na rede), nos dois bancos; os mutantes
e o oráculo do contrato estão no parágrafo da 74e do `docs/roadmap.md`. **Contado na cópia** (só SELECT, só contagem,
03/11/2026): nenhum item tem o custo gravado ainda (a linha `custoDoItem` é de hoje); pelo cadastro de hoje, julho de 2026
teria 175 itens com preço (R$ 10.345,90) e 25 cortesias "sem custo". CORRIGIDO.

### D685 — A Retirada vale pelo preço de compra de hoje o que veio antes da gravação, e não como "sem registro" — REGISTRADO em 03/11/2026 — ANOTADO em 03/11/2026 (284 a)

**Achado na verificação da fatia 74b (PDV-CUSTO-DO-ITEM; a resposta 224 (b) do dono).** O texto da opção que o dono escolheu
diz que "a Retirada [mostra] o valor em reais a partir desse dia (o que veio antes aparece como 'sem registro')". O que a 74b fez,
pela nota 2 da fatia ("o custo GRAVADO onde houver", "mude o mínimo"): a cortesia vendida desde a data entra pelo custo gravado
na venda; a cortesia de ANTES da data e TODA saída do estoque (`produtos_estoque`, que não é venda e não tem item onde gravar)
continuam pelo custo de hoje do cadastro, como desde a fatia 49 (a F1 de `spec-retirada.md`), e nenhuma parte aparece como "sem
registro". A tela diz qual é qual (o cartão "as N cortesias com o custo gravado na venda pelo custo delas; o resto ao custo de hoje
do cadastro", e o "Como os números são contados"). **A escolha é do dono** (a pergunta no lote, "A Retirada antes da data do
custo", D685): manter o custo de hoje no que não tem registro (como está), tirar do valor a cortesia de antes da data, ou tirar
também a saída do estoque. Nada se perde em nenhuma: o que muda é só o que o cartão soma. Não bloqueia.

**A resposta 284 (a) do dono (03/11/2026), registrada na fatia 74e (RESPOSTAS-283-E-284):** "Como está: a cortesia nova pelo
custo gravado; o resto pelo custo de hoje, com a tela dizendo qual é qual." Nada mudou no código: a cortesia vendida desde a
data vai pelo custo gravado na venda, a de antes e toda saída do estoque pelo custo de hoje do cadastro, e o cartão e o "Como os
números são contados" dizem qual é qual. Escrito no `spec-retirada.md`. O débito fica adiado por decisão do dono -- fora do "de
pé" do `contar-debitos.php`, que lê o ANOTADO como adiado --, e só volta se o dono pedir o "sem registro" (o texto da 224 b).
ANOTADO.

### D686 — O pacote refeito só paga a cota de quem o lançou quando o primeiro acaba — REGISTRADO em 03/11/2026 — CORRIGIDO em 03/11/2026 (285 a)

**Achado na verificação da fatia 74d (RESPOSTAS-281-E-282; a resposta 281 (a) do dono, o D683).** A 281 (a) diz: "Se a venda foi
refeita, o pacote novo é o que paga." A cota segue a letra (a sessão de pacote de origem cancelada grava o vendedor e a cota zero, e
a de pacote de venda válida paga). Mas o `VendaService::cancelar` põe a venda com o status de cancelada e NÃO desfaz o pacote do
cliente que ela criou (ele continua ativo, com as sessões restantes), e o `ClientePacoteRepository::selecionarPacote` (o PDV e a
agenda) escolhe o pacote que vence antes, e o de id menor no empate. Com a venda refeita, o cliente fica com dois pacotes do mesmo
item, e a sessão seguinte sai do ANTIGO: sem cota, e o novo só começa a pagar quando o antigo acaba. Na cópia, em 03/11/2026 (só
SELECT, só contagem): das 38 sessões de 2026 com a venda de origem cancelada, **30 saíram do pacote antigo (18 pacotes) quando o
cliente já tinha outro pacote do mesmo item, de uma venda concluída criada depois da cancelada e antes da sessão** -- a venda
refeita. Nenhuma tem cota gravada (a linha `cotaDoPacote` é de hoje). **A escolha é do dono** (a pergunta no lote, "Cota de quem
vendeu: a sessão sai do pacote da venda cancelada", D686): encerrar o pacote da venda cancelada no cancelamento, fazer a sessão
gastar primeiro o pacote de venda válida, ou deixar como está. As duas primeiras mudam quantas sessões o cliente tem, e não só a
cota. Ficou como está (não perde dado: o vendedor fica gravado em toda sessão). Não bloqueia.

**A resposta 285 do dono (03/11/2026), a opção (a)** -- e não a (b) que a pergunta recomendava: "Encerrar o pacote no
cancelamento: cancelar a venda desativa o pacote do cliente que ela criou (as sessões que restavam somem); quem refez a venda
gasta o pacote novo, que paga a cota. Muda quantas sessões o cliente tem." **Entrou na fatia 74f (RESPOSTA-285):** o
`VendaService::cancelar` -- a porta única do ERP (`VendasController::cancelarAction`) e da API (`Adm\VendasController::cancelarAction`);
a rotina `CronJobService::comandasCancelar` só cancela comanda aberta, que nunca criou pacote (ele nasce no finalizar,
`VendaPagamentoService::salvarPacotes`) -- passa a encerrar (`ClientePacoteRepository::encerrarOsDaVenda`) todo
`clientes_pacotes` ativo com `venda` = a cancelada, com a MESMA marca da remoção pela ficha (`status` falso e a
`data_alteracao`, `ClienteService::pacoteRemover`), sem coluna nova e sem `DELETE`: o restante fica gravado como estava, e o
`selecionarPacote`, o `selecionarPacotes` e as contas do PDV, que leem só o ativo, já não o oferecem. (i) O usado em parte também
é encerrado (a letra: "as sessões que restavam somem"); a venda que criou dois pacotes encerra os dois. (ii) A venda que não criou
pacote não muda nada. (iii) Não há porta que desfaça o cancelamento (nenhuma ação põe a venda cancelada de volta; o
`reabrirDepoisDoFinalizar` é a recusa do finalizar, e não toca venda cancelada): o pacote encerrado não volta. (iv) O pacote
comprado em crédito ou fiado, ou com o pagamento estornado, segue a mesma regra. O pacote de outra venda do cliente não é tocado;
o "sem venda de origem" não existe no banco (`clientes_pacotes.venda` é NOT NULL nos dois esquemas, e nenhum na cópia). O que já
foi usado não muda (as sessões gravadas, a cota zero da 74d, a comissão de quem executou). **TUDO NUMA TRANSAÇÃO SÓ:** o
`cancelar` passou a abrir a transação, e o cancelamento que falha no meio deixa a venda como estava e o pacote ativo (até aqui
cada `flush` gravava o seu pedaço). A recusa por unidade e a permissão do cancelamento não mudam (o
`conferir-recorte-por-unidade.php`, saída 0). **A tela:** o modal do cancelamento do ERP antigo (`index-vendas.phtml`) diz "Se a
venda vendeu um pacote, cancelá-la encerra o pacote do cliente criado por ela, com as sessões que restavam: ele não pode mais
ser lançado numa comanda. A sessão dele que já estiver lançada numa comanda aberta ainda é descontada dele ao finalizar." (a
segunda frase desde a verificação da 74f: é o que o código faz enquanto o D687 não tem resposta) -- a frase geral, porque a listagem não tem as sessões de cada pacote, e a tela não listava o que o cancelamento
desfaz; a tela nova só cancela a comanda aberta, que ainda não deu pacote. O glossário `cota.quemVendeu`, a
`CotaParteJson::NOTA_DE_QUEM_VENDEU` e o `spec-cota-parte.md` dizem a regra nova, e a exceção do D687 (a sessão já lançada
numa comanda aberta ainda sai do pacote encerrado, sem cota), que o caso do D687 liga ao texto. **Na cópia (só SELECT, só contagem, em
03/11/2026):** em 2026, 21 das 3.800 vendas canceladas tinham criado 48 pacotes de cliente (16 clientes; 18 dessas vendas criaram
mais de um); hoje 24 deles continuam ativos, com 20 sessões restantes nos 12 que têm saldo positivo (3 estão com o saldo negativo, -51
somados -- o cliente usou mais do que tinha --, e a soma dos 24 dá -31) -- o restante na hora do cancelamento não é recuperável (a
venda cancelada de antes da 70 não tem `cancelado_em`), e as contas são de hoje; os outros 24 já estão inativos (removidos pela
ficha). **Daqui em diante:** os pacotes de venda JÁ cancelada não foram encerrados, na cópia nem em lugar nenhum (118 ativos no
histórico, de 66 vendas e 39 clientes, com 166 sessões restantes nos 69 com saldo positivo (5 com o saldo negativo, -56
somados; a soma dos 118 dá 110); nenhum dentro da validade hoje, o mais novo venceu em
07/09/2026). Se o corte os encerra uma vez é do dono: o **D688**, ao lote. A sessão lançada numa comanda aberta ANTES do
cancelamento ainda sai do pacote encerrado: o **D687**, ao lote. Provas no `PacoteEncerradoNoCancelamentoTest`, nos dois bancos;
desde a verificação da 74f também a falha DEPOIS de um `flush` (o crédito gravado e o débito que recusa: a venda, o crédito e o
pacote como estavam), o pacote já carregado no mesmo pedido lendo o encerramento (`relerOPacote`), e o pacote da fixture empurrado
para daqui a 730 dias (a data dele, 2030-12-31, viraria o cenário a partir de 2030). CORRIGIDO.

### D687 — A sessão lançada numa comanda aberta antes do cancelamento ainda sai do pacote encerrado — REGISTRADO em 03/11/2026 — CORRIGIDO em 03/11/2026 (286 a)

**Achado na fatia 74f (RESPOSTA-285; a resposta 285 (a) do dono, o D686).** Desde a 74f o cancelamento da venda encerra o pacote
do cliente que ela criou. Mas o pacote da sessão é escolhido NO LANÇAMENTO do item (`VendaProdutoService::insert` e
`VendaService::comandaAbrirAgenda`, pelo `selecionarPacote`, que lê só o ativo), e o item guarda o pacote em `vendas_produtos.pacote`.
Se a comanda do cliente já estava aberta com a sessão lançada quando a venda do pacote foi cancelada, o finalizar a baixa do
pacote encerrado mesmo assim: a recusa (`ClientePacoteRepository::sessoesQueFaltamNaVenda`) e a baixa (`baixarSessoesDaVenda`)
não leem o `status`. O cliente usa uma sessão que "sumiu", sem pagar, e a cota de quem vendeu sai zero (a origem cancelada, a 74d).
O texto que o usuário lê diz isso desde a verificação da 74f (o modal do ERP, o glossário `cota.quemVendeu` e a
`NOTA_DE_QUEM_VENDEU`), e o caso do D687 confere a frase: se o dono escolher a recusa, os três mudam junto com o caso.
A comanda aberta pela agenda DEPOIS do cancelamento não tem o problema: o lançamento já não oferece o pacote encerrado, e a sessão
vai ao outro pacote ativo do cliente, ou sai pelo preço. Na cópia (só SELECT, só contagem, em 03/11/2026): nenhuma comanda aberta
com sessão de pacote hoje. O caso `testASessaoLancadaAntesDoCancelamentoAindaSaiDoPacoteEncerrado` documenta o comportamento
(e não o defende). **A escolha é do dono** (a pergunta no lote, "A sessão já lançada no pacote que o cancelamento encerrou",
D687): o finalizar recusar a comanda com a sessão do pacote encerrado, dizendo qual (o operador tira o item e lança de novo: sai
do outro pacote ou pelo preço), ou deixar como está. Ficou como está (não perde dado). Não bloqueia.

**A resposta 286 do dono (03/11/2026), a opção (a)**, a recomendada: "Recusar ao finalizar: a comanda com a sessão de um pacote
encerrado não finaliza, e a tela diz qual pacote; o operador tira o item e lança de novo, e a sessão sai do outro pacote do cliente
ou pelo preço (como já acontece quando falta sessão)." **Entrou na fatia 74g (RESPOSTAS-286-E-287):** a pergunta nova
`ClientePacoteRepository::sessoesDePacoteEncerradoNaVenda` (os pacotes com `status` falso que os itens da venda, com quantidade
maior que zero, ainda apontam) e, no `VendaPagamentoService`, `pacotesEncerrados` e a frase `fraseDosPacotesEncerrados` ("O pacote
"<nome>" (<serviço>) foi encerrado depois que a sessão dele foi lançada nesta venda (a venda do pacote foi cancelada, ou ele foi
removido da ficha do cliente). Tire da venda o item da sessão e lance-o de novo: a sessão sai de outro pacote ativo do cliente, ou
pelo preço."; provisória, como a do D452; escapada no ERP). AS DUAS PORTAS QUE FINALIZAM recusam ANTES de chamar o finalizar, no
molde do D452: o PDV do ERP antigo (`VendasPagamentosController::validarFinalizarVenda`, a frase no `toastr`) e a API da tela nova
(`Adm\ComandasController::finalizarAction`, 409 com o `motivo` `pacote_encerrado` e os pacotes em `detalhes`, antes da recusa das
sessões que faltam e da do fiado). A tela nova mostra a frase do servidor inteira, como todo 409 de motivo que ela não conhece
(caso novo no `ComandasLigada.teste.tsx`). E O SERVIÇO RECUSA TAMBÉM: a baixa condicional (`baixarSessoesDaVenda`, a primeira
escrita depois do fechamento) só baixa o pacote ATIVO -- `AND status = TRUE` no mesmo `UPDATE` que confere o saldo --, e a venda é
reaberta; isso fecha a corrida do cancelamento entre a conferência do controller e a baixa, e os controllers releem a pergunta depois
da recusa e dizem a frase. Nada é gravado na recusa (a venda, os pacotes, o caixa, o crédito, os itens e a cota como estavam,
provado com o retrato do banco). A comanda com duas sessões, uma do pacote ativo e outra do encerrado, é recusada inteira, nomeando
só o encerrado. As sessões que faltam (`sessoesQueFaltamNaVenda`, o D452) passam a ler só o pacote ativo: o encerrado tem a frase
dele, e o ERP não diz as duas para o mesmo pacote. **(i) Encerrado por qualquer motivo**, pela letra: o pacote removido pela ficha
(`ClienteService::pacoteRemover`, a mesma marca) recusa igual. Na cópia (só SELECT, só contagem, em 03/11/2026): **nenhuma
comanda aberta com sessão de pacote encerrado**, no MySQL (0 das 17 abertas) e no PostgreSQL (0 das 61); nenhuma das abertas tem
sessão de pacote nenhum. **(ii) O vencido não é encerrado:** continua ativo, e a regra dele não muda (a sessão lançada antes de
vencer ainda baixa; caso novo). **(iii) A saída funciona pelas telas que existem:** tirado o item, o lançamento já não oferece o
encerrado (`selecionarPacote` e `selecionarQuantidadeServicoDisponivel` leem só o ativo, e o `saldoDoPacoteNaVenda` do D453 dá zero
ao encerrado), e a sessão sai do outro pacote ativo do cliente, que paga a cota, ou pelo preço. O texto: o modal do cancelamento do
ERP ("A comanda aberta que já tiver uma sessão dele lançada não finaliza: tire o item e lance-o de novo."), o glossário
`cota.quemVendeu` e a `CotaParteJson::NOTA_DE_QUEM_VENDEU`, e o `spec-cota-parte.md`, no lugar da exceção. O caso
`testASessaoLancadaAntesDoCancelamentoAindaSaiDoPacoteEncerrado` virou `testASessaoLancadaAntesDoCancelamentoRecusaOFinalizar`, e
passou a defender a recusa. Provas no `PacoteEncerradoNoCancelamentoTest`, nos dois bancos; o oráculo pela API no contrato. A
recusa por unidade e a permissão do finalizar não mudam. CORRIGIDO.

**A verificação da 74g (03/11/2026)** achou três afirmações deste registro sem caso, e elas ganharam o seu no
`PacoteEncerradoNoCancelamentoTest`, nos dois bancos: os nomes do pacote e do serviço escapados no ERP, nos dois caminhos (antes do
finalizar e depois da recusa do serviço), e crus na API (`testOsNomesVaoEscapadosNoErpECrusNaApi`, com "Pacote <b>VIP</b>" e
"Corte <i>VIP</i>", o molde do D452); a recusa da API antes da do fiado sem autorização (`testNaApiARecusaDoEncerradoVemAntesDaDoFiado`,
a comanda cobrando 89,90 que ninguém pagou); e só o item que gasta sessão (`testOItemDeQuantidadeZeroNoEncerradoNaoRecusa`: o item de
quantidade zero no pacote encerrado não recusa, e o serviço finaliza sem baixar nada). O 409 `pacote_encerrado` entrou no desenho da
API do PDV (`docs/f2-fatia-pdv.md`, a nota de 03/11/2026), com a ordem.

### D688 — Os pacotes de venda cancelada antes de 03/11/2026 continuam ativos — REGISTRADO em 03/11/2026 — CORRIGIDO em 03/11/2026 (287 a)

**Achado na fatia 74f (RESPOSTA-285; a resposta 285 (a) do dono, o D686).** A regra nova (o cancelamento encerra o pacote que a
venda criou) vale daqui em diante: a fatia não desativou os pacotes de vendas JÁ canceladas, na cópia nem em lugar nenhum. Na cópia
(só SELECT, só contagem, em 03/11/2026): **118 pacotes de cliente ativos nasceram de venda hoje cancelada**, de 66 vendas e 39
clientes, com 166 sessões restantes nos 69 pacotes com saldo positivo (5 estão com o saldo negativo, -56 somados, e a soma dos
118 dá 110); **nenhum está dentro da validade hoje** (o mais novo venceu em
07/09/2026), e por isso nenhum pode ser usado. A produção, no dia do corte, terá outros números. **A escolha é do dono** (a
pergunta no lote, "Os pacotes de venda já cancelada: encerrar uma vez no corte?", D688): encerrar uma vez, no corte, os pacotes
ativos de venda já cancelada (uma marca de estado, `status` falso, e não `DELETE`; contados antes no próprio passo), ou deixá-los
como estão. Ficou como está (não perde dado). Não bloqueia.

**A resposta 287 do dono (03/11/2026), a opção (a)**, a recomendada: "Encerrar uma vez no corte: no corte, os pacotes ativos de
venda já cancelada passam a encerrados, com a mesma marca do cancelamento novo (nada é apagado; as sessões que restavam ficam
registradas), e o passo conta antes quantos são e quantos ainda estavam válidos." **Entrou na fatia 74g (RESPOSTAS-286-E-287),
como roteiro preparado; roda no corte:** `scripts/encerrar-pacotes-de-venda-cancelada.sql` e o irmão
`postgres/encerrar-pacotes-de-venda-cancelada.sql`. Primeiro a CONTAGEM (os ativos de venda com o `status` 0 --
`RegraDaVendaNoPdv::VENDA_CANCELADA`, a regra da 74d e da 74f, e não o `cancelado_em` --, quantos ainda dentro da validade, a nula
contando como válida, as sessões restantes nos de saldo positivo e nos ainda válidos, os de saldo negativo, de quantos clientes e
de quantas vendas); depois a LISTA dos que vão ser encerrados, com a `data_alteracao` de antes (só ids, datas e contagens; o `tee`
do procedimento a guarda, e é o caminho de volta exato); depois o UPDATE (`status` falso e a `data_alteracao`, o mesmo instante em
todos, só no ativo de venda cancelada); e a CONFERÊNCIA (0 ativos de venda cancelada). Sem `DELETE`, sem coluna nova; não toca o
pacote de venda válida nem o já encerrado (a data dele fica); o "sem venda de origem" não existe (`venda` é NOT NULL nos dois
esquemas, conferido no caso). **Onde roda:** no passo 7 da etapa 1 (`procedimento-de-corte-etapa-1.md`), logo depois das linhas de
`colunas_gravadas`, com o código da 74f já no lugar e a aplicação em manutenção -- antes do código, a venda cancelada no meio deixaria
o pacote ativo --, e no 7d do `procedimento-de-corte.md` (o irmão, que com a etapa 1 feita conta 0 e só encerra no corte de uma vez
só; por isso o irmão é preciso); fora do laço do passo 0 (caso novo); no ensaio geral, na mesma ordem. **Não é ponto de parada:** a
contagem sai antes do UPDATE para quem opera ler e anotar, e o número dos ainda válidos vai ao dono depois da janela (o item 5 do
"Depois"); a resposta é encerrar todos. Numa volta os pacotes ficam encerrados (a linha nova da tabela "O que fica no banco").
**Na cópia franquiabv_dev o roteiro NÃO roda** (só a contagem, por SELECT, em 03/11/2026, igual nos dois bancos: **118 ativos de
venda cancelada, 0 ainda válidos, 69 com saldo positivo e 166 sessões restantes, 5 com o saldo negativo, de 39 clientes e 66
vendas**, o mais novo vencido em 07/09/2026 -- os da 74f). **Rodado num banco descartável montado da cópia** (só `clientes_pacotes`
e `vendas`, apagado no fim), duas vezes em cada motor: no MySQL, a contagem 118 / 0 / 69 / 166 / 0 / 5 / 39 / 66, 118 encerrados
num instante só, a conferência 0, em 0,48 s com o `docker exec`; a segunda vez conta 0 e encerra 0 (0,05 s); no PostgreSQL, os
mesmos números (0,06 s e 0,05 s); nos dois, comparado com a cópia, 0 pacotes de venda válida tocados, 0 já encerrados com a data
mudada, 0 restantes ou validades mudados. Provas no `EncerrarPacotesDeVendaCanceladaTest`, nos dois bancos, e no oráculo do contrato.
CORRIGIDO (o roteiro está pronto; o encerramento acontece no corte).

**A verificação da 74g (03/11/2026)** achou duas coisas, tratadas. (1) Três colunas da contagem -- o número que vai ao dono -- não
eram separadas pelo cenário do caso (a validade nula contando como válida, as sessões dos ainda válidos sem o vencido de saldo
positivo, e as vendas contadas uma vez): o cenário ganhou o pacote sem validade, o vencido com saldo e o segundo pacote da mesma
venda (ids 90008478 a 90008480), e a contagem esperada passou a 5 / 3 / 4 / 11 / 7 / 1 / 1 / 4. (2) Os números do PostgreSQL acima
vieram de um banco descartável SEM RLS, e o caso da suíte pula o `SET app.inquilino_id`. **Medido depois, num banco descartável com
as duas tabelas, as políticas do `schema.sql` (RLS forçado) e dois inquilinos** (apagado no fim): como o `bvip_app`, o roteiro como
está contou e encerrou só os 2 do inquilino 1, a conferência 0, e a segunda vez contou 0 e encerrou 0; sem a linha do `SET`, parou
no primeiro SELECT ("unrecognized configuration parameter") sem mudar nada; e **como o dono superusuário -- o `psql` do 7d -- o RLS
não vale e o `SET` não filtra: contou e encerrou os 3, os dos dois inquilinos.** No corte é o mesmo, porque a base só tem o
inquilino 1 (o segundo do portão 2 só tem uma unidade e é apagado no fim dele); o cabeçalho do irmão em `postgres/` o diz.

### D689 — Quem indicou vê no app o amigo do formulário só se ele tem a ligação — REGISTRADO em 03/11/2026 — PARCIAL em 03/11/2026 (288 b) — CORRIGIDO em 04/11/2026 (293 a)

**Achado na fatia 75 (REL-INDICACAO-PORTA, a I3).** O item 6 da I3 do `docs/f3-relatorios/spec-indicacao.md`, escrito em
02/10/2026, antes da 53 (c), mandava as duas listas do aplicativo (`AplicativoController::indicacoesListarAction` e o
`V4\ClientesController::indicacoesAction`, pelo `IndicacaoRepository::listarPorClienteIndicador`) e a tela antiga do relatório
(`listagemRelatorioIndex` e `totaisRelatorio`) lerem só a porta do aplicativo, "como hoje". Desde 10/10/2026 (a 53 (c)) o convite
do formulário de quem indica ativo grava a mesma linha, com a ligação e a comissão, e o código já o mostrava nas duas listas; a
porta do aplicativo sozinha o tiraria delas (o convite que pagou a comissão sumiria da lista de quem indicou). A fatia ficou com o
que não muda nada para quem usa: as listas mostram a linha que é a LIGAÇÃO de um convite (a do aplicativo, ou a do formulário para a
qual o amigo aponta), e não mostram a do formulário SEM a ligação -- a do passado (183 na cópia) e a do amigo de quem indica
inativo, que não existiam antes. Na cópia, 0 linhas do formulário com a ligação; na produção, 0 até o corte. **A escolha é do dono**
(a pergunta no lote, "A lista de convites do aplicativo mostra o amigo indicado pelo formulário?", D689): mostrar os convites do
formulário também (com e sem a ligação), só os com a ligação (como ficou), ou só os do aplicativo (a letra da I3). Não bloqueia.

**PARCIAL em 03/11/2026 (a fatia 75b, RESPOSTA-288), pela resposta 288 (b) do dono ("Mostrar todos os do formulário", e não a
(a) recomendada; sem observação).** O `IndicacaoRepository::listarPorClienteIndicador` mostra toda linha de `indicacoes` em que o
cliente é quem indicou, das duas portas -- também a do formulário SEM a ligação (a do passado, que a migração da 75 escreveu, e a
do amigo de quem indica inativo), sem comissão, com "aguardando" ou a data da adesão; o recorte é o indicador da linha e a ordem, a
data do convite, a mais nova primeiro. Assim respondem a lista da v4 (`V4\ClientesController::indicacoesAction`, o Bearer do
telefone) e a rota antiga (`AplicativoController::indicacoesListarAction`) com as dez rotas do aplicativo instalado FECHADAS (a
sessão do ERP). O JSON não muda de forma: a linha nova chega com a `comissao`, a `data_venda` e (aguardando) a `data_adesao` nulas,
os tipos que a lista já mandava no convite do aplicativo sem venda (na cópia, das 10 linhas de antes, 6 sem comissão, 8 sem venda,
5 sem adesão e 1 amigo sem telefone); nenhum campo novo, nenhum valor trocado. A TELA ANTIGA DO RELATÓRIO (`listagemRelatorioIndex`
e `totaisRelatorio`) FICA COMO ESTAVA, só com a ligação, até sair na I4: a letra da 288 fala da lista do cliente no aplicativo, e a
tela nova já conta essas linhas no formulário.

**O QUE RESTA, AO DONO (a verificação da fatia): a rota antiga com as dez ABERTAS, que é a de hoje e a do aplicativo instalado.**
Pela 189 (b), ela responde SEM LOGIN, com `Access-Control-Allow-Origin: *`, a qualquer `:id` sequencial de cliente ativo. Com a
288 (b) inteira, quem percorresse os ids levaria o nome e o telefone de 182 amigos do formulário (os de indicador ativo), 110 deles
inativos -- gente que nunca concluiu o cadastro e que nenhuma rota sem login listava (o `clienteSelecionar` só devolve o cliente
ativo, e pelo telefone); antes, a mesma rota entregava 10 linhas. A pergunta 288 dizia "o cliente passa a ver", e não que a lista
antiga responde a qualquer um. Até a resposta, ela fica como estava (o `listarPorClienteIndicador` com `$soALigacao`, o padrão que
não perde dado nem expõe nada novo): o aplicativo instalado continua com a lista de antes, e a 288 (b) chega a quem usa a v4. A
pergunta no lote (seguranca, "A lista antiga do aplicativo, sem login, mostra os amigos do formulário?", D689).

**QUEM INDICA INATIVO NÃO VÊ LISTA NENHUMA**, em nenhuma das rotas: a antiga só responde ao cliente ativo (`getStatus()`), e a v4
dá o 403 da barreira (o `ClienteAutenticacaoService::clientesDaSessao` só traz o ativo, pela consulta e pelo filtro). A linha do
amigo dele está no banco e aparece quando ele voltar a ficar ativo. Na cópia, nos dois bancos (só SELECT, só contagem): 183 linhas
novas, de 183 clientes, uma por cliente, todas "aguardando", nenhuma com comissão, venda ou primeira venda; nenhum desses clientes
tinha lista antes; 182 deles estão ativos e as veem na v4, e o 183º, inativo, não vê. Os casos em `ListaDeConvitesDoAplicativoTest`
(os dois estados do interruptor, o indicador inativo) e o `IndicacaoDoFormularioTest::testAListaDoAplicativoMostraOPassadoEATelaAntigaSoALigacao`;
o teto de 10 por dia e o `indicarAmigo` não mudaram. O texto da volta no `docs/procedimento-de-corte-etapa-1.md` (a linha da fatia
75) diz a regra nova.

**CORRIGIDO em 04/11/2026 pela resposta 293 (a) do dono ("Só no aplicativo novo", a recomendada; sem observação), registrada na
fatia 76r (RESPOSTAS-289-A-293).** O que restava ao dono era a rota antiga com as dez ABERTAS, e a resposta é o estado de hoje: ela
continua só com a ligação (o `$soALigacao` do `listarPorClienteIndicador`) até ser fechada, e a v4 já mostra todos os amigos do
formulário. Nada de código mudou -- a correção da 75b já o deixou assim, e os casos do `ListaDeConvitesDoAplicativoTest` (os dois
estados do interruptor) o prendem. Quando as dez rotas fecharem, com o aplicativo novo em campo, a rota antiga responde pela sessão do
ERP e mostra todos sozinha, e a 288 (b) vale inteira; o `SO_A_LIGACAO` sai na I4 quando nada mais o usar (`spec-indicacao.md`). Os
comentários do `AplicativoController::indicacoesListarAction`, do `IndicacaoRepository` e do `ListaDeConvitesDoAplicativoTest` ainda
dizem "o D689, ao dono": a fatia foi só documentação, e quem tocar neles os atualiza. CORRIGIDO.

### D690 — A Carteira de clientes exporta o contato? O Clientes antigo exporta sem teto e sem o consentimento — REGISTRADO em 03/11/2026 — DECIDIDO em 04/11/2026 (289 b), EM ABERTO: a CA6 — CORRIGIDO em 04/11/2026: a CA6

**Achado na fatia 76 (RELATORIOS-DA-138-DESENHO, o desenho da Carteira, `docs/f3-relatorios/spec-carteira.md`), o S29 do
levantamento.** O Relatórios › Clientes do ERP antigo exporta o PDF, o XLS e o TXT (`primeiroNome;telefone`, mais `;email` para o
administrador) da lista inteira filtrada, sem teto e sem olhar `clientes.marketing`, e lê a lista da SESSÃO (a última janela aberta,
`ClientesController.php:100` e `:117-143`), e não do pedido. A Carteira, que o substitui pela 138 (a), não exporta até a resposta. Na
cópia, 175 dos 239.239 ativos (sem os sentinelas) não aceitaram mensagens, 79 deles sumidos (mais de 45 dias); 239.236 têm telefone.
A decisão 10 ("como hoje") de 01/10/2026 foi sobre os relatórios daquela lista, e o Clientes não estava nela. **A escolha é do dono**
(a pergunta no lote, "A carteira de clientes exporta o contato?", D690): exportar só quem aceitou, exportar todos como hoje, ou não
exportar. A CA4 da Carteira (a saída do Clientes antigo) espera a resposta; a CA0 a CA3, não. Desde a CA1, o Clientes antigo recebe
a política da Carteira (a E10 do desenho): até a resposta, ele exporta como hoje, mas só para o gerente e o administrador.

**A resposta 289 (b) do dono (04/11/2026, "Como hoje: exporta todos, também quem recusou mensagens"; não foi a (a) recomendada;
sem observação), registrada na fatia 76r (RESPOSTAS-289-A-293).** A Carteira EXPORTA o TXT, o PDF e a planilha, com o contato de
todos, também dos 175 que recusaram mensagens -- por escolha do dono, escrito no `spec-carteira.md` (a 1.6 e a 1.10). A exportação sai
da lista que está na tela (o recorte, a faixa, o colaborador e os quatro filtros da 292 (b)), e não da última janela aberta, como a do
antigo: a parte "lê a lista da sessão" fecha com ela. As colunas são as de hoje (a planilha com as 19, mais a faixa, os dias, a unidade
e o "Aceita mensagens"); o e-mail como hoje, pela letra da (b): no PDF e na planilha para todos os papéis, no TXT só para o
administrador (a pergunta 289 descrevia mal o "hoje", e o D699 a leva de novo ao dono). **O tamanho:** o antigo não tem teto; a base
não sobe o `memory_limit`; a maior lista de um gerente é a unidade inteira (a lista sem faixa, a F5 do desenho: 22.321 na unidade 1,
e 21 das 59 unidades passam de 5.000), e o sintético de 23 colunas mediu, para ela, 394 MB na planilha e 302 MB no PDF (para 5.000
linhas, 103 e 68 MB). Um teto de 5.000 recusaria o arquivo da unidade inteira em 21 unidades, contra a letra "Como hoje": a escolha
vai ao dono (o D700); o TXT leva sempre a lista inteira. Nada mudou no código. **EM ABERTO: a CA6** (a fatia nova da Carteira),
depois da CA5 e da resposta do D700; até lá a Carteira não exporta, e o Clientes antigo exporta como hoje. Fecha na CA6 (CORRIGIDO, a
marca nova no fim do título).

**CORRIGIDO em 04/11/2026 (a fatia 76e2 da fila das telas que faltam, CARTEIRA-CA6; a CA6 e a 1.10 de `docs/f3-relatorios/spec-carteira.md`).** A Carteira exporta o TXT, o PDF e a planilha da lista que está na tela -- o
recorte, a faixa, o colaborador e os quatro filtros, sem a página --, pela `exportacaoAction` da base (`GET
/adm/api/relatorios/carteira/exportacao?formato=<pdf|xlsx|txt>`), e não da última janela guardada na sessão: o S29 consertado sem
tirar ninguém. Também quem recusou mensagens, nos três (a 289 (b); o caso `testQuemRecusouMensagensEstaNosTresArquivos`, com o
consentimento lido do banco, e a coluna "Aceita mensagens" na planilha). O tamanho é o do D700 e do D706. O Clientes antigo continua
exportando como hoje até a CA4.

### D691 — A Evasão conta linhas de item como vendas, conta o cliente uma vez por colaborador e esquece quem sumiu há mais de 18 meses — REGISTRADO em 03/11/2026 — PARCIAL em 04/11/2026: a Carteira no ar na API e no menu novo (a CA1); a Evasão errada sai na CA3 — PARCIAL em 04/11/2026: a Carteira na tela nova (a CA2); a Evasão errada sai na CA3 — CORRIGIDO em 04/11/2026: a Evasão saiu do ERP, com o PDF e a rotina das 04:10 (a CA3)

**Medido na fatia 76 (`docs/f3-relatorios/spec-carteira.md`, 1.3), na cópia, nos dois bancos, com hoje = 17/08/2026 e a janela de
2 meses da rotina (de 17/06/2025 a 17/06/2026), a rede.** O "Total de vendas" do PDF é `COUNT(ven.id)` pelo JOIN dos itens
(`VendaRepository::evasaoPorColaboradorTotalVendas`): 363.789 "vendas" para 232.802 vendas distintas -- o S10; e o "Perc. de vendas"
divide uma soma de itens pela outra. O "sem retorno" conta o cliente em cada colaborador que o atendeu no dia da última visita:
36.164 pares para 33.645 clientes. As cinco janelas cobrem quem veio pela última vez de 2 a 18 meses atrás (42.317 ativos); os
164.982 de mais de 18 meses nunca entram. A rotina das 04:10 (`CronJobService::relatoriosEvasaoAtualizar`) grava o resultado com
nome, telefone e e-mail num arquivo por unidade (o D693). **O conserto é a Carteira** (a régua única de 45 dias, o cliente contado
uma vez): PARCIAL quando a CA1 ligar a Carteira (a conta certa no ar, a Evasão errada ainda no ERP); CORRIGIDO só na CA3, quando a
tela, o PDF e a rotina saírem. A CA3 dependia da resposta do D693, que chegou em 04/11/2026 (a 290 (b), os arquivos ficam): ela
depende agora só da CA2 (registrado na fatia 76r).

**PARCIAL em 04/11/2026 (a fatia 76b, CARTEIRA-CA1).** A conta certa está no ar: a `GET /adm/api/relatorios/carteira` (o
`Adm\CarteiraController`) conta cada cliente uma vez, pela última venda finalizada com item, numa régua só de 45 dias, com o resumo
por colaborador do último serviço (a E7: o cliente conta uma vez, para um colaborador só), e o item "Carteira de clientes" está no
menu novo (com a tela não migrada até a CA2). O oráculo, só SELECT, nas duas cópias, refeito com o código final (o SQL em
`SqlDaCarteira` e a lista da linha "sem serviço" da correção): 357 conferências por banco e dia, 0 divergências. **O que resta:** a Evasão antiga (a tela, o PDF com o "Total de vendas" por item e o "sem retorno" por colaborador, e a
rotina das 04:10) continua no ERP até a CA3.

**PARCIAL em 04/11/2026 (a fatia 76c, CARTEIRA-CA2).** A conta certa tem tela: a Carteira de clientes abre na tela nova, com o resumo
por colaborador da última visita contado uma vez (a E7), no lugar do "sem retorno" por colaborador da Evasão, e sem o "Total de
vendas" por item (o S10). O oráculo da tela, no banco de contrato, com o `CenarioDaCarteira` semeado: 28 conferências pelo HTTP e 293
na tela pura desenhada com as respostas reais, 0 divergências. **O que resta:** a Evasão antiga continua no ERP até a CA3.

**CORRIGIDO em 04/11/2026 (a fatia 76d, CARTEIRA-CA3; a CA3 de `docs/f3-relatorios/spec-carteira.md`).** A Evasão errada saiu do
ERP antigo: o item escondido do menu Relatórios, o `EvasaoController` (a tela e o PDF), o formulário, a view, o `PdfEvasao` com o
"Total de vendas" por item (o S10) e o "sem retorno" contado uma vez por colaborador, a rotina das 04:10
(`CronJobService::relatoriosEvasaoAtualizar`) com a ação, a rota e a documentação da API, e as duas consultas que só ela chamava
(`VendaRepository::evasaoPorColaborador` e `::evasaoPorColaboradorTotalVendas`). A conta certa é a da Carteira de clientes, na tela
nova (a CA1 e a CA2). `/mod-relatorios/rel-evasao/index` e `.../pdf` dão 404, também com o privilégio; `/api/cronjobs/relatoriosEvasaoAtualizar`
dá 404. No corte, o passo 9 e o recuo da etapa 1 tiram a linha da Evasão do crontab guardado, e a do ticket médio, das mesmas 04:10,
fica. **O que mudou para quem usa:** o relatório Evasão não está mais no ERP antigo; os 3 que só têm o privilégio dele (nenhum
gerente; contados de novo, só com SELECT, iguais nos dois bancos) perdem o PDF sem ganhar a Carteira (a E9). Os arquivos já gravados
na produção ficam (a 290 (b), o D693). Provado pelo `EvasaoSaiuTest` (8 casos) e pelo `RelatoriosNoMenuTest`, nos dois bancos, pelo
`scripts/ensaiar-crontab-do-passo-9.sh`, e pelo HTTP no banco de contrato MySQL como o gerente de teste (14 conferências, 0
divergências).

### D692 — A Capacidade × realizado mostra um potencial em reais? O ticket do Potencial é o da rotina, por item — REGISTRADO em 03/11/2026 — DECIDIDO em 04/11/2026 (291 a), EM ABERTO: a CR2 — CORRIGIDO em 04/11/2026 (291 a): a CR2

**Achado na fatia 76 (`docs/f3-relatorios/spec-capacidade.md`, 1.3).** O Potencial de Franquia é horários × ticket × 0,7 × (1 −
redução): o horário é o de HOJE aplicado a qualquer período; o ticket é o campo `unidades.potencial_franquia`, que a tela de Unidades
oferece como "Ticket médio inicial" e a rotina das 04:10 (`unidadesTicketMedioAtualizar`) sobrescreve com a soma dos itens de serviço
sobre o número de itens dos três meses anteriores (em julho de 2026, R$ 60,55 por item, com 663 itens a zero, contra R$ 90,42 por
venda com serviço -- o S04); e o 0,7 só está no código. A mesma conta, com outra população, é a meta que vai à Yungas (o S12: 5
unidades com 7 pessoas só internas). A Capacidade × realizado (a 138 (a)) mostra a capacidade da agenda pela régua da F4, a ocupação e
o realizado do painel, e até a resposta NÃO mostra potencial em reais; a tela antiga fica. **A escolha é do dono** (a pergunta no
lote, "A Capacidade × realizado mostra um potencial em reais?", D692). A CR2 espera a resposta. Até ela, o Potencial antigo é também
o único lugar que conta a capacidade de um mês que ainda não chegou ou de um intervalo livre (a Capacidade fica com o mês do painel,
a E5 do desenho); desde a CR0 ele só abre para o administrador (a E6).

**A resposta 291 (a) do dono (04/11/2026, "Sem potencial em reais", a recomendada; sem observação), registrada na fatia 76r
(RESPOSTAS-289-A-293).** A Capacidade × realizado mostra a capacidade em horários, a ocupação e o realizado, e nenhum valor em reais;
a meta da Yungas continua como está, só lá (a rotina `yungasFaturamentoAtualizar` não muda). A CR2 não espera mais nada: tira o
Potencial antigo do ERP, e com ele o ticket por item (o S04), os 70% e a única conta de capacidade de um mês futuro ou de uma semana --
escrito no `spec-capacidade.md` (a E4, a E5 e a CR2). Nada mudou no código. **EM ABERTO: a CR2**; fecha nela (CORRIGIDO, a marca nova
no fim do título), junto com o D695.

**CORRIGIDO em 04/11/2026 (a fatia 76k, CAPACIDADE-CR2; a CR2 de `docs/f3-relatorios/spec-capacidade.md`), pela 291 (a).** O
Potencial de Franquia saiu do ERP antigo -- o item do menu, a `potenciaisAction`, o `calcularPotenciais`, o
`totalHorariosDisponiveisAgenda` do controller, o formulário e a view --, e com ele o potencial em reais (horários × ticket do
cadastro × 70% × (1 − redução)), o ticket por item (o S04) mostrado como "Ticket Médio" e os 70% que só estavam no código. A
Capacidade × realizado mostra a capacidade em horários, a ocupação e o realizado, sem valor em reais. A meta da Yungas
(`yungasFaturamentoAtualizar`) e a rotina do ticket (`unidadesTicketMedioAtualizar`) FICAM, sem mudança, e não rodaram. **O que
mudou para quem usa:** o administrador não tem mais o Potencial de Franquia no ERP antigo, nem a conta da capacidade de um mês que
ainda não chegou ou de uma semana (a E5). Provado pelo `PotencialSaiuTest` (a ação, o formulário, a view, o item do layout e as
irmãs que ficam), nos dois bancos, e pelo HTTP no banco de contrato como o administrador (o endereço antigo com 404).

### D693 — Os arquivos da rotina da Evasão na produção guardam nome, telefone e e-mail de clientes — REGISTRADO em 03/11/2026 — DECIDIDO em 04/11/2026 (290 b): ficam

**Achado na fatia 76 (`docs/f3-relatorios/spec-carteira.md`, a CA3).** A rotina das 04:10 grava `data/cache/relatorios/evasao/<unidade>.json`
com o id, o nome, o telefone, o e-mail e a data da última venda de cada cliente "sem retorno" (`CronJobService.php:695-701`), e o PDF
não mostra nenhum deles (`PdfEvasao.php`). Quando a Carteira tirar a tela e a rotina (a CA3), os arquivos ficam na produção sem
ninguém que os leia ou os atualize. Os da Fidelização ficaram pela resposta 263 (a) (o D610), mas não tinham contato de cliente.
Nada foi mexido na produção. **A escolha é do dono** (a pergunta no lote, "Os arquivos da Evasão com o contato dos clientes: apagar no
corte?", D693).

**A resposta 290 (b) do dono (04/11/2026, "Ficam, como os da Fidelização"; não foi a (a) recomendada; sem observação),
registrada na fatia 76r (RESPOSTAS-289-A-293).** Os arquivos ficam na produção: a CA3 tira a tela, o PDF e a rotina que os escreve, e
eles param de ser atualizados no dia em que ela sair; nada os apaga, nem a CA3 nem o corte, e o `docs/procedimento-de-corte.md` não
ganha passo -- o mesmo da Fidelização (a 263 (a), o D610). Escrito no `spec-carteira.md` (a CA3). A CA3 deixa de esperar a resposta.
Nada mudou no código nem na produção. DECIDIDO.

**A rotina que os escrevia saiu em 04/11/2026 (a fatia 76d, CARTEIRA-CA3).** `CronJobService::relatoriosEvasaoAtualizar`, a
ação, a rota e o `EvasaoController::pdfAction`, o único leitor, saíram do código; os arquivos de `data/cache/relatorios/evasao/` na
produção ficam como estão, pela 290 (b), e param de ser atualizados no dia em que o código novo entrar. O
`EvasaoSaiuTest::testNenhumCodigoQueFicouCitaAPastaDaEvasao` confere que nenhum `.php` ou `.phtml` de `module/` e `config/`, lido sem
os comentários, cita a pasta. Nada foi mexido na produção.

### D694 — A última visita do cadastro não volta quando a venda é cancelada — REGISTRADO em 03/11/2026 — PARCIAL em 07/11/2026 (a fatia 101: o cancelamento refaz a última visita; as 435 linhas já erradas são dado da produção, e a pergunta foi ao lote; a fidelização e a restrição de agendamento que o finalizar grava não voltam, e também foram ao lote)

**Medido na fatia 76 (`docs/f3-relatorios/spec-carteira.md`, a E2), na cópia, nos dois bancos.** O finalizar grava
`clientes.ultima_visita`, `ultima_visita_unidade` e `ultima_visita_colaborador` (`VendaPagamentoService.php:486-495`), e o
cancelamento não os desfaz. Dos 239.239 ativos sem os sentinelas, 226 têm a coluna depois da última venda finalizada e 209 a têm sem
nenhuma venda finalizada; 366 desses 435 têm uma venda não finalizada (cancelada ou aberta) naquele dia. Leem a coluna: o "Sentimos sua
falta" dos 30 dias, a lista dos 31 dias da Agenda, a fidelização dos 120 dias, o filtro de última visita do Clientes e das Campanhas.
A Carteira lê a venda finalizada e não depende dela. O conserto (o cancelamento refazer as três colunas pela venda finalizada anterior)
mexe no cancelamento e nas rotinas que leem a coluna, e não entra em nenhuma fatia da fila ainda.

#### O cancelamento refaz a última visita — 07/11/2026, a fatia 101 (FINALIZAR-E-CANCELAR-SEM-DINHEIRO-EM-DOBRO)

`VendaService::cancelar` da venda finalizada refaz as três colunas pela venda finalizada anterior
(`VendaRepository::ultimaVisitaSemAVenda`), com a regra do finalizar: nada muda se uma finalizada mais nova do cliente as
gravou depois (a ordem é a `data_criacao`, que o finalizar grava no instante em que fecha, e o id); senão, o instante da
anterior, a unidade de quem a fechou (a do caixa) e -- só se a cancelada tinha serviço, porque sem serviço o finalizar não
mexe no barbeiro -- o barbeiro do último serviço da finalizada mais nova que tem serviço; sem nenhuma finalizada, nulas. Vale
nas duas telas do PDV (o mesmo serviço), na transação do cancelamento, com o cliente travado; a comanda aberta não mexe.

**Provado** em `D383CancelarUmaVezSoTest`, nos dois bancos: cancelar a única venda apaga as três; cancelar a última volta à
anterior (instante, unidade e barbeiro); cancelar uma antiga não muda nada; a última sem serviço não mexe no barbeiro; a
comanda aberta não mexe; o ERP e a API as levam de volta. Mutantes: sem refazer, sem a guarda da mais nova, o barbeiro sem a
guarda do serviço.

**Na verificação da fatia (07/11/2026), dois casos a mais**, nos dois bancos:

- **O barbeiro** (`testOBarbeiroVemDoUltimoServicoDaFinalizadaMaisNovaQueTemServico`): a anterior com dois serviços (o
  gerente, depois o barbeiro), uma de ontem só com a pomada lançada pelo gerente, e a de hoje, com serviço, cancelada. O
  instante e a unidade voltam à de ontem, e o barbeiro ao ÚLTIMO serviço da anterior -- a regra do finalizar, que percorre os
  itens na ordem do id (`Venda::$produtos`, `@OrderBy id ASC`) e grava o colaborador de cada serviço. Mutantes, vermelhos: a
  consulta sem `p.tipo = 'ser'` (nos dois bancos: sairia o gerente da pomada) e com `vp.id ASC` (MySQL: o gerente do
  primeiro serviço).
- **A trava do cliente** (`testOCancelamentoEsperaOFinalizarDeOutraVendaDoClienteENaoApagaAVisitaNova`, com OUTRO
  PROCESSO): o finalizar de outra venda do mesmo cliente, mais nova, trava o cliente e, 1,5 s depois, põe a venda em 1 e grava
  a visita nova. O cancelamento espera o cliente antes de tocar a venda, vê a mais nova, e a visita nova fica. Mutante, sem a
  trava do cliente: **vermelho no PostgreSQL** (o cancelamento lê antes da confirmação e grava a visita antiga por cima da
  nova) e **verde no MySQL** -- medido: lá o `UPDATE` condicional do `status` muda a entrada do índice da chave estrangeira
  do cliente (`FK_VENDAS_cliente`: `cliente, status, data_criacao`), e o InnoDB confere a chave com uma trava compartilhada
  na linha do cliente; a primeira escrita já espera o outro (1,5 s, lido no registro de SQL), com ou sem a trava explícita. A
  trava explícita fica: ela não depende de índice, e é ela que segura no PostgreSQL.

**O que o finalizar grava e o cancelamento não desfaz** (a verificação; ficou de fora, e foi ao lote): além das três colunas
da visita, o finalizar (`VendaPagamentoService::gravarAteAVendaFechada`) põe `fidelizado_colaborador` (o barbeiro desta
visita é o mesmo da anterior), `fidelizado_unidade` (só para verdadeiro: as visitas da janela da unidade chegaram ao mínimo)
e tira `cadastro_restricao_agendamento` (a marca do cadastro feito pelo aplicativo, que a primeira visita tira,
`AplicativoService`). Quem lê: o filtro "fidelizado" do Clientes e das Campanhas (`ClienteRepository`, `FiltrosDaAudiencia`)
e o agendamento pelo aplicativo. A rotina da fidelização dos 120 dias já zera as duas marcas quando a última visita fica
velha -- e agora ela lê a visita refeita. Refazer as duas marcas pela regra do finalizar é conta; devolver a restrição de
agendamento ao cliente cuja visita foi cancelada é regra do negócio (a pessoa pode ter vindo, e a venda só ter sido lançada
errada). A pergunta foi ao lote: TELAS-RESTANTES [cadastro] (07/11/2026), *"A venda cancelada desfaz também a fidelização e
a restrição de agendamento do cliente?"*.

**Os leitores, lidos e não rodados** (nenhum envio): o "Sentimos sua falta", os 31 dias da Agenda, a fidelização e os filtros
do Clientes e das Campanhas comparam datas (`ClienteRepository`: `DATE(cli.ultimaVisita) = :data`, `>=`, `<=`, e a
fidelização com `ultimaVisitaUnidade IS NOT NULL`): o nulo sai das listas como o cliente que nunca veio, e a data refeita
põe o cliente na janela da visita de verdade. A Carteira lê a venda finalizada e não depende da coluna.

**O que fica:** as 435 linhas já erradas na cópia (226 com a coluna depois da última finalizada, 209 sem nenhuma) são dado da
produção. Refazê-las é escrever em `clientes` na produção, e a pergunta foi ao lote (TELAS-RESTANTES [corte], 07/11/2026).
E a fidelização e a restrição de agendamento, pela pergunta [cadastro] do mesmo dia.

### D695 — A tela de Unidades diz que só a Yungas usa a redução do potencial — REGISTRADO em 03/11/2026 — CORRIGIDO em 04/11/2026: a CR2

**Achado na fatia 76 (`docs/f3-relatorios/spec-capacidade.md`, 1.3).** A explicação do campo "Redução do potencial" na tela nova de
Unidades (`frontend/src/telas/Unidades.tsx:1184`) diz "só a integração da Yungas a usa, na meta do mês", e o Potencial de Franquia do
ERP antigo também a usa (`FranquiasController.php:68-70`). 5 unidades ativas têm redução na cópia. Corrige na CR2 da Capacidade, junto
com a decisão do D692 (a frase diz quem a usa depois dela).

**CORRIGIDO em 04/11/2026 (a fatia 76k, CAPACIDADE-CR2).** O Potencial de Franquia saiu do ERP, e com ele a conta que lia a redução
no `FranquiasController`. Conferido por grep no `module/` e no `public/` (e pelo caso
`PotencialSaiuTest::testAReducaoDoPotencialSoELidaPelaMetaDaYungas`, que lê o código sem os comentários): a única conta que lê a
`reducaoPotencial` é a meta da Yungas (`CronJobService::yungasFaturamentoAtualizar`); o cadastro (`RegraDaUnidade`, `UnidadeJson`,
o formulário e as views antigas de Unidades) só a edita e mostra. A frase da tela de Unidades ("só a integração da Yungas a usa, na
meta do mês", `frontend/src/telas/Unidades.tsx`) fica como estava, e passou a ser verdade nesta fatia. **Depois da verificação da
fatia**, o caso deixou de olhar só a forma `['reducaoPotencial']` de array (que deixava verdes um leitor pelo getter
`getReducaoPotencial()`, uma conta na DQL e uma segunda conta no próprio `CronJobService`): ele conta toda menção ao campo no
`module/`, sem os comentários, arquivo por arquivo, contra um mapa fixo (a meta da Yungas, as duas consultas do `UnidadeRepository`
que trazem o campo e o cadastro), e confere que as duas leituras de conta do `CronJobService` estão dentro da
`yungasFaturamentoAtualizar`. Uma menção nova em qualquer arquivo deixa o caso vermelho; quem a puser decide se é conta (e a frase
da tela de Unidades muda junto) ou cadastro (e o mapa muda). Provado pelos mutantes N1 (o getter num controller), N2 (a conta na
DQL), N3 (a segunda conta noutra rotina do `CronJobService`) e N3b (a conta movida para um ajudante do mesmo arquivo), todos
vermelhos; o controle N4 (a menção só num comentário) verde.

### D696 — A busca e os 26 filtros da tela antiga ficam sem lugar na Carteira — REGISTRADO em 03/11/2026 — DECIDIDO em 04/11/2026 (292 b), EM ABERTO: a CA5 e a CA4 — PARCIAL em 04/11/2026: a CA5 — CORRIGIDO em 04/11/2026: a CA4 (a fatia 76e)

**Achado na verificação da fatia 76 (`docs/f3-relatorios/spec-carteira.md`, a 1.8); a tela antiga do título é o Relatórios ›
Clientes.** O Relatórios › Clientes do ERP antigo lista
os clientes com a busca livre, 26 filtros (convênio, consumo, mês do aniversário, idade, origem, serviço comprado, fidelizado...),
8 ordenações e as colunas de nascimento, NPS, consumo, convênio, pontuação e fidelizado (`ClientesController.php:21-111`,
`clientes/index.phtml:229-301`). A Carteira da 138 (a) tem as faixas pelo tempo desde a última visita, o colaborador e a página. A
primeira versão do desenho dizia que os filtros "ficam" nas Campanhas, e não ficam: a porta das Campanhas só CONTA a audiência
("só os números, NUNCA OS IDS (o D297)", `NotificacoesController.php:261-263`) e força o consentimento; nenhuma tela nova mostra
quem são os clientes de um convênio ou de um mês de aniversário. A busca pelo nome está no cadastro de Clientes da tela nova. A 138
(a) não decide o fim da lista filtrável. **A escolha é do dono** (a pergunta no lote, "A lista de clientes com os filtros sai junto
com o Clientes antigo?", D696). A CA4 da Carteira (a saída do Clientes antigo) espera a resposta; a CA0 a CA3, não.

**A resposta 292 (b) do dono (04/11/2026, "A carteira ganha os filtros"; não foi a (a) recomendada; sem observação), registrada na
fatia 76r (RESPOSTAS-289-A-293).** A busca, o convênio, o mês do aniversário e o consumo entram na lista da Carteira, e o Clientes
antigo sai depois. O desenho está no `spec-carteira.md` (a 1.9 e a fatia nova CA5): os quatro com o mesmo sentido do filtro das
Campanhas (`filtrosListagemIndexRelatorio`, que não se reusa porque recorta pela unidade da coluna e escreve DQL; um teste amarra os
dois), condições sobre `clientes` antes da conta da visita, que valem com qualquer das quatro opções da 294 (o D697) -- medido, a lista
com um filtro fica mais barata que sem ele (de 107 a 233 ms no MySQL e de 438 a 672 ms no PostgreSQL, na rede). O consumo é a coluna
`clientes.consumo`, como no antigo, e ela está atrasada (o D698). O que a letra não lista -- 17 parâmetros de filtro, as 8 ordenações,
as colunas de NPS e de fidelizado na tela -- fica FORA, com o motivo (a tabela da 1.9), e sai com a CA4; as Campanhas seguem contando
com todos. Nada mudou no código. **EM ABERTO: a CA5 e a CA4** (a CA4 depois da CA5 e da CA6); fecha na CA4 (CORRIGIDO, a marca nova
no fim do título).

**PARCIAL em 04/11/2026 (a fatia 76e1 da fila das telas que faltam, CARTEIRA-CA5; a CA5 de `docs/f3-relatorios/spec-carteira.md`).**
O gerente e o administrador filtram a lista da Carteira pela busca (nome, telefone ou e-mail, cada palavra em `clientes.tags`), pelo
convênio (o id da lista oferecida ao recorte, ou "todos"), pelo mês do aniversário e pelo consumo (os dois limites inclusive, o nulo
como zero), como no Clientes antigo -- a regra pura `ISRelatorio\Regra\FiltrosDaCarteira`, as condições dentro da subconsulta dos
clientes de `SqlDaCarteira`, a ação `GET .../carteira/convenios` (`ConvenioRepository::daCarteira`) e a barra dos filtros na tela. A
amarração às Campanhas medida na cópia, nos dois bancos: em seis filtros, todo id das Campanhas está na Carteira, e a Carteira só tem a
mais quem recusou mensagens e, em "até R$ 100", os 3.516 com o consumo nulo (a F4). **O que resta:** a exportação (a CA6) e a saída do
Clientes antigo (a CA4), que fecha este débito; as 8 ordenações, os outros 17 filtros e as colunas de NPS e de fidelizado ficam fora,
como a 1.9 escreve.

**CORRIGIDO em 04/11/2026 (a fatia 76e da fila das telas que faltam, CARTEIRA-CA4; a CA4 de `docs/f3-relatorios/spec-carteira.md`,
pela 292 (b) e pela 289 (b)).** Com os quatro filtros (a CA5) e os três arquivos (a CA6) na Carteira, o Clientes antigo saiu do ERP: o
item do menu Relatórios, o `ISRelatorio\Controller\ClientesController` (a lista, o PDF, a planilha e o TXT, com a guarda da E10), a
fábrica, o invokable e o alias `rel-clientes`, a view, o `Pdf/PdfClientes.php`, o `Xls/Clientes.php`, a linha `Rel-clientes` do
`SessaoAcl::menuAtivo`, o `clientes` da conta do `menuAcesso('relatorios')` (nenhum outro item do grupo o pede; na cópia, os 108
ativos fora dos administradores com o privilégio têm um dos outros oito -- ninguém perde o grupo) e o
`ClienteRepository::listagemIndexRelatorio` (sem outro chamador). Os quatro endereços antigos dão 404. **O que sai com ela, escrito**
(a tabela FORA da 1.9): os 17 parâmetros de filtro que a 292 (b) não lista, as 8 ordenações e as colunas de NPS e de fidelizado da
tela; o nascimento, o consumo, o convênio e a pontuação continuam na planilha da Carteira. **O que fica:** o privilégio
`relatorios/clientes` (é o da Carteira), o formulário `ISRelatorio\Form\Cliente` e o `filtrosListagemIndexRelatorio` -- as Campanhas
montam o filtro e contam a audiência com os 26 filtros de antes (`marketingPesquisarClientes`); o `RelatorioClientesInjecaoTest` (o
D73) passou a cobrá-los por essa porta. Provado no `ClientesAntigoSaiuTest` (6 casos), no `RelatoriosNoMenuTest` (o par `rel-clientes`
no `SAIRAM_DO_LAYOUT`; a `METADE_QUE_FICA` da CA3 saiu) e, no front, com a Carteira no `SO_NA_TELA_NOVA` (o item é botão; o caminho é
só a chave da tela nova).

### D697 — A regra da Carteira passa de 1 s na rede no MySQL e fica no limite no PostgreSQL, e o índice das vendas por cliente não está no passo 0 da etapa 1 — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (294 a), EM ABERTO: a CA1 — PREPARADO em 04/11/2026: a CA1 (a conta exata no ar; o índice na migração do passo 0 da etapa 1)

**Medido na fatia 76b (CARTEIRA-CA1, `docs/f3-relatorios/spec-carteira.md`, a nota MEDIDA da CA1), na cópia, só SELECT, com o
"hoje" em 17/08/2026, quente, três rodadas de cada consulta.** A CA1 começa pela medida (a 1.7): se nenhuma forma da regra da E2 (a
última venda finalizada com algum item, a unidade de quem abriu o caixa dela) ficar abaixo de 1 s na rede em algum motor, a fatia para
antes do código. Parou. A primeira medida (os roteiros `p76b/01-*` a `p76b/13-*` do rascunho da sessão) não guardou as saídas e rodou
algumas formas uma ou duas vezes; a correção depois da verificação refez todas com três rodadas e guardou cada saída (`p76b/14-*` a
`p76b/18-*`, o `.sql` e o `.txt` de mesmo nome; os três primeiros gerados por `p76b/gerar-remedida.py`), e mediu também o retrato da
unidade 1 com o resumo por colaborador (a T7) e a página de 50 de uma faixa com o colaborador e o próximo agendamento (a T8) nas formas
novas, que a primeira não tinha medido -- a T8 em nenhum motor, a T7 só no MySQL. Os números abaixo são os da remedida (04/11/2026,
de 00:17 a 00:22). Todas as consultas, em todas as rodadas, deram as faixas da 1.5 (13.670 / 4.205 / 6.180 / 8.380 / 16.852 / 28.188 /
149.951 / 11.813 na rede), a unidade 1 com 22.321 (4.010 sem visita, 2.679 em dia, 15.632 sumidos, 71 colaboradores, 134 com visita e
sem serviço) e a página com 50 linhas das 748 da faixa de 46 a 90 dias da unidade 1:

| Consulta e forma | MySQL, 128 MB (o de fábrica) | MySQL, 2 GB | PostgreSQL como a aplicação (`bvip_app`, RLS): o JIT padrão; sem o JIT |
|---|---|---|---|
| A rede, pela janela por cliente (a T6 da 1.7) | 5.569 a 5.691 ms | -- | 1.042 a 1.162 ms (a 1ª, fria: 2.425); 1.034 a 1.116 ms |
| A rede, pelo `LATERAL` | 7.267 a 7.363 ms | -- | 1.340 a 1.361 ms (a 1ª, fria: 2.464); 1.268 a 1.285 ms |
| A rede, pela subconsulta por cliente (o id pelo índice) e a volta à venda pela chave (a B) | 1.688 a 1.758 ms | 1.179 a 1.269 ms | -- |
| A rede, pelo `MAX` com a volta e a subconsulta só para quem sobra (a G3) | -- | 1.577 a 1.665 ms | -- |
| A rede, pelo `DISTINCT ON` por cliente (a D) | -- | -- | 979 a 1.114 ms; 941 a 998 ms |
| A rede pela B, só o id da última venda com item, sem a unidade do caixa | 823 a 850 ms | 635 a 691 ms | -- |
| A T7, o retrato da unidade 1 com o resumo por colaborador (a B no MySQL, a D no PostgreSQL) | 1.846 a 1.848 ms | 1.218 a 1.255 ms | 1.032 a 1.077 ms; 996 a 999 ms |
| A T8, a página de 50 da faixa de 46 a 90 dias da unidade 1, com o colaborador, o próximo agendamento e o total da faixa | 1.362 a 1.452 ms (a 1ª, com as agendas fora da memória: 3.076) | 873 a 938 ms | 754 a 993 ms; 714 a 789 ms |
| A B no banco descartável, com o índice de produção da etapa 1 (`FK_VENDAS_cliente (cliente)`) | 6.780 a 6.880 ms | -- | -- |

A coluna de 2 GB: o pool crescido por `SET GLOBAL` e devolvido aos 128 MB na mesma rodada (conferido depois), com uma passada de
aquecimento antes, guardada no mesmo `.txt` (a primeira rodada da B, fria, 2.634 ms). O banco descartável (`p76b_descartavel`, só a
`vendas` da cópia com o índice de uma coluna) também com aquecimento (6.737 a 7.599 ms), e apagado no fim. A primeira medida, sem saída
guardada, deu números da mesma ordem (a B 1.560 a 1.656 ms com 128 MB e 1.111 a 1.252 ms com 2 GB; a D 1.015 a 1.111 ms; o descartável
7.158 a 7.250 ms, duas rodadas); a decisão de parar não muda com a remedida.

**O que a medida diz:** no MySQL nenhuma forma da regra fica abaixo de 1 s na rede -- a melhor, a B, leva 1,7 a 1,8 s com o pool de
fábrica e 1,2 a 1,3 s com 2 GB; o retrato da unidade custa o mesmo que a rede (o cliente é da unidade da ÚLTIMA venda, que só se sabe
depois de achar a de todos). No PostgreSQL a D fica no limite: 0,98 a 1,11 s com o JIT padrão, o da aplicação, e 0,94 a 1,00 s sem ele.
A lista de uma faixa (a T8) é a única abaixo de 1 s, no PostgreSQL e no MySQL com 2 GB. Sem a unidade do caixa, a B fica abaixo de
1 s nos dois pools: a unidade, que a regra pede de todo cliente antes de recortar (E6), custa o resto.

**O índice não era novo.** O `vendas (cliente, status, data_criacao)` que a CA1 ia criar já existe nas duas cópias desde 29/09/2026
(o **D308**), e a T6 a T8 da 1.7 já foram medidas com ele; o `mysql.sql` e o passo 0 da etapa 1 ainda têm o `FK_VENDAS_cliente
(cliente)`, e com ele a melhor forma leva 6,8 a 6,9 s. **Pôr o índice no passo 0 da etapa 1 muda uma decisão anterior do dono:** o
D308 diz "índice em produção fica para a migração final (decisão do dono)" -- o de quatro colunas nasce no PostgreSQL com o schema
(o `postgres/schema.sql` já o tem, pelo gerador), e o MySQL de produção seguiria com o de uma coluna durante a etapa 1. A troca é um
`ALTER TABLE vendas DROP INDEX FK_VENDAS_cliente, ADD INDEX FK_VENDAS_cliente (cliente, status, data_criacao)` com o mesmo nome (a
chave estrangeira segue servida): não apaga linha, mas também não é só acrescentar; o D308 a ensaiou em 18 s na cópia, `INPLACE`,
`LOCK=NONE`. O precedente é a 196 (b), quando o dono pôs o índice da agenda (o D189) no passo 0 da etapa 1. O buffer pool de produção
nunca foi lido, e se ajusta na janela (a 205 (a)): os números da coluna de 2 GB são os mais próximos de lá, sem prova.

**A coluna `clientes.ultima_visita` contra a regra, cliente a cliente** (`p76b/18-coluna-contra-regra-*`, iguais nos dois bancos): 278
ativos ficam noutra faixa pela coluna; em 203 deles a coluna cai no dia de uma venda não finalizada, sem venda finalizada com item no
mesmo dia (o D694), em 3 no dia de uma venda finalizada só de crédito, e 72 têm outra diferença que esta medida não separa; 215 dos 278
são "sem visita" pela regra e têm data na coluna. O caminho de volta da E2, como a 1.7 o escreveu, é a coluna **com o D694 consertado
antes**: os 203 voltam à faixa da regra, e cerca de 75 seguem noutra.

**De passagem, no PostgreSQL:** medida pelo dono do banco (superusuário, sem o RLS), a consulta não traz o `inquilino_id` que abre todos
os índices, e o `LATERAL` passou de 10 min (cancelado pelo `pg_cancel_backend`, só SELECT). Medir o PostgreSQL como o `bvip_app`, com o
`app.inquilino_id`, é o que vale para a aplicação.

**A escolha é do dono** (a pergunta no lote, "A carteira de clientes demora 1 a 2 segundos para abrir: aceitar, ou trocar a
conta?", D697): (a) aceitar a espera com a conta exata e o índice do D308 no passo 0 da etapa 1; (b) a conta exata guardada por 10
minutos e o mesmo índice -- guardando só as contas e os ids, ou uma cópia por papel, porque a lista leva nome e telefone e, na do
administrador, o e-mail que o gerente não vê (a E9, a 1.6), e guardá-la em arquivo repetiria o cache da Evasão (o D693); (c) uma tabela
de resumo gravada pelo finalizar e pelo cancelar (não medida: o 0,1 s é a estimativa pela forma da coluna, 68 a 95 ms no MySQL e 14 a
21 ms no PostgreSQL na 1.7); ou (d) a coluna `clientes.ultima_visita` com o D694 consertado antes (o caminho de volta da E2; a venda só
de crédito conta como visita). A CA1 espera a resposta; nada mudou no código.

**A resposta 294 (a) do dono (04/11/2026, "Aceitar a espera, com a conta exata"; a recomendada; sem observação),
registrada na fatia 76u (RESPOSTAS-294-A-300).** A Carteira abre em 1 a 2 segundos, com "carregando", e conta a visita pela última
comanda finalizada com item (a E2, a conta do painel); o "abaixo de 1 s" deixa de ser a condição da CA1, que roda sem esperar mais
nada, com a forma B (a subconsulta por cliente e a volta à venda pela chave) no MySQL e a D (o `DISTINCT ON` por cliente) no
PostgreSQL, a medida acima escrita como aceita (`docs/f3-relatorios/spec-carteira.md`, a 1.7 e a CA1). As outras três (a conta
guardada, a tabela de resumo, a coluna do cadastro) ficam como caminho de volta, sem mudar número: a (b) entra depois se a espera
incomodar. **O índice das vendas por cliente entra no passo 0 da etapa 1** (`docs/procedimento-de-corte-etapa-1.md`, o passo 4 e o
item 15 de "O que falta fazer antes da janela") -- a mudança da decisão do D308, que passa a guardar a migração. Conferido nas duas
cópias, só no catálogo (`p76u-01-permissoes-indice-*` no rascunho da sessão): o índice já está nas duas desde 29/09/2026, e o
`mysql.sql` do repositório tem o de uma coluna. Nada mudou no código. **EM ABERTO: a CA1** (CORRIGIDO quando a Carteira entrar no ar
com a conta exata, a marca nova no fim do título); a migração do índice é o D308.

**PREPARADO em 04/11/2026 (a fatia 76b, CARTEIRA-CA1; a 294 (a)).** A conta exata está no ar pela forma B no MySQL e pelo
`DISTINCT ON` no PostgreSQL (`ClienteRepository::carteiraDaRede`, `::carteiraDaUnidade` e `::carteiraLista`), e a troca do índice
vai ao passo 0 da etapa 1 pela `scripts/migracao-vendas-indice-cliente-status-data.sql` (o D308). **A medida em processo**, pelo
controller, como o administrador, com `memory_limit=128M`, na cópia, hoje = 04/11/2026 (o primeiro pedido do processo; os seguintes),
com o código final -- o SQL em `SqlDaCarteira` e a lista da linha "sem serviço" da correção da 76b (`p76b3/m-mysql.txt` e
`p76b3/m-pg.txt` no rascunho da sessão): a rede em 2,19 s; 1,78 s no MySQL de fábrica (pico 33,7 MB) e 1,18 s; 1,06 s no PostgreSQL
(18 a 20 MB); o retrato da unidade 1 em 2,05 s; 1,96 a 1,98 s e 1,27 s; 1,21 s (38 MB); a página de uma faixa da unidade 1 em 1,33 s;
1,29 s e 0,90 s; 0,77 s; a da linha "sem serviço" da unidade 1 (os 134 clientes; a subconsulta do colaborador roda para cada cliente
da unidade), 1,85 a 1,87 s e 1,63 a 1,64 s. A primeira medida, com o SQL ainda no `ClienteRepository` (o texto movido igual por
roteiro), dava a rede em 1,98 s; 1,82 s e 2,40 s; 1,26 a 1,43 s, e a unidade 1 em 2,95 s; 1,99 s e 1,63 s; 1,19 a 1,27 s: o primeiro
pedido é o que mais varia, com o que o banco tem na memória. A rede não carrega os 239 mil numa lista: chega linha a linha à
`ContasDaCarteira::porUnidade`. **O que resta:** a produção, onde o índice é o de uma coluna até o passo 0 da etapa 1 rodar a
migração (sem ela, a conta leva 6,8 a 6,9 s no MySQL); e o "carregando" da tela, que é da CA2.

### D698 — O consumo do cadastro está atrasado em 4.477 clientes ativos, e é ele que o filtro de consumo lê — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (295 a), EM ABERTO: a CA7 e o refazer no corte — PREPARADO em 04/11/2026: a CA7; o refazer no corte

**Medido na fatia 76r (RESPOSTAS-289-A-293, o desenho dos filtros da Carteira, `docs/f3-relatorios/spec-carteira.md`, a F4 da 1.9),
na cópia, só SELECT de contagem, iguais nos dois bancos** (`p76r-02-filtros-*.sql`, `p76r-03-tempo-filtros-*.sql` e, em centavos,
`p76r-06-consumo-centavos-*.sql` no rascunho da sessão, com o `.txt` de mesmo nome). A coluna `clientes.consumo` é o total das vendas finalizadas do cliente na rede, desde sempre
(`VendaRepository::selecionarConsumoPorCliente`), e só a rotina da 01:10 a escreve (`CronJobService::clientesAtualizarConsumo`),
para quem tem a `ultima_visita` nos dois últimos dias; nada a refaz quando uma venda é cancelada, nem para quem não volta. Dos 239.239
ativos sem os sentinelas, 4.477 têm a coluna diferente da soma de `vendas.valor_total` das finalizadas em mais de 1 centavo: 4.445
abaixo (2.918 com zero e venda, 8 com o nulo e venda, 1.519 com um valor menor) e 32 acima; 4.406 dos 4.477 têm a última venda
finalizada há mais de um ano (antes de 17/08/2025). A primeira contagem dava 4.478 (e 4.407): comparava o ponto flutuante com
`> 0.01`, e um cliente com a diferença de 0,010000000000000002 contava como divergente sem estar em nenhum dos dois baldes (a
verificação da 76r); em centavos inteiros (`ROUND(x * 100)`), a soma das partes fecha. Leem a coluna: o filtro e a coluna de consumo do Relatórios › Clientes antigo, o filtro das Campanhas
(`marketingPesquisarClientes`, pelo `filtrosListagemIndexRelatorio`) e, desde a CA5, o filtro de consumo da Carteira, que a lê como
as outras duas para o número de cada cliente ser o mesmo nas três (a soma na hora custa 4,9 s no MySQL) -- com uma diferença de
sentido, escrita no desenho (a F4) e na pergunta: a Carteira conta o nulo como zero, e a lista de "até R$ X" dela tem a mais os 3.516
ativos com o nulo, que o antigo e as Campanhas deixam fora. **A escolha é do dono** (a
pergunta no lote, "O consumo do cadastro está atrasado em 4.477 clientes: consertar a conta?", D698): o conserto recalcula a coluna
de todos uma vez (um UPDATE da coluna na produção, que não apaga linha mas muda dado) e faz a rotina refazê-la também ao cancelar. A
CA5 não espera a resposta. Não bloqueia.

**A resposta 295 (a) do dono (04/11/2026, "Consertar a conta"; a recomendada; sem observação), registrada na fatia 76u
(RESPOSTAS-294-A-300).** Duas partes, desenhadas na fatia nova **CA7** do `docs/f3-relatorios/spec-carteira.md`: (1) a rotina da
01:10 (`CronJobService::clientesAtualizarConsumo`) passa a refazer também o consumo de quem teve uma venda cancelada nos dois
últimos dias, pelo `vendas.cancelado_em` que o PDV grava desde a fatia 70 -- o caminho que cancela uma venda finalizada é um só, o
`VendaService::cancelar`, chamado pelo PDV antigo e pela `/adm/api`; o `CronJobService::comandasCancelar` das 23:58 cancela só
comanda aberta, que o consumo não conta --; e (2) uma vez, no corte, o consumo de todos é refeito pela soma das vendas finalizadas,
no passo 7 da etapa 1, logo depois da troca do código, com a contagem antes (`docs/procedimento-de-corte-etapa-1.md`). Muda o número
guardado; não apaga linha. O Clientes antigo, as Campanhas e a Carteira (a F4 da CA5) passam a ler o total certo. **Medido na
cópia, só SELECT de contagem** (`p76u-02-consumo-*.sql`, `p76u-03-rotina-*.sql` e `p76u-06-consumo-quando-*.sql`, com o `.txt` de
mesmo nome, no rascunho da sessão): pela régua deste débito (mais de 1 centavo), o refazer muda **4.707** dos 242.597 clientes, igual
nas duas cópias -- os 4.477 ativos acima e mais 230 entre os inativos e os dois sentinelas --; pela diferença em qualquer centavo, a
conta muda entre as rodadas no PostgreSQL (4.727 e 4.728: a soma em ponto flutuante), e por isso o roteiro usa a régua de 1 centavo.
Nove clientes sem venda finalizada e com valor na coluna vão a zero; o nulo sem venda fica nulo (3.508 ativos), e a diferença de
sentido da F4 da Carteira (o nulo conta como zero só lá) não muda (**superado pela 303 (a)**, o D707, a fatia 76p: o nulo sem
venda também vai a zero, pela rotina e pelo roteiro; 9.184 mudam na cópia). A consulta que acha esses clientes leva cerca de 6 s no MySQL
(pelo `docker exec`, junto com a contagem das canceladas) e 0,53 s no PostgreSQL; a seleção nova da rotina, sem índice em
`cancelado_em`, 0,26 a 0,45 s no MySQL e 53 ms no PostgreSQL (zero linhas: a cópia não tem cancelamento com o instante). Dos 4.477,
4.406 têm a última venda finalizada há mais de um ano, 47 nos últimos 30 dias (a cópia não roda a rotina) e 31 dos 32 acima da soma
têm uma venda cancelada. Nada mudou no código nem na produção. **EM ABERTO: a CA7 e o refazer no corte** (PREPARADO quando a CA7
estiver commitada, com o roteiro do refazer; CORRIGIDO depois do corte, a marca nova no fim do título).

**PREPARADO em 04/11/2026 (a fatia 76o, CARTEIRA-CA7; a CA7 do `docs/f3-relatorios/spec-carteira.md`).** As duas partes da 295 (a)
estão no código, e o refazer espera o corte. (1) A rotina da 01:10 junta aos que vieram os clientes ativos com uma venda de
`cancelado_em` de anteontem a hoje (`ClienteRepository::selecionarClientesComVendaCanceladaNoPeriodo`, pelo
`CronJobService::clientesDoConsumoARefazer`), sem repetir quem está nas duas, e o log diz as duas partes; o cancelar não mudou. (2)
O `scripts/refazer-consumo-dos-clientes.sql` refaz a coluna de todos onde ela difere da soma das finalizadas em mais de 1 centavo
(em centavos inteiros; o nulo sem venda fica nulo; zero para quem não tem venda -- **superado pela 303 (a)**, o D707, a fatia
76p: o nulo sem venda também vai a zero), com a contagem antes, a lista dos que mudam com o
número de antes (o caminho de volta) e a conferência (0); idempotente. Ele roda no passo 7 de `docs/procedimento-de-corte-etapa-1.md`,
logo depois dos pacotes de venda cancelada, pelo caminho da árvore no lugar e lendo o `PIPESTATUS` do `mysql` -- e, pela correção
que a 76n apontou, as outras chamadas de roteiro do passo 7 (as sete de `colunas_gravadas`, a dos pacotes e o `verificar-local-php.php`)
também passaram ao caminho da árvore no lugar e ao `PIPESTATUS`. **Num clone descartável da cópia do MySQL**: 4.707 mudam (35 acima,
4.672 abaixo; 2.941 com zero e venda, 156 com o nulo e venda, 9 sem venda com valor; 4.477 ativos), o `UPDATE` refez 4.707, a
conferência e a segunda rodada deram 0, os 20 clientes sorteados ficaram com a soma, e nenhuma outra coluna mudou; cerca de 19 s. A
contagem antes, só SELECT, nas duas cópias: os mesmos números. Testes: `tests/Caracterizacao/ConsumoDoClienteTest.php` (11 casos; os
dois que rodam o roteiro, só no MySQL) e o `PermissoesComONomeDaTelaTest::testOPasso7LeASaidaDoMysqlENaoADoTee` estendido a todo
roteiro do passo 7. As cópias locais não foram refeitas (no `franquiabv_dev`, só SELECT). **Falta o corte:** o `UPDATE` no passo 7, e
o ensaio da etapa 1 (o item 11); CORRIGIDO depois dele, a marca nova no fim do título. O que fica de fora, escrito: o cancelamento
feito numa volta ao código antigo não grava o `cancelado_em`, e esse cliente fica atrasado até voltar ou até o roteiro rodar de novo.

**A correção da verificação da 76o (04/11/2026).** (1) **A seleção em duas leituras:** o "0,26 a 0,45 s" acima era a leitura de
`vendas` sozinha; a 76o a tinha escrito como um `EXISTS` partindo de `clientes`, que o MySQL resolve varrendo os 237 mil clientes
pelo `FK_VENDAS_cliente` -- 4,2 a 4,4 s na cópia (o `IN (subconsulta)` e o `DISTINCT` caem no mesmo plano). Agora são os ids das
vendas canceladas no intervalo, só de `vendas` e sem `DISTINCT` (0,23 a 0,25 s), e os clientes ativos por esses ids, pela chave; no
PostgreSQL, cerca de 50 ms nas duas formas (`p76o-corr/` no rascunho da sessão; só SELECT, zero linhas). (2) No procedimento, o
parágrafo do consumo com o tempo medido (cerca de 19 s no clone) e a linha 7 da linha do tempo contando o refazer; e o passo 8 com o
`bloquear-migrar-senhas.sh` pelo caminho da árvore no lugar (no texto e na tabela da janela: o diretório ainda é o da troca do 7,
onde `scripts/` não existe), com o caso do passo 7 estendido até o próximo `cd` e às linhas 7 a 13 da tabela. (3) A descrição da rota
na documentação da `/api` (`ApiDocsController`) e a frase da F4 da Carteira com a venda cancelada. (4) Os casos novos do
`ConsumoDoClienteTest` (15 casos): o cancelamento à meia-noite de anteontem pela rotina, a seleção em duas leituras (com o plano no
MySQL), a única finalizada cancelada deixando o nulo na rotina (o **D707**), e os textos. **Achado, e perguntado ao dono:** o cliente
sem nenhuma venda finalizada fica com o nulo se a rotina o refaz e com zero se o corte o refaz (o **D707**).

### D699 — O e-mail do cliente nos arquivos da Carteira: o PDF e a planilha de hoje o dão também ao gerente — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (296 b): como hoje — CORRIGIDO em 04/11/2026: a CA6

**Achado na fatia 76r (RESPOSTAS-289-A-293, o desenho da exportação da Carteira, `docs/f3-relatorios/spec-carteira.md`, a X2 da
1.10).** O Relatórios › Clientes antigo exporta o e-mail do cliente ao gerente no PDF (`PdfClientes.php:62`) e na planilha
(`Xls/Clientes.php:39`); só o TXT o guarda para o administrador (`ClientesController.php:159`). A tela da Carteira mostra o e-mail só ao
administrador (a 1.6 do desenho), e a pergunta 289, que o dono respondeu com a (b) "Como hoje", dizia na opção (a) "o e-mail só para o
administrador, como hoje" -- o que só é verdade no TXT. Essa frase estava na opção (a), que o dono não escolheu; a letra da (b),
"Como hoje", é o arquivo de hoje -- o e-mail no PDF e na planilha também para o gerente. **Até a resposta, a CA6 segue a letra da 289
(b)** (a correção da 76r: a primeira versão seguia a tela, o e-mail só ao administrador, que era a recomendação deste débito, e não a
letra); o gerente já recebe esse e-mail hoje pelo Clientes antigo, e nada se abre a mais. **A escolha é do dono** (a pergunta no lote,
"O arquivo da carteira leva o e-mail do cliente para o gerente?", D699), porque o texto da 289 descreveu mal o "hoje". A (a), se vier,
é uma condição no `documento` da CA6. Não bloqueia a CA5, a CA6 nem a CA4.

**A resposta 296 (b) do dono (04/11/2026, "Como hoje"; a recomendada; sem observação), registrada na fatia 76u
(RESPOSTAS-294-A-300).** O PDF e a planilha da Carteira levam o e-mail do cliente também para o gerente; o TXT, só para o
administrador -- o que a X2 da 1.10 do `docs/f3-relatorios/spec-carteira.md` já dizia até a resposta, e por isso nada muda no
desenho além da marca: a CA6 segue sem a condição do caminho de volta. A tela continua sem o e-mail para o gerente (a 1.6): é a linha
da Carteira, e não o arquivo. Nada mudou no código. DECIDIDO.

**CORRIGIDO em 04/11/2026 (a fatia 76e2 da fila das telas que faltam, CARTEIRA-CA6; a CA6 e a 1.10 de `docs/f3-relatorios/spec-carteira.md`).** O PDF e a planilha da Carteira levam o e-mail do cliente também para o gerente,
como o Clientes antigo (`CarteiraJson::documento`); o TXT, só para o administrador (a regra da base, `TextoDoRelatorio::contatos` pelo
escopo); a tela continua sem o e-mail para o gerente. Os casos `testOsTresArquivosDoGerente` e `testOsArquivosDoAdministrador`, nos
dois bancos, e os mutantes S3 (o e-mail fora do PDF do gerente) e S4 (o e-mail no TXT do gerente), vermelhos.

### D700 — O PDF e a planilha da Carteira: a unidade inteira, como hoje, ou até um teto? A maior lista de um gerente passa da memória de fábrica — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (297 a), EM ABERTO: a CA6 — CORRIGIDO em 04/11/2026: a CA6

**Achado na verificação da fatia 76r (RESPOSTAS-289-A-293, `docs/f3-relatorios/spec-carteira.md`, a X3 da 1.10).** A primeira versão
do desenho pôs um teto entre 4.010 e 5.000 linhas no PDF e na planilha da Carteira, partindo da "maior lista de um gerente" como a
faixa de mais de 2 anos da unidade 1 (11.804). Mas a lista da Carteira pode vir sem faixa (a F5 da 1.9): a maior lista de um gerente
é a unidade inteira -- 22.321 na unidade 1, e 21 das 59 unidades passam de 5.000 (`p76r-01-maior-faixa-*.txt`, pela regra da
Carteira, iguais nos dois bancos). Hoje, o Relatórios › Clientes antigo exporta a unidade inteira sem filtro, sem teto: a planilha
sobe o `memory_limit` a 1024 MB (`Xls/Clientes.php:13-14`); o administrador pede também a rede inteira (`ClientesController.php:26`).
A base da F3 não sobe o limite (`PlanilhaDoRelatorio.php:28-30`). Num documento SINTÉTICO de 23 colunas, sem dado da cópia
(`p76r-04-medir-arquivo.php`, no contêiner, sem limite): a unidade 1 inteira, 22.321 linhas, leva 394 MB de pico na planilha e 302 MB
no PDF; a rede inteira passaria de 4 GB na planilha (estimado, não medido). O `memory_limit` da produção nunca foi lido (o D631). Um
teto de 5.000 recusaria o arquivo da unidade inteira em 21 unidades, contra a letra da 289 (b), "Como hoje". **A escolha é do dono**
(a pergunta no lote, "O PDF e a planilha da carteira: a unidade inteira, como hoje, ou até 5.000 clientes?", D700): (a) a unidade
inteira, como hoje, com mais memória só na montagem do arquivo ou o cache de células em disco, e a rede inteira só no TXT; (b) um teto
de cerca de 5.000 linhas no PDF e na planilha. O TXT leva sempre a lista inteira. A CA6 espera a resposta; nada se perde antes, porque o
Clientes antigo exporta como hoje até a CA4, que roda depois da CA6. Nada mudou no código.

**A resposta 297 (a) do dono (04/11/2026, "A unidade inteira, como hoje"; a recomendada; sem observação), registrada na
fatia 76u (RESPOSTAS-294-A-300).** O PDF e a planilha saem com a lista inteira de qualquer unidade, até a maior (a 1, 22.321 na
cópia); na rede, o PDF e a planilha pedem uma unidade, uma faixa ou um filtro (sem nenhum dos três, o 422 que diz isso), e o TXT sai
sempre inteiro. **Como o servidor monta o arquivo grande, pelo que o código de hoje já usa:** o `ini_set('memory_limit', '1024M')`
só na ação que monta o PDF ou a planilha da Carteira, como o `Xls/Clientes.php:14` e outras seis planilhas antigas fazem (o
`Xls/Sorteio`, o `Xls/VendasVersao1`, o `Xls/VendasVersao2`, o `Xls/Atendimentos`, o `Xls/VendasRealizadas` e o
`VendasController::indexVendasExportarXlsAction`; as outras chamadas do grep, no `ApiSMS`, no `VipClassService`, no
`ArquivoService` e no `UsuarioService`, não são planilhas -- a primeira versão deste parágrafo dizia "oito"); nenhum código usa o
cache de células do PhpSpreadsheet em disco, e ele não serviria ao PDF. A base da F3 continua sem subir o limite
(`PlanilhaDoRelatorio.php:28-30`): a CA6 o sobe no `documento` da Carteira, e só nele. **A rede com uma faixa que não cabe foi ao
dono (o D706, na verificação da 76u):** a primeira versão deste parágrafo fixava um "teto técnico na rede, que a letra não diz e
que não tira nada de hoje"; a verificação mostrou que o teto restringe o que a letra libera (na rede, "uma unidade, uma faixa ou um
filtro" basta, e a faixa de mais de 2 anos tem 149.951) e que o "não tira nada de hoje" não foi provado (o Clientes antigo não foi
medido). As medidas estão no D706. Até a resposta, a CA6 segue a (a) dele, o teto técnico, e a CA4 espera. A CA6 deixa de esperar o
D700. Nada mudou no código. **EM ABERTO: a CA6** (fecha com o D690, a marca nova no fim do título).

**CORRIGIDO em 04/11/2026 (a fatia 76e2 da fila das telas que faltam, CARTEIRA-CA6; a CA6 e a 1.10 de `docs/f3-relatorios/spec-carteira.md`).** O PDF e a planilha saem da unidade inteira, qualquer que seja o tamanho, para o
gerente e para o administrador que escolheu a unidade; na rede, sem unidade, faixa, colaborador nem filtro, o 422 "Na rede inteira, o
PDF e a planilha pedem uma unidade, uma faixa ou um filtro; o TXT leva todos." (`ContasDaCarteira::recusaDoDocumento`, a mesma regra
que a lista ecoa à tela em `recusaDoDocumento`). O limite de memória sobe a 1024 MB só no `documento` (o PDF e a planilha), e nunca
desce; o TXT vai por iterador, sem subir nada. **Medido em processo, pela Carteira, com `memory_limit=128M` no começo (o código sobe),
a cópia, hoje = 17/08/2026, MySQL e PostgreSQL:** a unidade 1 inteira (22.321) -- o TXT em 2,8 s e 20 MB (fria, 7,5 s) e 5,5 s e 18 MB;
o PDF em 3,5 s e 108 MB e 2,6 s e 108 MB; a planilha em 8,2 s e 350 MB e 7,1 s e 350 MB. Coube no 1 GB com folga: a CA6 não parou.
A primeira medida da faixa de 1 a 2 anos da rede caiu no `OutOfSpaceException` do cache de memória do Doctrine (o teto dele é a
metade do limite do começo do processo, 64 MB, e uma consulta DQL vinha depois da lista inteira na memória): as leituras leves
passaram para antes da pesada (a lição do D646 e do D655), e ela saiu.

### D701 — A permissão das Datas da equipe segue com o nome "Relatórios de aniversariante" no cadastro dos grupos — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (298 c), EM ABERTO: a DE3 e o passo 7 da etapa 1 — PREPARADO em 04/11/2026: a DE3; o UPDATE no corte

**Achado na fatia 76h (DATAS-DA-EQUIPE-DE2, `docs/f3-relatorios/spec-datas-da-equipe.md`).** A 138 (a) renomeou o relatório, e a DE2
tirou o Aniversariantes antigo do ERP; o privilégio ficou, e é o da tela nova (`PoliticaDoRelatorio::POLITICAS['datasDaEquipe']`,
`relatorios/aniversariantes/ler`). Mas o ACL casa a funcionalidade PELO NOME (`SessaoAcl::setAcl`, `'aniversariantes' =>
$this->salvarAcesso($grupo->getNome(), 'Relatórios de aniversariante')`, e o `salvarAcesso` pergunta ao `Laminas\Permissions\Acl` pelo
nome), e os dois cadastros dos grupos mostram o nome da linha de `funcionalidades` como ela está -- a tela antiga e a nova
(`GrupoJson::daFuncionalidade` devolve o `getNome()`): "Relatórios de aniversariante" (nas fixtures, o 90002039). O desenho
não decide o nome da permissão. Renomear é um `UPDATE` de uma linha de `funcionalidades` na produção no mesmo
deploy em que o `SessaoAcl` passa a procurar o nome novo (os `privilegios` ligam o grupo à funcionalidade pelo id, e nenhum grupo
perde a permissão), e por isso é passo do corte, e não do passo 0. **A escolha é do dono** (a pergunta no lote, "A permissão das
Datas da equipe continua com o nome 'Relatórios de aniversariante' no cadastro dos grupos?", D701): (a) fica o nome; (b) passa a
"Relatórios de datas da equipe" no corte. Nada mudou no código: o nome de hoje fica até a resposta (o padrão que não perde dado).

**O alcance, depois da verificação da 76h (04/11/2026).** As Datas não são a única tela nova com o privilégio no nome da antiga: o
Profissional Parceiro usa "Relatórios dashboard colaboradores" (`PoliticaDoRelatorio::POLITICAS['parceiro']`; a 182 o manteve na
lista, sem falar do nome), a Carteira, "Relatórios de clientes" (`['carteira']`), e a Cota-parte e a v2, "Relatórios de vendas"
(`['cotaParte']`, `['cotaParteV2']`). Os Royalties usam "Relatórios de franquias", mas pelo papel de administrador, que passa sem
linha. Renomear só a das Datas deixaria o cadastro com um nome novo e três antigos: a pergunta passou a dizer isso e ganhou a (c),
as quatro de uma vez no corte (cada uma, a linha de `funcionalidades` e a do `SessaoAcl::setAcl`; o
`scripts/ensaiar-links-consertados-do-erp.php` também cita "Relatórios de vendas"). Nada mudou no código.

**A resposta 298 (c) do dono (04/11/2026, "As quatro passam ao nome da tela no dia da virada"; a recomendada; sem
observação), registrada na fatia 76u (RESPOSTAS-294-A-300).** Quatro linhas de `funcionalidades` renomeadas na produção, no passo 7
da etapa 1 (com a troca do código; `docs/procedimento-de-corte-etapa-1.md`), e ninguém perde permissão: os privilégios ligam o grupo
e a pessoa à funcionalidade pelo id. Os ids, lidos na cópia só por SELECT (`p76u-01-permissoes-indice-*` no rascunho da sessão,
iguais nas duas): **49** "Relatórios de aniversariante" → "Relatórios de datas da equipe"; **40** "Relatórios dashboard
colaboradores" → "Relatórios do profissional parceiro"; **15** "Relatórios de clientes" → "Relatórios da carteira de clientes";
**17** "Relatórios de vendas" → "Relatórios da cota-parte" (com 2, 3, 2 e 6 linhas de molde de grupo e 181, 949, 181 e 1.052 de
pessoa). Nenhum dos quatro nomes novos existe nas cópias. O `postgres/schema.sql` da etapa 2 declara o nome único
(`funcionalidades_nome_unico`, `:1943`), mas a cópia local do PostgreSQL não tem a restrição (só a `funcionalidades_pkey`;
`p76u-10-nome-unico-pg.*` no rascunho, só SELECT no catálogo -- a diferença é anterior a esta fatia), e o MySQL não tem índice no
nome: nenhum banco local recusa o nome repetido. A "Relatórios dashboard colaboradores - plano carreira" não muda: o `UPDATE` é pelo id e pelo nome antigo.
**O que lê o nome, e por isso muda junto:** o `SessaoAcl::setAcl` pergunta ao `Laminas\Permissions\Acl` pelo nome literal das
quatro (`module/ISConfiguracao/src/Permissions/SessaoAcl.php:120`, `:122`, `:131` e `:135`), e o `Acl` só conhece os nomes que
estão na tabela (`Acl::carregarFuncionalidades`): perguntado por um nome que não existe, o `isAllowed` LANÇA (o "Resource ... not
found" do `laminas-permissions-acl`), e o `setAcl` roda no login de todos, o administrador inclusive. Renomear a linha sem o
código, ou trazer o código sem a linha, derruba o login de todo mundo -- e o código antigo, o de produção hoje, pergunta pelos
mesmos quatro nomes antigos (as mesmas linhas no `eaf1fca`). Por isso o desenho (a **DE3** do
`docs/f3-relatorios/spec-datas-da-equipe.md`): o código novo pergunta pelo nome novo e, sem ele, pelo antigo, para a ordem entre o
deploy e o `UPDATE` não importar; a volta da etapa 1 devolve os quatro nomes antes de o código antigo servir; as fixtures dos testes
(as linhas 90002023, 90002025, 90002034 e 90002039 do `tests/Caracterizacao/fixtures.sql`) e o
`scripts/ensaiar-links-consertados-do-erp.php` (que acha "Relatórios de vendas" pelo nome) mudam junto, e também o
`tests/Caracterizacao/PrivilegioDaTelaNovaTest.php` (que procura "Relatórios de vendas" pelo nome no banco, `:52` e `:122-126`: com as
fixtures renomeadas e ele sem mudar, fica vermelho), o comentário do `scripts/ensaiar-permissao-adm-api.php:520` e o texto de carga
dos `.teste.tsx` (`UsuariosLigado.teste.tsx`, `privilegioDaTela.teste.ts`) -- a primeira versão deste parágrafo dizia "nenhum outro
leitor pelo nome" e deixava os três de fora; o desenho da DE3 já os citava. Fora esses, nenhum: as listas do `RegraDoPrivilegio`
(`DE_REDE`, `FORA_DE_USO`, `NAO_GUARDAM`) não têm nenhuma das quatro, e as telas de grupos e de privilégios mostram o `getNome()`
como ele estiver. **As fixtures entram também por cima da cópia** (o `scripts/ensaiar-corte.sh` nos clones, o `scripts/setup-dev.sh`
no `franquiabv_dev`) e cedem só pelo nome: trocadas só no nome, elas inseririam os quatro nomes novos ao lado dos antigos, os
gerentes do clone perderiam as quatro telas (os privilégios ligam ao id do nome antigo), e o `UPDATE` do ensaio criaria o nome
repetido que derruba o login (o incidente de 15/09 do comentário do `fixtures.sql`). Por isso a DE3 (depois da verificação da 76u):
as quatro fixtures cedem também pelo nome antigo, e o roteiro do corte para sem mudar nada se o nome novo já existir em outro id. Nada mudou no código nem na produção. **EM ABERTO: a DE3 e o passo 7 da
etapa 1** (PREPARADO quando a DE3 estiver commitada, com o roteiro; CORRIGIDO depois do corte, a marca nova no fim do título).

**PREPARADO em 04/11/2026 (a fatia 76n, DATAS-DA-EQUIPE-DE3; a DE3 do `docs/f3-relatorios/spec-datas-da-equipe.md`).** O código e o
roteiro estão prontos, e nada muda hoje para quem usa: as cópias e a produção seguem com os nomes antigos, e o login segue por eles.
O `SessaoAcl::salvarAcessoPeloNomeNovo` pergunta ao `Acl` pelo nome novo se a tabela o tem, e senão pelo antigo (sem nenhum dos dois,
lança, como hoje). O passo 7 da etapa 1 roda o `scripts/renomear-permissoes-das-telas-novas.sql` (os quatro pares numa tabela
temporária, um `UPDATE` pelo id e pelo nome inteiro, o `MUDOU`, as contagens de moldes e de pessoas antes e depois, e duas paradas
que saem com 1 sem mudar nada: o nome novo já em outro id, conferido antes, e o id que não fica com o nome novo, conferido dentro da
transação); a volta, o `scripts/devolver-nomes-das-permissoes-das-telas-novas.sql`. As quatro fixtures levam o nome novo e cedem
também pelo antigo (`nome_antigo`): por cima do catálogo antigo, nenhuma entra. Provado: o
`tests/Caracterizacao/PermissoesComONomeDaTelaTest.php` (6 casos, nos dois bancos) e o `scripts/ensaiar-permissoes-com-o-nome-da-tela.php`
(no `conferir-tudo.sh`: a ida e a volta duas vezes, dez paradas, a "plano carreira" intacta, e as fixtures por cima do catálogo antigo e
do vazio nos dois bancos), com os mutantes da DE3 vermelhos (a tabela no `docs/roadmap.md`, 04/11/2026). **Falta:** o `UPDATE` na
produção, no passo 7, e o ensaio num clone da cópia (o item 11 da etapa 1) -- CORRIGIDO depois do corte. O ajudante sai numa entrega
depois de o dono declarar o fim da volta.

**A verificação da 76n (04/11/2026)** consertou o procedimento: a volta chama o roteiro inverso pelo caminho da árvore nova
(`/var/www/franquiabv.com.br/public_html.novo-<data>/scripts/...`: depois da troca das árvores, a antiga no lugar não o tem, e o
`mysql` nem rodaria), o passo 7 lê o `PIPESTATUS[0]` do `mysql` e não o `$?` do `tee`, e o texto diz o que o roteiro para e o que
só mostra. O ensaio passou a rodar os roteiros também com o cliente em latin1 (o `SET NAMES utf8mb4` com caso), e a regra do
`medir-funcionalidades-orfas.php` passou a `scripts/regras-das-funcionalidades-lidas.php`, com caso: o
`PermissoesComONomeDaTelaTest` tem 9 casos. A marca não muda: falta o mesmo.

### D702 — A conta do menu Relatórios do ERP antigo é uma lista curta: seis itens do grupo pedem privilégio que não está nela — REGISTRADO em 04/11/2026 — CORRIGIDO em 07/11/2026: os cinco itens de fora entraram na conta do grupo, cada um com a condição do item

**Achado pela verificação da fatia 76h (DATAS-DA-EQUIPE-DE2).** O `SessaoAcl::menuAcesso('relatorios')` abre o grupo no ERP antigo
por nove privilégios: caixa, clientes, franquias, vendas, vendasCanceladas, vendasProdutos, descontos, dashboardFaturamentos e
indicacoes. Dentro do grupo, no `layout.phtml`, seis itens pedem outro: o Atendimentos (`atendimentos`), o NPS (`nps`), a Retirada de
Produtos (`retiradasDeProdutos`), a Evasão (`evasao`), o Consultor Visagista (`consultorVisagista`) e o VIP Class
(`vipclass/relatorio`, de outra funcionalidade). Quem tem só privilégios desses seis não vê o grupo, e com ele os itens a que tem
direito. Até a DE2, o "aniversariantes" na conta tapava o buraco para quem tinha o das Datas; a DE2 o tirou (abriria vazio para
quem só tinha ele), e o buraco passou a valer também para esses.

**Medido na cópia (04/11/2026, só SELECT; `medir-p76h-menu-curto.sql` no rascunho da sessão):** dos usuários ativos fora dos grupos
administradores, 108 têm o das Datas, e todos têm um dos nove; e nenhum tem um dos seis sem um dos nove. Hoje ninguém perde item.

**O caso que dizia o efeito (até 07/11/2026):** `AniversariantesSaiuTest::testSemOAniversariantesNaContaOMenuEscondeTambemOItemDeForaDaLista`
(com o das Datas e um dos cinco de Relatórios de fora, o menu não abre; com um dos nove, abre). **O conserto** é a conta derivar
dos itens do grupo -- os seis entrando nela, e o VIP Class pela funcionalidade dele --, com o cuidado do Dash. Colaboradores: três
deles (o Atendimentos, o NPS e a Retirada) passam também pelo `veOMenu`, e a conta abriria o grupo vazio para quem a política
recusa (como já abre hoje pelo caixa e pelas vendas). O caso acima muda junto. Nada mudou no código além do comentário do
`SessaoAcl` e do caso.

**A saída do Potencial de Franquia (04/11/2026, a fatia 76k, a CR2) não muda a lista.** O `franquias` fica nela: é o privilégio dos
Royalties, que continuam no ERP antigo. O que já era verdade desde a CR0 (o Potencial pelo `veOMenu`) e a fatia 54 (os Royalties
pelo `veOMenu`) continua: quem não é administrador e tem só o `franquias` entre os nove abre o grupo Relatórios sem item nenhum. Na
cópia, nenhum não administrador ativo tem o `franquias` (contado na CR0, só com SELECT); a lista e o caso do D702 não mudaram.

**A saída da Evasão (04/11/2026, a fatia 76d, a CA3 da Carteira) tira um dos seis.** O item da Evasão saiu do `layout.phtml`,
e com ele o `evasao` da conta dos itens que pedem privilégio fora da lista: são cinco agora (o Atendimentos, o NPS, a Retirada de
Produtos, o Consultor Visagista e o VIP Class). A lista dos nove não mudou; o caso do D702
(`AniversariantesSaiuTest::testSemOAniversariantesNaContaOMenuEscondeTambemOItemDeForaDaLista`) e o comentário do `SessaoAcl`
mudaram junto (o `evasao` saiu do laço).

**A saída do Clientes antigo (04/11/2026, a fatia 76e, a CA4 da Carteira) tira um dos nove da lista.** O item "Clientes" saiu do
`layout.phtml`, e nenhum outro item do grupo pede o privilégio `clientes`: ele saiu do `menuAcesso('relatorios')`, que ficou com oito
(caixa, franquias, vendas, vendasCanceladas, vendasProdutos, descontos, dashboardFaturamentos e indicacoes) -- com ele, quem só o
tivesse abriria o grupo vazio. **Medido na cópia (04/11/2026, só SELECT, iguais nos dois bancos; `p76e-01-menu-sem-clientes.sql` no
rascunho da sessão):** dos usuários ativos fora dos grupos administradores, 108 têm o privilégio do Clientes, e todos têm um dos outros
oito; nenhum perde o grupo, nem com um dos cinco de fora. O caso do D702 e o comentário do `SessaoAcl` dizem oito; o
`ClientesAntigoSaiuTest::testOMenuRelatoriosNaoAbreNemAcendePeloClientes` cobra que o `clientes` sozinho não abre o grupo.

#### CORRIGIDO em 07/11/2026 -- a conta do grupo Relatórios ganhou os cinco itens de fora (a fatia 109, SESSAO-LE-O-BANCO)

O `SessaoAcl::menuAcesso('relatorios')` abre o grupo também pelo Atendimentos, pelo NPS, pela Retirada de Produtos, pelo Consultor
Visagista e pelo VIP Class (`vipclass/relatorio`), cada um com a MESMA condição do item no `layout.phtml`: os três que o item
guarda pelo `RecorteDoRelatorio::veOMenu` (Atendimentos, NPS, Retirada) entram pelo `veOMenu`, e o grupo não abre vazio para quem a
política recusa -- o colaborador abre o grupo pelo Atendimentos (o dele) e não pelo NPS nem pela Retirada. Os oito de antes ficaram
como estavam: quem a política recusa no caixa e nas vendas continua abrindo o grupo sem esses itens (como antes; não é deste débito).
Na cópia ninguém estava no buraco (a medição de 04/11), e ninguém perde nada.

**Provado** no `AniversariantesSaiuTest::testOsCincoItensDeForaAbremOMenuComACondicaoDoItem`, que tomou o lugar do caso que dizia o
efeito antigo, nos dois bancos; o `ClientesAntigoSaiuTest` e o `MenuDosRelatoriosSemAsTelasQueSairamTest` seguem verdes. Mutantes
R1 a R3, vermelhos e devolvidos: o NPS sem o `veOMenu`, o VIP Class fora da conta, o Atendimentos fora da conta.

### D703 — A ocupação da rede na Capacidade × realizado fica em branco quando falta o horário gravado de uma pessoa só — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (299 b), EM ABERTO: a CR3 — CORRIGIDO em 04/11/2026: a CR3 (a fatia 76l)

**Achado na fatia 76i (CAPACIDADE-CR0, `docs/f3-relatorios/spec-capacidade.md`).** O desenho manda a linha da rede somar cada
régua à parte e "deixar a taxa nula se faltar cobertura a alguém", e a fatia o fez (`ContasDaCapacidade::rede`): a pessoa sem a
vigência do horário nem a linha do Dash (`semCobertura`), ou aquela de quem não se sabe se tinha agenda
(`semCoberturaSemAgendamento`), tira a taxa da rede nas duas réguas; a de cada unidade continua, com a contagem ao lado, como no
total do Profissional Parceiro. **Medido na cópia, em processo, nas duas, a rede inteira:** julho de 2026, 3 sem cobertura e 5 de
quem não se sabe (de 148 com agenda) -- a taxa da rede nula; agosto, 1 e 1 -- nula; de setembro em diante, ninguém -- a taxa
aparece. Nos meses de antes da gravação do horário, pela mesma conta, a rede fica sem a porcentagem. **A escolha é do dono** (a
pergunta no lote, "Na Capacidade × realizado, a ocupação da rede some quando falta o horário gravado de uma só pessoa?", D703): (a)
fica como o desenho; (b) a rede mostra a taxa da soma com a contagem ao lado, como cada unidade. O código segue o desenho (a (a))
até a resposta; o caminho para a (b) é tirar a condição da `rede` (o mutante M9 da fatia) e o caso que a prova
(`CapacidadeDoRelatorioTest::testATaxaDaRedeSemCoberturaENula`) muda junto. A tela é a CR1: ela desenha a taxa nula como "--".

**A tela (04/11/2026, a fatia 76j, a CR1):** desenha a taxa nula da rede como o travessão, com o porquê no título ("falta o horário
da época de alguém na rede"), e diz a cobertura numa frase no cartão do mês ("A ocupação da rede fica sem número: N pessoas estão
sem o horário da época, e de M outras não se sabe se tinham agenda...") e nas marcas de cada unidade. Com a (b), a frase sai junto
com a condição (o caso `a cobertura é dita` do `Capacidade.teste.tsx` muda).

**A resposta 299 (b) do dono (04/11/2026, "A rede mostra a ocupação com o aviso"; a recomendada; sem observação),
registrada na fatia 76u (RESPOSTAS-294-A-300).** A linha da rede passa a ter a ocupação da soma das unidades, cada régua à parte,
com a frase "N pessoas sem o horário gravado ficaram fora da conta" ao lado (no singular, "1 pessoa sem o horário gravado ficou fora
da conta"), como cada unidade já faz; o N é o `semCobertura` mais o `naoSeSabe` da rede. **A frase vai NA LINHA DA REDE, e não no
cartão do mês** (a verificação da 76u: a primeira versão a deixava no cartão, longe do número que ela explica): no lugar das duas
marcas da linha de "Todas as unidades", como as marcas de cada unidade; e as marcas das unidades passam ao termo da letra ("sem o
horário gravado", e não "sem o horário da época"), para a mesma tabela não ter dois nomes para a mesma coisa. Desenhada na fatia nova **CR3** do `docs/f3-relatorios/spec-capacidade.md`: sai a condição da cobertura do
`ContasDaCapacidade::rede` (o mutante M9 da 76i, agora o conserto), e a `fraseDaCoberturaDaRede` do front troca a frase "A
ocupação da rede fica sem número..." pela da letra. **Medido em processo, na cópia do MySQL, só leitura** (`p76u-04-capacidade.php`
e `p76u-04-capacidade-mysql.txt` no rascunho da sessão; o PostgreSQL não, porque o roteiro trocaria o `local.php`): julho de 2026, 8
pessoas fora (3 e 5), a ocupação da rede 41,37% na régua nova (10.657 de 25.761) e 42,33% na antiga; agosto, 2 (1 e 1), 21,51% e
22,36%; março de 2025, 18 (8 e 10), 60,05% na nova (251 de 418) e 43,93% na antiga (os números da CR3 sozinha; a CR4, a do D704,
muda março de 2025 outra vez pelas desativadas, e o quanto depende do D705). Nada mudou no código. **EM ABERTO: a CR3**
(CORRIGIDO nela, a marca nova no fim do título).

**Feito na CR3 (04/11/2026, a fatia 76l, CAPACIDADE-CR3).** A `ContasDaCapacidade::rede` refaz a taxa de cada régua da rede pela
soma, sem olhar a cobertura (a sem base continua nula), e a `cobertura` da rede fica na resposta como estava. Na tela, a linha da
rede mostra a porcentagem e, no lugar das duas marcas, uma só, a da letra ("8 pessoas sem o horário gravado ficaram fora da conta";
"1 pessoa sem o horário gravado ficou fora da conta"; nenhuma com a cobertura completa); a frase longa do cartão do mês e o
travessão "falta o horário da época de alguém na rede" saíram; as marcas das unidades e o glossário (`capacidade.cobertura`)
passaram ao termo da letra, "sem o horário gravado". O caso `CapacidadeDoRelatorioTest::testATaxaDaRedeSemCoberturaENula` mudou de
sentido e de nome (`testATaxaDaRedeSemCoberturaEADaSoma`: a taxa da soma nas duas réguas, a mesma da rede coberta), e o mutante M9
da 76i é agora o conserto. **Medido em processo, nas duas cópias** (`p76l-medir-capacidade.php` no rascunho, só leitura; o
PostgreSQL pelo `p76i-no-pg.sh`), a resposta com uma casa: julho de 2026, 41,4% na régua nova e 42,3% na antiga, 8 fora da conta;
agosto, 21,5% e 22,4%, 2; março de 2025, 60,0% e 43,9%, 18; março de 2024, a nova sem base (nula) e 44,4% na antiga, 18 -- iguais
nos dois bancos e à tabela da CR3 (com duas casas lá). O oráculo da 76i com a taxa da rede pela soma (`p76l-oraculo.py`): 183
conferências em cada mês (julho e agosto de 2026, março de 2025) e em cada cópia, 0 divergências; com a regra antiga
(`--falsificar-cobertura`), 1 em cada. CORRIGIDO. O `rede.foraDasLinhas` e a frase das unidades hoje fechadas que venderam ficam:
são da CR4 (o D704, que espera o D705).

### D704 — A Capacidade × realizado deixa de fora, também nos meses em que trabalharam, as unidades que hoje estão inativas — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (300 b), EM ABERTO: a CR4 — CORRIGIDO em 04/11/2026: a CR4

**Achado na verificação da fatia 76i (CAPACIDADE-CR0, `docs/f3-relatorios/spec-capacidade.md`).** As linhas do relatório são as
unidades ativas de HOJE (`CapacidadeController::montar`: a `royaltiesUnidades` sem movimento e a `listaDoFiltro` da base, a
`popularCombobox`), como o Potencial antigo, e não as que trabalharam no mês pedido: a unidade fechada depois some também do
histórico. Os itens dela são lidos (a rede lê o painel inteiro) e não entram em linha nenhuma nem na soma da rede, e pedida pelo id
(`?unidade=`) ela é o 422. O desenho diz "uma linha por unidade ativa" sem dizer se é ativa hoje ou no mês; o molde dos Royalties
mostra a desativada que trabalhou no ciclo (o D367, a resposta 132 do dono), mas a fatia não cita a 132. **Medido na cópia do
MySQL, só com SELECT** (vendas concluídas pela unidade de quem fechou, a do painel): de 2023 a dezembro de 2025, todo mês tem
unidade hoje inativa com venda -- até 10 por mês em 2023, 7 em 2024, 6 em março de 2025 (as 2, 16, 18, 31, 33 e 49: 1.537 das
21.700 vendas concluídas da rede, cerca de 7%), 1 em dezembro de 2025; em 2024-03, 2.660 de 23.145 (cerca de 11%). Em julho de 2026,
o mês do oráculo da fatia, nenhuma: as 183 conferências não podiam vê-lo. **O que a correção fez, sem decidir:** a linha da rede
APONTA as unidades que venderam no mês e não são linha (`rede.foraDasLinhas`, os ids em ordem; em março de 2025, na cópia, as seis
acima; em julho de 2026, nenhuma), para a CR1 dizer que a rede do mês está sem elas -- o realizado delas continua fora da soma.
**A escolha é do dono** (a pergunta no lote, "Na Capacidade × realizado, a unidade que hoje está inativa aparece nos meses em que
trabalhou?", D704): (a) como está, só as ativas de hoje, com as que venderam apontadas; (b) como os Royalties: a inativa que vendeu
no mês vira linha, marcada "desativada", e entra na rede, e o filtro do administrador a oferece; (c) a (b), e também a inativa só com
agenda no mês (julho de 2026: 35 pessoas, 16.757 horários, nenhum ocupado). O código segue o desenho (a (a)) até a resposta; o caminho
para a (b) é o `unidadesDoFiltro` e a segunda leitura das unidades do `RoyaltiesController`, e os casos
`CapacidadeDoRelatorioTest::testNaRedeAsAtivasECadaReguaAParte` e `testAUnidadeDaSessaoEAsRecusadas` (W, a inativa do cenário, com a
venda S7) mudam junto.

**A tela (04/11/2026, a fatia 76j, a CR1):** segue a (a) -- com o `rede.foraDasLinhas` não vazio, o cartão do mês diz "N unidades
hoje fechadas venderam neste mês e não estão na conta." (a frase do desenho; no oráculo da fatia, março de 2025 no contrato, a W do
cenário). Com a (b) ou a (c), a frase sai e a linha "desativada" entra na tabela (o caso `as unidades hoje fechadas...` do
`Capacidade.teste.tsx` muda).

**A resposta 300 (b) do dono (04/11/2026, "Como nos Royalties"; a recomendada; sem observação), registrada na fatia 76u
(RESPOSTAS-294-A-300).** A unidade hoje desativada que vendeu no mês vira linha, marcada "desativada", e entra na soma da rede (a
agenda, o realizado e a cobertura); o filtro do administrador a oferece, com " (desativada)" no nome, como o
`RoyaltiesController::unidadesDoFiltro`. A desativada que só tinha agenda e não vendeu NÃO entra (a (c), não escolhida). Desenhada
na fatia nova **CR3** do `docs/f3-relatorios/spec-capacidade.md`: o `rede.foraDasLinhas` e a `fraseDasFechadas` saem (toda desativada
que vendeu passa a ser linha), a linha ganha `unidade.ativa`, e os casos do W no `CapacidadeDoRelatorioTest` e no
`Capacidade.teste.tsx` mudam junto. **Medido em processo, na cópia do MySQL, só leitura** (`p76u-04-capacidade.php`, o filtro do
rascunho com as desativadas, pedida cada uma pelo id; e `p76u-07-filtro-*` nas duas cópias): em julho e agosto de 2026, nenhuma muda
nada; em março de 2025, as 6 entram -- 175 pessoas com agenda em vez de 155, 21.655 atendimentos em vez de 20.118 e R$ 1.853.074,16
em vez de R$ 1.725.208,36 --; em março de 2024, as 7 (23.121 atendimentos em vez de 20.463). As 28 unidades inativas da cópia têm
alguma venda concluída com item, e o filtro do administrador passa de 36 a 64 unidades. **A agenda da desativada na rede foi ao
dono (o D705, na verificação da 76u):** a agenda da desativada é a de quem está HOJE no cadastro dela, como em toda linha; a 49, em
março de 2025, tem 1.624 horários na régua nova e nenhum ocupado (com 191 atendimentos no mês), e com ela a ocupação da régua nova
da rede cai de 60,05% para 12,29%. A primeira versão o escrevia como "efeito, sem decidir de novo"; é o efeito que a recomendação da
300 usou para descartar a (c), e por isso virou pergunta. A 300 (b) passou da CR3 para a fatia nova **CR4**, que espera a resposta
do D705; a CR3 fica só com a 299 (b). Nada mudou no código. **EM ABERTO: a CR4** (CORRIGIDO nela, a marca nova no fim do título).

**CORRIGIDO em 04/11/2026, na CR4 (a fatia 76m, CAPACIDADE-CR4; a 300 (b) e, para a agenda, a 301 (c) do D705).** Na rede, as
linhas são as ativas de hoje e as desativadas com venda concluída com item no mês, marcadas (`unidade.ativa` falso), na soma da rede
(as pessoas, a cobertura e o realizado; a agenda pelo D705); a desativada só com agenda no mês não é linha. O `rede.foraDasLinhas`
saiu da resposta e a `fraseDasFechadas` da tela. O filtro do administrador (`CapacidadeController::unidadesDoFiltro`, sobrescrito
como o dos Royalties) soma às ativas as desativadas com alguma venda concluída com item (`UnidadeRepository::
capacidadeDesativadasComVenda`, com " (desativada)"), e a escolhida vem como linha também num mês sem venda, com a frase do cartão
("Esta unidade está desativada e não vendeu neste mês: a rede não a conta."). **Onde se afastou do desenho, medido:** (1) as linhas
da rede são lidas UMA vez, antes do pico, com os ids da lista do filtro (as ativas e as desativadas que venderam alguma vez), e a
desativada sem item no mês sai depois dos itens -- a segunda leitura dos Royalties, com `IN (:ids)`, é outra DQL, e lida depois do
pico caía no `OutOfSpaceException` do cache de consultas com `memory_limit = 128M` (março de 2025, na cópia do MySQL); (2) a consulta
do filtro tem o `EXISTS` no `SELECT` (`CASE WHEN EXISTS ... AS vendeu`), e não no `WHERE`: no `WHERE`, o MySQL 8.4 o faz semi-join com
"weedout" e levava 2,77 a 2,85 s em todo pedido da Capacidade (a lista do filtro é a guarda da unidade); no `SELECT`, 2 a 13 ms, as
mesmas 28 (remedida na correção, 2 a 4 ms). As duas condições do `EXISTS` (a venda concluída e o item) têm caso desde a correção:
Q, só com venda cancelada e comanda aberta, e T, só com venda concluída sem item, ficam fora do filtro e são o 422. **Medido em processo nas duas cópias** (`p76m-medir-capacidade.php`, `memory_limit = 128M`, três rodadas): março de 2025,
42 linhas (as desativadas 2, 16, 18, 31, 33 e 49), 175 pessoas, 21.655 atendimentos, R$ 1.853.074,16; março de 2024, 43 linhas
(as 2, 11, 16, 18, 31, 33 e 49), 181 pessoas, 23.121 atendimentos, R$ 1.777.258,05 -- a tabela do desenho, iguais nos dois bancos; o
filtro com 64 unidades (28 desativadas). A rede quente em 0,52 a 0,59 s no MySQL e 0,68 a 0,75 s no PostgreSQL; o pico de um
pedido, 118 a 122 MB, cada mês num processo novo (na medida dos três meses num processo só, 124 MB: o acumulado do processo; a nota
no D631). O oráculo por SELECT, unidade a unidade com as desativadas, e o pelo HTTP no
contrato MySQL, no D705.

### D705 — Na Capacidade × realizado, a unidade desativada que vendeu traz para a ocupação da rede a agenda vazia de quem ficou no cadastro dela — REGISTRADO em 04/11/2026 — CORRIGIDO em 04/11/2026 (301 c): a CR4

**Achado na verificação da fatia 76u (RESPOSTAS-294-A-300, `docs/f3-relatorios/spec-capacidade.md`, a CR4).** Pela resposta 300 (b)
do dono (o D704), a unidade hoje desativada que vendeu no mês vira linha e entra na soma da rede. A agenda de cada linha é a de quem
está HOJE no cadastro da unidade (a régua da F4, que a CR0 segue para todas). Numa desativada, quem ficou no cadastro costuma ser
quem não foi para outra unidade, com a agenda aberta e sem atendimento. **Medido em processo na cópia do MySQL, só leitura**
(`p76u-09-agenda-da-desativada.php` e `-mysql.txt` no rascunho da sessão; só ids de unidade e números): em março de 2025, a 49 teve
191 atendimentos, feitos por quem hoje está no cadastro de outra unidade, e as 4 pessoas do cadastro dela têm 1.624 horários na
régua nova e 608 na antiga, nenhum ocupado; com ela, a ocupação da régua nova da rede cai de 60,05% (251 de 418) para 12,29% (251
de 2.042), e a da antiga de 43,93% para 42,46%. Em março de 2024, a régua nova da rede passa de "sem base" a 0,00% pelos 432
horários vazios da mesma 49. As outras cinco desativadas de março de 2025 (as 2, 16, 18, 31 e 33) têm horário ocupado na régua
antiga. A recomendação da pergunta 300 descartou a (c) dela por encher a rede "de horários que ninguém usou, das pessoas que ficaram
cadastradas na unidade fechada", e a pergunta não avisava que a (b) também traz esses horários das fechadas que venderam. **A escolha
é do dono** (a pergunta no lote, "Na Capacidade × realizado, a unidade fechada que vendeu entra na ocupação da rede com a agenda de
quem ficou no cadastro dela?", D705): (a) inteira, como a letra da 300 (b) -- março de 2025, 12,29% e 42,46%; (b) a desativada só
com o realizado na rede -- 60,05% e 43,93%, e a agenda real das outras cinco também fora; (c) a agenda da desativada na rede só nas
réguas em que ela teve algum horário ocupado no mês -- 60,05% e 42,85%. Recomendei a (c). A CR4 espera a resposta; a CR3 (a 299 (b))
não. Nada mudou no código.

**A resposta 301 (c) do dono (04/11/2026, a recomendada, sem observação):** *"A agenda da fechada entra na rede só onde ela teve algum
horário ocupado no mês: a linha dela mostra tudo, e a rede deixa de fora só a agenda vazia; a tela diz, na linha da fechada, que
aquela agenda ficou fora da rede."* **CORRIGIDO em 04/11/2026, na CR4 (a fatia 76m, CAPACIDADE-CR4).** `ContasDaCapacidade::
daUnidade` marca, na linha da desativada, cada régua sem nenhum horário ocupado (`agendaForaDaRede`, régua a régua e nunca a unidade
inteira: a que teve ocupação só na antiga entra só com a antiga), e a `::rede` soma a agenda sem essas réguas (o `gravadoDesde` da
antiga de fora também fica fora); as pessoas, a cobertura e o realizado da desativada entram sempre. A linha mostra a agenda inteira,
e a tela diz na marca dela, régua a régua, no plural e no singular: "1.624 horários sem ocupação ficaram fora da rede", "na régua
antiga, 608 horários sem ocupação ficaram fora da rede" ("1 horário sem ocupação ficou fora da rede"); o glossário
(`capacidade.rede`) diz a regra. **Medido em processo nas duas cópias:** março de 2025, a régua nova da rede 60,0% (251 de 418) e
a antiga 42,8% (28.697 de 66.976), com a 49 fora nas duas réguas; março de 2024, a nova sem base e a antiga 42,4% (30.215 de
71.322), com a 49 fora nas duas e a régua nova da 33 (0 horários) fora -- a coluna (c) da tabela acima, iguais nos dois bancos. A
frase da linha da rede da CR3 passa a somar a cobertura das desativadas: março de 2025, 23 fora da conta (9 + 14), e não 18; março
de 2024, 26 (5 + 21). **O oráculo** (`p76m-oraculo.py`, a régua da F4 escrita de novo e as linhas e o realizado da desativada por
SELECT; o realizado das ativas pelo painel), unidade a unidade: março de 2025, 258 conferências em cada cópia, e março de 2024, 264,
0 divergências; com a agenda inteira da desativada na rede (`--falsificar-d705`, a (a)), 1 em cada. **Pelo HTTP no contrato MySQL**,
como o gerente de teste com o grupo administrador (só no contrato, remontado depois), com o `CenarioDaCapacidade` semeado: W e Y como
linhas desativadas, a agenda de W (um ocupado) na rede e a de Y (nenhum) fora, X (só com agenda no mês) e Z (nunca vendeu) fora, a
soma de cada régua, W em abril e X em março como linha com zero atendimento, Z com o 422, o filtro com W, X e Y "(desativada)" e
sem Z: 32 conferências, 0 divergências; falsificado (a (a)), 1.

### D706 — A Carteira da rede com uma faixa grande: o PDF e a planilha de 150 mil clientes não cabem na memória nem saem em disco num tempo de espera — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (302 a) — CORRIGIDO em 04/11/2026: a CA6

**Achado na verificação da fatia 76u (RESPOSTAS-294-A-300, `docs/f3-relatorios/spec-carteira.md`, a X3).** A resposta 297 (a) do
dono ao D700 diz que, na rede inteira, o PDF e a planilha "pedem uma unidade, uma faixa ou um filtro" -- uma faixa basta. A faixa de
mais de 2 anos da rede tem 149.951 clientes na cópia (as outras faixas da rede ficam abaixo de 28.200). **Medido num documento
SINTÉTICO de 23 colunas, nenhum dado da cópia** (`p76u-05-medir-arquivo.*` e `p76u-08-medir-arquivo-disco.*` no rascunho da sessão):
na memória, 50.000 linhas levam 914 MB de pico na planilha (15,4 s) e 668 MB no PDF (6,7 s), e 150 mil passariam de 2,5 GB pela
mesma conta (não medida); com as células da planilha em disco (um PSR-16 em arquivos no `Settings::setCache`), 50.000 linhas cabem
em 402 MB mas levam 148 s, e 149.951 não terminaram em 580 s. O PDF não tem como montar em disco. O Clientes antigo de hoje (19
colunas, 1 GB) não foi medido com essa lista. A primeira versão do desenho fixava um teto técnico na rede, acima de cerca de 40.000,
como se a letra o deixasse; ele restringe o que a letra libera. **A escolha é do dono** (a pergunta no lote, "Na carteira da rede
inteira com uma faixa, o PDF e a planilha de 150 mil clientes: um limite, ou tentar sempre?", D706): (a) um teto na rede, acima dele
o 422 pedindo mais um filtro ou uma unidade, o TXT inteiro; (b) a planilha sem teto, montada em disco, o PDF com o teto; (c) sem teto
nenhum. Recomendei a (a). Até a resposta, a CA6 segue a (a) -- nada se perde, porque o Clientes antigo exporta como hoje até a CA4 --,
e a CA4 espera a resposta. Nada mudou no código.

**A resposta 302 (a) do dono (04/11/2026, "Um limite na rede"; a recomendada; sem observação).** Acima de cerca de
40.000 clientes, pela medida, e nunca abaixo da maior unidade inteira, o PDF e a planilha da rede pedem mais um filtro ou uma
unidade; o TXT sai inteiro.

**CORRIGIDO em 04/11/2026 (a fatia 76e2 da fila das telas que faltam, CARTEIRA-CA6; a CA6 e a 1.10 de `docs/f3-relatorios/spec-carteira.md`).** O teto é `CarteiraController::TETO_DO_ARQUIVO_NA_REDE = 40000`, conferido pela primeira linha
da lista (o `COUNT(*) OVER ()`) antes de as outras virarem array do PHP; acima dele, o 422 "A lista passa de 40.000 clientes: o TXT leva todos; para o
PDF ou a planilha, escolha uma unidade ou mais um filtro.", e a lista ecoa a mesma frase, para a tela oferecer só o TXT. Na unidade
o teto não vale. **A medida, em processo, sem o teto (só para medir), MySQL:** a faixa de 1 a 2 anos da rede (28.188) -- a planilha
em 10,6 s e 497 MB (no PostgreSQL, 9,7 s e 499 MB), o PDF em 3,6 s e 130 MB; 37.583 linhas (mais de 2 anos e até R$ 50) -- 11,1 s e
599 MB, e 2,0 s e 164 MB; 48.286 -- 14,5 s e 727 MB, e 2,5 s e 208 MB; 56.484 (de R$ 100 a R$ 250) -- a planilha em 17,9 s e 934 MB,
no limite do 1 GB. A densidade pior medida, 17,6 KB por linha (a faixa de 1 a 2 anos), põe 40.000 linhas perto de 700 MB: cerca de
70% do limite, a folga que o desenho pedia. Os 40.000 ficam acima da maior unidade inteira (22.321; o caso
`testOTetoNuncaFicaAbaixoDaMaiorUnidade` compara com ela e com a maior do banco do caso). Na cópia, de toda faixa sozinha só a de mais
de 2 anos da rede (149.951) passa dele: o 422 em 5,1 s e 3,2 s. O TXT da rede inteira: 239.236 linhas (os 239.239 ativos menos os 3
sem telefone, contados só por SELECT), 11,4 a 11,7 s e 71,7 MB no MySQL, 6,4 s e 37 MB no PostgreSQL, com 128 MB. A CA4 (a saída do
Clientes antigo) deixa de esperar.

**A correção da verificação (04/11/2026, a fatia 76e2).** (1) **Um filtro sem faixa também passa do teto na rede**, e não só a
faixa de mais de 2 anos: na medida da CA6, o consumo de R$ 100 a R$ 250 da rede inteira, sem faixa, tem 56.484 clientes -- o
administrador que só filtra o consumo assim vê só o TXT, com a frase do teto. A pergunta ao dono dizia "só a faixa de mais de 2 anos
da rede, sem outro filtro, passa dele", o que vale para as faixas sozinhas; a letra da (a) ("acima de cerca de 40.000 [...] pedem
mais um filtro ou uma unidade") cobre os dois, e nada mudou no código. Escrito aqui, no desenho e no roadmap, para o dono saber. (2)
**"Antes de ler o resto" não é streaming do banco:** nada desliga o buffer do PDO (`USE_BUFFERED_QUERY`), e o `iterateAssociative`
percorre um resultado que o mysqlnd (ou o pdo_pgsql) já recebeu inteiro na execução; no mysqlnd ele conta no `memory_limit`. O que
o teto pela primeira linha poupa são os arrays do PHP (o caso conta: uma linha lida acima do teto); o 422 da faixa de mais de 2 anos
leva 5,1 s porque o banco monta e manda as 149.951 linhas antes, e os 71,7 MB do TXT da rede incluem o resultado bruto -- a folga do
TXT cresce com a rede, e não com a linha. Os comentários do código e o desenho passaram a dizer isso. (3) O colaborador conta como
filtro na rede (a frase "sem filtro" só sem ele): escolha da fatia, fora do lote, que nenhuma tela alcança (o retrato da rede não
tem colaborador; só o endereço escrito à mão), e a lista de um colaborador fica abaixo do teto.

### D707 — O cliente sem venda finalizada fica com o consumo nulo pela rotina e com zero pelo refazer do corte, e o filtro antigo trata os dois de um jeito diferente — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (303 a) — PREPARADO em 04/11/2026: a rotina grava zero; o nulo das cópias, no refazer do corte

**Achado na verificação da fatia 76o (CARTEIRA-CA7, `docs/f3-relatorios/spec-carteira.md`, a CA7; o D698).** A rotina da 01:10
grava no `clientes.consumo` a soma do `VendaRepository::selecionarConsumoPorCliente`, que é nula quando o cliente não tem nenhuma venda
finalizada ("o mesmo `setConsumo`" do desenho); o roteiro do corte (`scripts/refazer-consumo-dos-clientes.sql`) grava
`COALESCE(soma, 0)`, zero. O cliente cuja única venda finalizada foi cancelada fica com o nulo se a rotina o refaz (pelo
`cancelado_em`, desde a CA7) e com zero se o corte o refaz. O Clientes antigo e as Campanhas filtram por `cli.consumo <= :consumoFim`,
que deixa o nulo fora de "até R$ X" e põe o zero dentro; na Carteira o nulo conta como zero (a F4) e nada muda. Já era assim antes da
CA7: o cadastro novo nasce com zero (`Cliente::__construct`) e a rotina grava o nulo em quem veio sem finalizada -- **na cópia, só
SELECT de contagem** (`p76o-corr/08-zero-e-nulo-mysql.*` no rascunho da sessão), dos 13.134 clientes sem nenhuma finalizada, 8.649
estão com zero e 4.476 com o nulo (8.230 e 3.507 entre os ativos); a CA7 só leva a rotina a mais gente, e o corte leva nove clientes
sem finalizada a zero. **A escolha é do dono** (a pergunta no lote, "O cliente que não tem nenhuma compra fica com o consumo zero ou
em branco?", D707): (a) zero nos dois -- a rotina grava zero sem finalizada, como o cadastro novo e o roteiro; (b) em branco nos dois
-- o roteiro grava o nulo sem finalizada, como a rotina; (c) como está. Recomendei a (a). Até a resposta, os dois lados ficam como o
desenho os pôs, e o `ConsumoDoClienteTest::testAUnicaFinalizadaCanceladaDeixaONuloNaRotina` fixa o nulo da rotina. Nada mudou no
código por isto.

**A resposta 303 (a) do dono (04/11/2026), "Zero nos dois", e a fatia 76p (CARTEIRA-CA7-ZERO, `docs/f3-relatorios/spec-carteira.md`,
a CA7).** Quem não tem compra finalizada fica com o consumo zero, pela rotina da 01:10 e pelo refazer do corte, como o cadastro novo
nasce. O `VendaRepository::selecionarConsumoPorCliente` devolve `?? 0` (o nulo vinha dele, a soma vazia; quem chama é só a rotina), e o
`scripts/refazer-consumo-dos-clientes.sql` pega todo nulo no `WHERE` (`c.consumo IS NULL OR` a régua de 1 centavo), na contagem
(`nulos_ate_1_centavo`, que substitui o `nulos_sem_venda_que_ficam`: os nulos de soma das finalizadas até 1 centavo; `mudam` =
acima + abaixo + ele), na lista, no `UPDATE` e na
conferência. O filtro do Clientes antigo e das Campanhas (`ClienteRepository::filtrosListagemIndexRelatorio`, `cli.consumo <=
:consumoFim`) não tem `IS NOT NULL` de propósito e não muda: depois do código e do corte, quem nunca comprou ou teve a única compra
cancelada entra em "gastou até R$ X"; na Carteira nada muda. **Na cópia, só SELECT, igual no MySQL e no PostgreSQL**
(`p76p/03-mysql.*` e `p76p/04-pg.*` no rascunho da sessão): o roteiro muda **9.184** clientes (eram 4.707), dos quais **4.477 nulos
de quem nunca comprou** (3.508 ativos; nenhum dos dois sentinelas entre eles: o que tem o nulo tem venda, e vai à soma), 7.985 ativos ao todo; num "até R$ 100", 3.515 ativos com o nulo
passam a entrar, sobre os 97.798 de hoje. **Num clone descartável da cópia do MySQL** (`franquiabv_p76p_descartavel`, apagado no fim;
`p76p/30-oraculo.*`): 9.184 refeitos em cerca de 18 s, a conferência 0, a segunda rodada 0 (16 s), os nulos de 4.633 para 0, 0 linhas
com outra coluna mudada, e 20 de 20 sorteados com o consumo igual à soma das finalizadas ou zero (9 deles com o nulo antes). Os casos
(`ConsumoDoClienteTest`, 19): o `testAUnicaFinalizadaCanceladaDeixaZeroNaRotina` (de sentido trocado), o novo
`testQuemVeioSemCompraComONuloFicaComZeroNaRotina`, o do roteiro com o nulo sem venda a zero e o novo
`testONuloDeQuemNuncaComprouVaiAZeroNoRoteiro`; os mutantes (o nulo de volta no repositório, o zero só para quem tinha valor na
rotina, o nulo fora do `UPDATE`, o nulo fora de tudo, o `SET` que devolve o nulo) todos vermelhos. **PREPARADO, e não CORRIGIDO:** o
código novo só serve depois da troca do passo 7, e o nulo que as cópias e a produção já têm só sai com o roteiro, no mesmo passo.

**A correção da 76p (04/11/2026, a verificação em três lentes, 3 achados baixos).** (1) O corpo da CA7 no desenho e o do D698 ainda
diziam que o nulo sem venda fica nulo, e chamavam de mutante o nulo virando zero: as três frases do desenho e as duas do D698
ganharam a marca "superado pela 303 (a)". (2) O `c.consumo IS NULL OR` da conferência (`diferem_depois`) não tinha caso:
depois do `UPDATE` ela dá 0 com ou sem ele. Novo o `testAConferenciaDeDepoisContaOsMesmosQueAContagemDeAntes`: a contagem, a lista
e a conferência, rodadas antes do `UPDATE`, acham os mesmos sete. (3) A contagem dos nulos (até 1 centavo da soma) não tinha caso
contra a definição pela soma nula, e o nome `nulos_sem_venda_a_zero` era impreciso para o nulo de 1 centavo (vai a 0,01): a
coluna passou a `nulos_ate_1_centavo`, e o novo `testONuloDeSomaAteUmCentavoEstaNaContagemEVaiASoma` põe um nulo com uma
finalizada de zero e um com uma de 1 centavo (na contagem, em `mudam` nem acima nem abaixo, e com a soma depois: zero e 0,01).
Na cópia nada muda: nenhum nulo tem soma de 1 centavo, e a coluna dá os mesmos 4.477. Os mutantes N1 (o nulo fora só da
conferência) e N2 (a contagem pela soma nula), um por vez, no MySQL, a reserva num diretório novo e vazio, devolvidos pelo sha1,
vermelhos pela asserção, cada um no caso novo dele.

### D708 — Na Carteira da rede, os clientes que vieram pela última vez numa unidade hoje fechada não têm linha — REGISTRADO em 04/11/2026 — DECIDIDO em 04/11/2026 (304 a), EM ABERTO: a CA8 — CORRIGIDO em 04/11/2026: a CA8

**Achado na fatia 76b (CARTEIRA-CA1, `docs/f3-relatorios/spec-carteira.md`, a nota FEITA da CA1).** A Carteira põe cada ativo na
unidade da última visita (a de quem abriu o caixa da última venda finalizada com item) ou, sem visita, na do cadastro (a E6), e a
rede mostra uma linha por unidade da lista do filtro -- as ativas, como o desenho dizia ("uma linha por unidade ativa"). O desenho
não dizia o que fazer com quem a regra põe numa unidade hoje desativada. Medido na cópia (só SELECT de contagem,
`p76b2/01-inativas-mysql.sql` e `p76b2/04-fora-das-linhas-mysql.sql` no rascunho da sessão; o oráculo conferiu o mesmo número nos dois
bancos): 56.781 dos 239.239 ativos (24%), em 25 unidades desativadas -- 54.244 com a última visita nelas e 2.537 cadastrados nelas sem
visita; 49.050 com a última visita há mais de 2 anos e nenhum em dia em 17/08/2026; 4.715 com o cadastro numa unidade ativa. **Até a
resposta, a rede os conta e os aponta** (`foraDasLinhas: {unidades, clientes}` na resposta da rede; a soma das linhas mais eles é a
rede, e a lista da rede os mostra, cada um com a unidade dele), sem linha e sem filtro -- nada some, e o gerente de uma unidade ativa
não os vê, como hoje no Clientes antigo (que filtra pela unidade da última visita). A pergunta ao dono, no lote: "TELAS-RESTANTES
[novas] (04/11/2026) -- Na carteira da rede, os 56 mil clientes que vieram pela última vez numa unidade hoje fechada: uma linha para
cada unidade fechada, ou só a conta? (D708)": (a) uma linha por unidade fechada com clientes, marcada, e no filtro do administrador
(como a 300 (b) da Capacidade); (b) uma linha só, "Unidades desativadas"; (c) como está; (d) o cliente da fechada vai à unidade do
cadastro quando ela está aberta (4.715). Recomendei a (a). A CA2 desenha a frase de `foraDasLinhas` como está (o texto está na CA2
do desenho: na CA1 não há tela, e a frase só existe lá); a resposta muda a consulta das unidades da rede e o filtro
(`unidadesDoFiltro`, no molde da `CapacidadeController`), numa fatia própria depois da CA2.

**A resposta 304 (a) do dono (04/11/2026): uma linha por unidade fechada com clientes, marcada, e no filtro do administrador.** Os
clientes da fechada entram na soma das linhas, que passa a ser a rede, e o `foraDasLinhas` e a frase dele saem. É a fatia 76q
(CARTEIRA-CA8) da fila das telas que faltam, no molde da CR4 da Capacidade e dos Royalties, depois da CA2. A CA2 (a fatia 76c) saiu
com a frase do `foraDasLinhas`, como o desenho mandava até a resposta; o glossário `carteira.rede` diz que ela conta os dois grupos (a
última visita e o cadastro de quem nunca veio). **Resta** a CA8; fecha nela (CORRIGIDO, a marca nova no fim do título).

**CORRIGIDO em 04/11/2026, na CA8 (a fatia 76q, CARTEIRA-CA8; a 304 (a)).** Na rede, as linhas são as unidades da lista do filtro --
as ativas e as hoje desativadas com cliente na carteira --, cada uma com `unidade.ativa` (a situação de hoje do cadastro), e a tela
marca a fechada "desativada", no molde da CR4 da Capacidade e dos Royalties; a soma das linhas é a rede, e um cliente numa unidade
que não é linha é `LogicException` (o filtro e a conta, duas consultas da mesma regra, discordando), e não um total que não fecha. O
`foraDasLinhas` saiu da resposta, e a frase dele da tela. O filtro do administrador (`CarteiraController::unidadesDoFiltro`) soma às
ativas as desativadas com cliente, pela MESMA regra da linha -- a E6: um ativo cuja unidade na carteira é ela, pela última visita ou,
sem a unidade da visita, pelo cadastro (`ClienteRepository::carteiraDesativadasComClientes`, o SQL em
`SqlDaCarteira::DESATIVADAS_COM_CLIENTES`, uma forma nos dois motores, com o `EXISTS` no SELECT por unidade, a parte do cadastro
primeiro), com o rótulo da lista do ERP e " (desativada)" (`UnidadeRepository::rotuloDaDesativada`, agora público); escolhida, o
retrato e a lista são os dela, com o e-mail só para o administrador, como em todo o resto. SÓ NO ESCOPO DA REDE: o gerente segue com
a unidade da sessão (a fechada pedida por ele é o 422), e a consulta das fechadas nem roda para ele. A conta das fechadas já estava
na da rede: a linha delas não pede leitura nenhuma a mais. **Medido em processo na cópia, `memory_limit=128M`, o HEAD contra a CA8**
(três rodadas, quente): a rede em 1,67 a 1,71 s e 1,69 a 1,73 s no MySQL, 1,10 a 1,19 s e 1,15 a 1,28 s no PostgreSQL; a unidade 1 em
1,85 a 1,87 s e 1,85 a 1,91 s, e 1,15 a 1,27 s e 1,25 a 1,26 s; o filtro sozinho (o `.../unidades` do administrador) de 0 para 14 a 15
ms no MySQL e 85 a 92 ms no PostgreSQL (o que a CA8 acrescenta a todo pedido do administrador); o pico da rede de 33,7 para 31,7 MB no
MySQL e 18 a 20 MB no PostgreSQL. A rede tem 61 linhas (36 ativas e 25 desativadas, as 25 das 28 desativadas que têm cliente; 56.781
clientes nelas), e a soma das linhas é 239.239, nos dois bancos. O oráculo (Python, só SELECT, a E6 pela janela `ROW_NUMBER`, contra
o JSON do controller em processo): as 61 linhas e a situação de cada uma, a soma, o filtro e uma fechada escolhida (a unidade 2: o
retrato, o resumo por colaborador e a página 1 de todas as faixas pelos ids), 643 conferências por banco, 0 divergências; falsificado
(a venda sem item como visita), 68. Pelo HTTP, no contrato MySQL, com o `CenarioDaCarteira` (a W pela visita, a Y só pelo cadastro, a
Z com história e sem cliente), como o gerente de teste e como o administrador: 30 conferências, 0 divergências. A prévia do design
(`frontend/.design-sync/previews/_carteira.ts` e `Carteira.tsx`) ainda monta a rede com o `foraDasLinhas` e as linhas sem
`unidade.ativa`: é do coordenador.

**A correção da verificação (04/11/2026, a mesma fatia; seis achados, todos baixos).** A fechada escolhida não tinha a marca em
lugar nenhum da resposta (na Capacidade e nos Royalties ela é uma linha marcada; aqui o retrato dela não tem linha de unidade): o
`recorte.unidade` da Carteira passou a levar `ativa` (`CarteiraController::recorteDaResposta`, sobrescrito), e o cabeçalho diz "VIP
Antiga (desativada)"; na lista da rede, a unidade de cada cliente também leva `ativa`, e a coluna diz o nome com " (desativada)"
(`textosDaCarteira.ts`, `nomeComAMarca`). As condições da consulta das fechadas que nenhum dado do cenário separava (o cliente
ativo, os sentinelas, a venda finalizada com item até hoje) ganharam armadilhas na Z do `CenarioDaCarteira` -- o K14 inativo, o
sentinela 2 cadastrado e com a última venda nela, e o K15 com a última visita em T e, depois, uma venda sem item, uma cancelada e uma
de amanhã nela --, com o caso `testAsArmadilhasDaFechadaSemCliente`; cada condição arrancada, nos dois bancos, fica vermelha (o
status e o "só até hoje" da parte das vendas estão escritos duas vezes, e tirar só um dos dois é equivalente). O `LogicException`
da soma das linhas ganhou caso (`testUmClienteForaDasLinhasEDefeito`, com um dublê do filtro sem a W). E os corpos da CA1 e da CA2
no desenho, marcados "superado pela 304 (a)". A prévia do design segue do coordenador, e nenhum portão a confere (o `tsc` só
inclui o `src`).

### D709 — O aviso das vencidas antigas de Contas a Pagar olha só os últimos 12 meses, e quanto ele olha é do dono — REGISTRADO em 04/11/2026; a pergunta foi ao lote — CORRIGIDO em 05/11/2026 (305 a)

**A resposta (05/11/2026):** o dono respondeu a pergunta 305 com a (a), "Os últimos 12 meses, como está": o aviso conta as vencidas
abertas dos últimos 12 meses (`Adm\SaidasController::MESES_DO_AVISO`), e o filtro "vencidas" mostra todas. É o estado de hoje desde a
fatia 77 (548d7e39): nada muda no código, e o débito fecha pela resposta.

**Achado ao montar a fatia 77 (SAIDAS-LEITURA, `docs/f3-telas-restantes/tela-saidas.md`, 1.3 e a P6).** A lista nova de Contas a Pagar
abre no mês de hoje, como a antiga, e avisa "Há N parcelas vencidas antes deste mês, desde novembro de 2025 (R$ X a pagar).", com o
atalho para o filtro "vencidas" de todos os meses (`GET /adm/api/saidas/parcelas?mes=&ano=`, a chave `vencidasAntes`;
`SaidaRepository::vencidasAntes`). O alcance de 12 meses é a decisão 9 do desenho, o padrão reversível até a P6 -- e a P6 ("as
vencidas abertas há muito tempo são dívida ou registro abandonado?") nunca foi ao lote: a fila marcou a P6 como fora da fatia. Na
cópia (o M5 e o M6 do desenho, de 02/10/2026): 464 vencidas abertas, R$ 975.923,87, em 16 unidades, a mais antiga de 06/2017; 238 na
unidade 26, que não lança pela tela desde 06/2024. Com os 12 meses, o filtro "vencidas" mostra todas, e o aviso conta só as do
alcance -- os dois números podem diferir, e a frase do aviso diz desde quando. **A escolha é do dono** (a pergunta no lote,
"Contas a Pagar: o aviso das parcelas vencidas conta só o último ano, ou todas?", D709): (a) os últimos 12 meses, como está; (b) todas;
(c) os 12 meses e uma baixa em lote das antigas sem pagamento (muda dado: só com a autorização, e na produção só no corte). Recomendei
a (a). O alcance é uma constante (`Adm\SaidasController::MESES_DO_AVISO`), e a resposta muda só ela: desde a verificação da fatia 77
(04/11/2026), a frase do aviso lê o `vencidasAntes.desde` que a API manda (`regraDasContas.ts::textoDoAviso`), e não escreve os 12
meses.

### D710 — A tela antiga de Contas a Pagar continua no ERP ao lado da nova, e quando ela sai é do dono — REGISTRADO em 04/11/2026; a pergunta foi ao lote — DECIDIDO em 05/11/2026 (306 b): fica até o corte de cada unidade — PREPARADO em 06/11/2026: a saída está no "Depois" do procedimento de corte

**Achado ao montar a fatia 78 (SAIDAS-ESCRITAS, a T2 de `docs/f3-telas-restantes/tela-saidas.md`).** Contas a Pagar está inteira na
tela nova -- as leituras da 77 e as escritas da 78, pela mesma API (`/adm/api/saidas`) --, e a tela antiga (`/mod-financeiro/saidas/...`,
o `SaidasController` do ERP, os itens "Nova" e "Gerenciar" do `layout.phtml`) continua escrevendo. As duas passam pelo mesmo
`SaidaService` e pela mesma `RegraDaSaida`, e por isso não discordam na regra (o 409 da parcela quitada, o motivo do estorno e da
quitação, a sangria das respostas 171 a 175); a T1 dava a quem escreve os caminhos da antiga, e a 78 os tirou. Nenhuma fatia da fila
tira a tela antiga, e o desenho também não decide (1.5 e 1.6: "a tela antiga continua ... enquanto existir"): a saída das telas antigas
foi, até aqui, resposta do dono (a 138 (a), para os relatórios de clientes). **A escolha é do dono** (a pergunta no lote, "Contas a
Pagar: a tela antiga sai do ERP agora, ou fica até o corte?", D710): (a) sai agora, no molde da 76e (os endereços com 404 e o menu do ERP
levando à tela nova); (b) fica até o corte de cada unidade; (c) fica até ele pedir. Recomendei a (b). Nada muda até a resposta.

**A resposta 306 (b) do dono, 05/11/2026 (o código na fatia 96, SAIDAS-RESPOSTAS, 06/11/2026): a tela antiga de Contas a
Pagar fica até o corte de cada unidade.** Nada mudou no código: o `SaidasController` do ERP, as três views de
`is-financeiro/saidas/`, o `mod-financeiro.js` e os itens "Nova" e "Gerenciar" do menu "Contas a Pagar" do `layout.phtml`
continuam, pelo mesmo `SaidaService` e pela mesma `RegraDaSaida` da tela nova. A saída foi escrita no item 6 do "Depois" de
`docs/procedimento-de-corte.md`, ao lado da linha da VIP Class do aluno (o D715), com a lista do que sai e do que fica (a
`/adm/api/saidas`, o serviço e a despesa da sangria do caixa ficam). PREPARADO, e não CORRIGIDO: a saída é do corte.

### D711 — O extrato de Contas a Pagar não mostra quem estornou, e mostrar o nome é do dono — REGISTRADO em 04/11/2026; a pergunta foi ao lote — CORRIGIDO em 06/11/2026 (307 b): o nome de quem entrou, só a quem escreve em Contas a Pagar

**Achado na verificação da fatia 78 (SAIDAS-ESCRITAS).** O estorno grava quem estornou (`saidas_parcelas_pagamentos.estornado_por`,
a resposta 18 (a), pelo `CrudController::autorDaEscrita`: o id de QUEM ENTROU, e não o da pessoa assumida; a pessoa assumida vai a
`estornado_em_nome_de`). A primeira rodada da 78 passou a PUBLICAR o nome no extrato da despesa (`estornadoPor`), e a tela nova o
mostrava ("estornado em dd/mm/aaaa por <nome>") a todo mundo que lê Contas a Pagar na unidade, também a quem só lê. A tela antiga
nunca mostrou autor nenhum (o `selecionarPagamentosPorSaida` lê o valor, a data e a criação), e as respostas 18 (a) e 140 (a)
decidem gravar, e não mostrar. Com o administrador da rede assumindo um funcionário da unidade, o nome que sairia é o do
administrador. Há precedente na casa (o `canceladaPor` do `RelatorioDeCaixaJson`, o `criadoPor` do `CupomJson`), mas é dado
pessoal que a tela antiga não mostra, e por isso a verificação o tirou: o `SaidaJson` não tem mais a chave, o
`SaidaParcelaPagamentoRepository::estornos` lê só o motivo, e o `ContasAPagarTelaNovaTest` afirma o autor gravado e nenhuma
resposta com a chave ou o nome. **A escolha é do dono** (a pergunta no lote, "Contas a Pagar: o extrato mostra quem estornou?",
D711): (a) não mostra, como a tela antiga; (b) mostra o nome de quem entrou a quem tem o privilégio de escrever; (c) mostra a todos
que leem a despesa. Recomendei a (a). Nada se perde até a resposta: o autor continua gravado.

**A resposta 307 (b) do dono, 05/11/2026, e o código na fatia 96 (SAIDAS-RESPOSTAS, 06/11/2026): o extrato mostra quem
estornou só a quem pode escrever em Contas a Pagar.** O `SaidasController::motivosDe` (da `/adm/api/saidas`) lê os autores
(`SaidaParcelaPagamentoRepository::autoresDosEstornos`: o `usuarios.nome` de `estornado_por`, o de QUEM ENTROU, e não o
`estornado_em_nome_de`) SÓ quando a sessão tem o `escrever` de `financeiro/saidas`; o `SaidaJson::parcela` põe
`estornadoPor` em cada pagamento só quando os recebe -- nulo no ativo e no estorno sem autor gravado (os 126 de antes da
coluna). Quem só lê recebe a resposta SEM a chave, nem nula, como antes. Na personificação, o privilégio que vale é o da
sessão (o da pessoa assumida, como o resto da API), e o nome é o gravado, o de quem entrou. A tela nova diz "estornado em
dd/mm/aaaa por <nome>" quando o nome vem, e só o dia e o motivo quando não vem (`notaDoEstorno`, em `regraDasContas.ts`; o
"por" nunca sai sem nome). As respostas das escritas da parcela já passaram pelo `escrever`, e trazem o nome. A tela antiga
continua sem autor nenhum (ela sai no corte, o D710). Os casos: `ContasAPagarQuemEstornouTest` (quem só lê não recebe a
chave nem o nome em parte nenhuma do corpo; quem escreve recebe o nome certo; o estorno do colaborador assumindo o gerente
mostra o colaborador, e a mesma sessão com o assumido só lendo não recebe a chave; o estorno sem autor sai com a chave nula
e 200), o `ContasAPagarTelaNovaTest` (o nome na despesa e na resposta do estorno, e a chave presa ao tipo da tela), e no
front o `regraDasContas.teste.ts` e o `ContasAPagarEscritas.teste.tsx`.

**A verificação da fatia 96 (06/11/2026)** achou três buracos de prova, e não de código, todos fechados com caso: (1) a
guarda das colunas no `autoresDosEstornos` (o `guardaOEstorno`) não tinha caso -- arrancada, tudo ficava verde, e na base do
teste do BVIP sem as colunas (o D473) quem escreve levaria um 500 ao abrir a despesa; agora o
`ContasAPagarSemAsColunasTest::testOsMotivosDaDespesaNaoSaoLidos` pede os autores com o `estornado_por` escrito à mão e espera
nenhum (mutante G1, vermelho); (2) o filtro do nome em branco era dado como equivalente com as fixtures, e não era: o
`ContasAPagarQuemEstornouTest::testONomeEmBrancoSaiComoNulo` apaga o nome de quem estornou no cadastro ('' e só espaços) e
espera a chave nula, no repositório e na despesa (mutantes S6, sem o filtro, e S7, sem o `trim`, vermelhos); (3) o contrato
da API em `docs/f2-fatia-saidas.md` ainda dizia que quem estornou não é publicado, e foi reescrito.

### D712 — A VIP Class lia e mostrava os arquivos pelo caminho do disco, e não pelo `Armazenamento` (o DV-8) — REGISTRADO em 05/11/2026 — PARCIAL em 05/11/2026: as capas e os fundos na 82 — CORRIGIDO em 05/11/2026

Achado pelo desenho da VIP Class (`docs/f3-telas-restantes/tela-vipclass.md`, DV-8; o rótulo ganhou número no commit
da fatia que o conserta, como o desenho manda). A gravação já passava pelo `Armazenamento`, e a leitura abria
`./public/vip-class/...` direto: com o armazenamento de objetos, o arquivo gravado não estaria no disco, e a tela
pediria o endereço velho. É o item 3 do D33, do lado da VIP Class.

**Feito em 05/11/2026, do lado do aluno (a fatia 79, VIPCLASS-ALUNO-SERVIDOR; a F0):** o fundo do certificado e o
material lidos pelo `Armazenamento` (`conteudo`; o que falta dá 404, e não o erro do FPDF no meio do PDF); a capa do
cartão pelo `url()`. E, na verificação, a decisão técnica 7 do desenho: com o armazenamento de objetos e a
`url_publica` configurada, o material vai ao endereço direto (`Armazenamento::enderecoDireto`, `null` no disco local e
no bucket sem a url pública, que pede assinatura) em vez de carregar até 87 MB na memória do PHP; o que falta no bucket
dá 404. O nome original no `Content-Disposition` não acompanha o redirecionamento (o cabeçalho é o DV-9, a fatia 84).
Provado no `VipClassArquivosTest`, nos dois bancos, com um dublê do `Armazenamento`; os mutantes da leitura direta do
disco, da capa pelo caminho escrito à mão, do redirecionamento retirado, do endereço direto sem a url pública e do
redirecionamento sem conferir que o arquivo existe, vermelhos. O certificado com o S3 de verdade não foi exercitado.

**Resta, e é da administração (a fatia 82, a F3 do desenho):** a capa e o certificado escritos à mão em
`vip-class-admin/index.phtml:96`, `vip-class-admin/capa.phtml:53` e `vip-class-admin/certificado.phtml:53`, e a capa do
relatório em `is-relatorio/vip-class-relatorio/index.phtml:87`.

**Feito em 05/11/2026, do lado da administração (a fatia 82, VIPCLASS-ADMIN-SERVIDOR; a F3):** a capa da lista
(`vip-class-admin/index.phtml`), a capa e o fundo das abas (`capa.phtml`, `certificado.phtml`) e a capa do relatório do menu
Relatórios (`is-relatorio/vip-class-relatorio/index.phtml`) saem pelo `Armazenamento::padrao()->url()`, escapados para o atributo.
Provado no `TelasDoVipClassDoErpTest` (a aba da capa renderizada, e as quatro telas sem o caminho escrito à mão, lidas); os mutantes
O16, O17 e O18 (o caminho à mão de volta), vermelhos. O envio (o DV-9) é da fatia 84.

### D713 — O link da aula da VIP Class não era conferido, e os textos do administrador saíam crus (o DV-10) — REGISTRADO em 05/11/2026 — PARCIAL em 05/11/2026: o checkpoint e o link na 82 — CORRIGIDO em 05/11/2026

Achado pelo desenho da VIP Class (`docs/f3-telas-restantes/tela-vipclass.md`, DV-10; o número veio no commit da fatia
que o conserta). O link da aula só precisava não ser vazio e ia cru para o `src` do iframe; o nome e a descrição do
curso, o nome do pré-requisito (dentro de uma string de JavaScript num `onclick`), a pergunta e a resposta saíam sem
escape. Medido no desenho: nenhum texto com `<`, `>` ou apóstrofo na cópia; só o administrador escreve.

**Feito em 05/11/2026, nas três telas do aluno (a fatia 79; a F0):** o vídeo sai pelo id do YouTube
(`RegraDoCursoVipClass::videoDoYoutube`), montado em `https://www.youtube-nocookie.com/embed/<id>` (as decisões 4 e 9
do desenho; o `nocookie` entrou na verificação), e o link que não é vídeo do YouTube diz que a aula está sem vídeo; os
textos saem escapados; o nome do requisito vai num atributo `data-requisito`, e o `vipClassEduModalRequisito` o põe no
modal com `.text()`. Provado no `VipClassAlunoPeloErpTest` e no `D203TextoDigitadoNasTelasDoErpTest` (o PHP), e, na
verificação, no `frontend/src/telas/vipClassDaTelaAntiga.teste.ts`, que roda a função do `mod-configuracoes.js`
servido: o mutante que volta ao `.html()` deixava o PHP verde, e fica vermelho ali.

**Resta, e é da administração (a fatia 82, a F3 do desenho):** a pergunta e a resposta cruas em
`vip-class-admin/checkpoint.phtml:71,79`; o link cru no `src` do iframe da lista de aulas da administração
(`vip-class-admin/aulas-tabela.phtml:8,10`); e o link aceito sem conferência ao gravar
(`VipClassAdminController::aulasValidarFormulario`, o `empty($dados['link'])`), com o Vimeo ainda oferecido no formulário
(`Form/VipClassAdminAula.php:39`) -- a decisão 9 do desenho é só YouTube.

**Feito em 05/11/2026, na administração (a fatia 82, VIPCLASS-ADMIN-SERVIDOR; a F3):** a pergunta e a resposta
saem escapadas no checkpoint da administração; a lista das aulas mostra o vídeo pelo id do YouTube, num iframe do
`youtube-nocookie.com` (nenhum `src` vem do banco), e o link que não é vídeo diz "Esta aula está sem vídeo"; ao gravar, o link é
conferido por `RegraDoCursoVipClass::conferirLink` (o `videoDoYoutube`, até 255), na tela antiga e na API, e a plataforma é sempre o
YouTube -- o Vimeo saiu do formulário. As duas telas entraram nas vigiadas do `conferir-escape-nas-views.php` (com os ids por
`(int)`). Provado no `TelasDoVipClassDoErpTest` e no `CursoApiTest`; os mutantes R12, O4, O12, O13, O14 e O15, vermelhos. Medido na
cópia (só SELECT): as 68 aulas são do YouTube, e nenhum texto tem `<` ou `>`.

### D714 — O certificado de quem concluiu um curso que saiu da lista dela não tem caminho na tela — REGISTRADO em 05/11/2026; a pergunta foi ao lote — CORRIGIDO em 06/11/2026: a seção "Outros certificados" (308 a)

**Achado na fatia 80 (VIPCLASS-ALUNO-TELA).** A decisão 2 do desenho (`docs/f3-telas-restantes/tela-vipclass.md`, seção 4)
diz que o certificado de quem concluiu sai sempre, mesmo que a pessoa tenha mudado de grupo ou o curso tenha sido desativado: é
dela. O servidor cumpre (`GET /adm/api/meus-cursos/:id/certificado` só confere a conclusão da própria pessoa, como o
`checkpointCertificadoAction` antigo). Mas a lista dos cartões é a dos cursos publicados para a unidade e o grupo DA SESSÃO, e
ativos: o curso concluído que saiu dessa lista não vira cartão, e o certificado dele fica sem caminho na tela -- nem na antiga,
nem na nova; só quem souber o endereço o baixa. Medido no desenho (M9): 4 conclusões de 2 pessoas fora do grupo atual; hoje
nenhum curso está inativo. **A escolha é do dono** (a pergunta no lote, "VIP Class: os certificados dos cursos que saíram da
lista da pessoa", D714): (a) uma seção "Outros certificados" no pé da lista, só com o nome, a data e o certificado de cada um,
sem as aulas; (b) como hoje, sem caminho na tela; (c) o curso concluído continua na lista inteiro, com as aulas, mesmo fora da
publicação. Recomendei a (a). Nada se perde até a resposta: a conclusão fica, e o endereço do certificado responde.

**Corrigido em 06/11/2026 (a fatia 95, VIPCLASS-RESPOSTAS; a resposta 308 (a) do dono).** A `GET /adm/api/meus-cursos`
passou a trazer `outrosCertificados`: os cursos que a pessoa da sessão concluiu e que não estão nos cartões dela (saiu do
grupo, o curso foi despublicado da unidade ou desativado), um por curso, com a PRIMEIRA conclusão (a 213 (a)) -- só o id,
o nome, a data e o endereço do certificado que já existia (`GET .../:id/certificado`), sem as aulas, o checkpoint, os
materiais, a capa e a nota. A regra é pura (`RegraDoCursoVipClass::outrosCertificados`: fora da lista, a primeira, na ordem do
nome), e a leitura, uma consulta só das conclusões da pessoa (`VipClassCheckpointUsuarioRepository::conclusoesDoAluno`);
na personificação, as da pessoa assumida, como o resto da tela. A tela do aluno (`MeusCursos.tsx`) mostra a seção no pé
da lista -- o nome, "concluído em dd/mm/aaaa" e o link do certificado --, e ela some quando vem vazia (aparece
também com a lista vazia). Provado no `MeusCursosApiTest::testOsOutrosCertificados` (o restrito de outro grupo com duas
conclusões, a mais antiga de id maior; o curso desativado de outra pessoa; o aberto, que está nos cartões; o certificado
do outro com 200) e no `RegraDoCursoVipClassTest::testOsOutrosCertificados`, nos dois bancos, e no front
(`MeusCursosLigado.teste.tsx`, "os outros certificados"); os mutantes S7 (o curso da lista no pé), S8 (a conclusão de
outra pessoa), S9 (a mais recente no lugar da primeira), F4 (a seção sempre) e F5 (sem a data), vermelhos. **Na correção da
fatia 95** (achado da verificação): a primeira escrita mandava e mostrava também a NOTA, e a opção (a) diz "só o nome do curso,
a data da conclusão e o botão do certificado". A API passou a mandar `concluidoEm` (a data) no lugar da `conclusao` com a nota,
e a linha diz só "concluído em dd/mm/aaaa"; o `MeusCursosApiTest::testOsOutrosCertificados` confere as chaves
(`id`, `nome`, `concluidoEm`, `certificado`) e o `MeusCursosLigado.teste.tsx`, a linha sem "nota" -- os mutantes C4 (a nota de
volta na API) e FE (a nota de volta na linha), vermelhos.

### D715 — VIP Class do aluno: a página do ERP convive com a nova, e a saída dela espera o dono — REGISTRADO em 05/11/2026; a pergunta foi ao lote — DECIDIDO em 06/11/2026 (309 b): fica até o corte de cada unidade — PREPARADO em 06/11/2026: a saída está no "Depois" do procedimento de corte

**Achado na verificação da fatia 80 (VIPCLASS-ALUNO-TELA, a F1 de `docs/f3-telas-restantes/tela-vipclass.md`).** A VIP Class do
aluno está inteira na tela nova (`/adm/api/meus-cursos`, o `MeusCursosLigado.tsx`): os cartões, as aulas, os materiais, o
checkpoint e o certificado. A tela antiga (`/mod-cadastros/vipClassEdu/...`, o `VipClassEduController`, o item "VIP Class" do
`layout.phtml` do ERP) continua respondendo, e está em uso. As duas passam pela mesma regra da fatia 79
(`RegraDoCursoVipClass`, `VipClassRepository::cursoDoAluno`) e pelo mesmo serviço da conclusão (`VipClassEduService`, a trava e a
releitura), e por isso não discordam em quem abre o quê nem gravam duas conclusões; mas convivem com duas diferenças: a antiga pede
o `escrever` para ver as aulas e a nova pede o `ler` (a decisão 3 do desenho); e a nova mostra a PRIMEIRA conclusão (a resposta
213 (a)), enquanto o certificado e o relatório antigos leem a mais recente (o D72) até a F2 e a F5. Nenhuma fatia da fila tira a
tela antiga, e o desenho não decide; o precedente é o D710 (Contas a Pagar), que foi ao lote. **A escolha é do dono** (a pergunta
no lote, "VIP Class: a tela antiga do aluno sai do ERP agora, ou fica até o corte?", D715): (a) sai agora, no molde da 76e (os
endereços do aluno com 404 e o item do menu do ERP levando à tela nova; a administração e o relatório antigos ficam); (b) fica até
o corte de cada unidade; (c) fica até ele pedir. Recomendei a (b), como no D710. Nada muda até a resposta.

**A resposta 309 (b) do dono, 06/11/2026 (a fatia 95, VIPCLASS-RESPOSTAS): a tela antiga do aluno fica até o corte de
cada unidade.** Nada mudou no código: o `VipClassEduController`, as três views de `vip-class-edu/`, o item "VIP Class" de
primeiro nível do `layout.phtml` e as funções `vipClassEdu*` do `mod-configuracoes.js` continuam, pela mesma regra e pelo
mesmo serviço da tela nova. A saída foi escrita no item 6 do "Depois" de `docs/procedimento-de-corte.md` (o procedimento
não tinha um passo de aposentar as telas antigas de uma unidade; o item nasceu aqui, com a lista do que sai e do que fica
-- o link público dos materiais fica). PREPARADO, e não CORRIGIDO: a saída é do corte.

### D716 — O relatório da VIP Class estava em dois lugares, pedia o escrever, listava quem não pode fazer o curso e fazia uma consulta por pessoa (o DV-13) — REGISTRADO em 05/11/2026 — PARCIAL em 05/11/2026: a consulta, a guarda e o recorte; a página da administração sai com a administração nova — PARCIAL em 05/11/2026: o Quem fez da administração na tela nova; a página antiga sai com a administração antiga (o D722) — CORRIGIDO em 05/11/2026: a página "Relatório" da administração antiga saiu (a correção da fatia 83)

**O DV-13 de `docs/f3-telas-restantes/tela-vipclass.md`, que ainda não tinha número** (a linha do `?status=0` era a da VIP Class
do D361). Quatro defeitos, nas duas telas antigas do relatório -- o do menu Relatórios (`VipClassRelatorioController`) e o
"Relatório" da administração (`VipClassAdminController::relatorioAction`), o S21 do desenho:

1. **as duas pediam o `escrever`** (o padrão do `getAcesso()`), e ler um relatório pede o `ler`; na cópia, os 63 com "VIP Class -
   Relatório" têm o `escrever` (o M6), e ninguém perde nada;
2. **a lista era feita pelo grupo**, sem olhar o privilégio de aluno (`VipClassRepository::relatorioUsuarios`), e pela unidade e o
   grupo em DQL montado (`getUnidadesStr`, `getGruposStr`, as duas concatenações do D73 e o "LEGÍTIMO" do D124);
3. **uma consulta por pessoa** (`selecionarUsuario`, até 267 na rede), com a data e a nota da tentativa MAIS RECENTE (o D72);
4. **o `?status=0` mostrava os cursos inativos** a quem não é administrador, e o curso inativo abria pelo id.

**Feito na fatia 81 (VIPCLASS-QUEM-FEZ, a F2 do desenho):** a consulta nova, `VipClassRepository::equipe`, uma para todas as
pessoas e todas as conclusões, com a regra da `RegraDoCursoVipClass` (`elegivel`: o grupo E o privilégio do aluno, a decisão 10,
o administrador pelo grupo; `primeira`: a resposta 213 (a)); a API nova (`GET /adm/api/cursos/conclusoes` e `.../:id/conclusoes`,
`Adm\ConclusoesDosCursosController`, com o `ler`) e as duas telas antigas chamam a mesma; o recorte pelas marcas
(`escopoDaEquipe`: o administrador a rede, o gerente a unidade, os outros só a si -- a resposta 3); o curso pelo id e a lista pela
`cursoDaEquipe` e a `situacaoDaLista` (o inativo e o de outra unidade fora a quem não é administrador). Medido na cópia (só
SELECT, só contagem): 5 pessoas saem da lista do relatório, em 63 pares de curso e pessoa, 9 deles com conclusão (o D717); os 8
administradores ativos ficam (168 pares, pelo grupo, sem linha de privilégio); quem tem o relatório e não é gerente é 1, que passa
a ver só a si; a consulta da rede inteira, 1 consulta e cerca de 24 ms, contra uma por pessoa. Provado no
`ConclusoesDoCursoApiTest` (16 casos) e no `RegraDoCursoVipClassTest`, nos dois bancos, no ensaio HTTP e no oráculo.

**A correção da fatia 81 (a verificação):** a página "Relatório" da administração tinha caso só como administrador -- voltar o
`escrever`, abrir o inativo ou o de outra unidade pelo id e tirar o "só a si" ficavam verdes. Agora
`ConclusoesDoCursoApiTest::testORelatorioDaAdministracaoParaQuemNaoEAdministrador` a despacha como o gerente e o colaborador com o
`ler` de "VIP Class - Admin" e sem o `escrever`: 200 com a unidade, o colaborador só a si, 404 sem o `ler`, para o curso de outra
unidade e para o inativo, e o `?status=0` da lista da administração sem o inativo. Cinco mutantes (o `escrever` de volta, o inativo,
a outra unidade, o "só a si", o "0" a quem não é administrador na lista), vermelhos nos dois bancos; o "só a si" também no
`conferir-recorte-por-unidade` (a entrada nova). E a condição `pri.ler` do privilégio do aluno ganhou caso
(`testOPrivilegioDoAlunoSemOLerNaoConta`: a linha de "VIP Class - Edu" sem o `ler` não conta; na cópia, 0 linhas assim).

**Achado na correção, e não mexido:** a busca por nome da lista da administração nunca filtrou -- o formulário manda `nome`, o
controller sempre leu `filtro`, e a consulta usa `nome`. Fica como estava (o `nome` vai nulo, de propósito); ligá-la é da
administração nova (a fatia 83).

**Feito na fatia 83 (VIPCLASS-ADMIN-TELA, 05/11/2026):** a vista "Quem fez" da administração na tela nova chama a
`GET /adm/api/cursos/:id/conclusoes` (a da 81), com a rede inteira ao abrir e a unidade escolhida depois; e a busca por nome da
lista da administração passa a filtrar na tela nova (o `?busca=` da API da 82, `listagemDaAdministracao`, com caso no
`CursoApiTest`). A tela antiga segue com a busca que não filtra até sair (o D722).

**Resta:** a página "Relatório" da administração (`VipClassAdminController::relatorioAction`) continua, chamando a mesma consulta:
o desenho a manda sair ("o relatório duplicado (S21)") com a administração na tela nova -- o "Quem fez" da administração, a
fatia 83, chama a `GET /adm/api/cursos/:id/conclusoes`. O relatório do menu Relatórios fica (o D306 não o apaga).

**Feito na correção da fatia 83 (05/11/2026), o resto:** a verificação lembrou que o desenho JÁ decide a saída da página -- a
lista "Sai" (`tela-vipclass.md`, "o relatório duplicado (S21)") e a F2 ("o da administração sai com a administração nova") -- e que
pô-la na pergunta do D722 entregava ao dono uma escolha já feita. A administração nova chegou na 83, com o "Quem fez"; a página
saiu: `VipClassAdminController::relatorioAction` e a view `vip-class-admin/relatorio.phtml` foram tiradas, e a aba "Relatório" das
seis páginas do curso (editar, capa, certificado, materiais, aulas e checkpoint) também. O endereço
`/mod-configuracoes/vipClassAdmin/relatorio/<id>` cai no 404 do controller, para todos. O relatório do menu Relatórios
(`VipClassRelatorioController`) fica, com os filtros (o D306), pela mesma consulta. Provado em
`ConclusoesDoCursoApiTest::testORelatorioDaAdministracaoSaiu` (o administrador e o gerente com 404, o do menu Relatórios com 200,
nenhuma view da administração com a aba), nos dois bancos; os mutantes (a ação de volta; a aba de volta numa view) vermelhos nos
dois. As duas entradas da página no `conferir-recorte-por-unidade.php` saíram com ela, e o inventário foi regerado (345 para 344
ações). Nada se perde: quem administra vê "Quem fez" na tela nova, e o do menu Relatórios tem a busca, o grupo e a situação. A
busca por nome da LISTA da administração antiga, que não filtra, não é deste débito: segue com a tela antiga, no D722.

### D717 — "Quem fez" não mostra a conclusão de quem hoje não pode fazer o curso — REGISTRADO em 05/11/2026; a pergunta foi ao lote — DECIDIDO em 06/11/2026 (310 a): como está; o relatório mostra somente quem hoje pode estudar aquele treinamento

**Achado na fatia 81 (VIPCLASS-QUEM-FEZ).** Pela decisão 10 do desenho, o relatório lista quem pode fazer o curso: o grupo E o
privilégio do aluno. A conclusão de quem saiu dessa lista -- perdeu o privilégio, mudou de grupo, o curso deixou de ser do grupo
dele -- continua no banco, e o certificado continua saindo (a decisão 2), mas a pessoa some de "Quem fez" e da conta "N de M".
Medido na cópia (só SELECT, só contagem): das conclusões que o relatório antigo mostrava, 9 (de 5 pessoas sem o privilégio do
aluno) deixam de aparecer; as 4 de 2 pessoas fora do grupo atual (o M9) já não apareciam. **A escolha é do dono** (a pergunta no
lote, "VIP Class: quem fez o curso e hoje não pode fazê-lo aparece em Quem fez?", D717): (a) como está: só quem pode fazer, e a
conta "N de M" fecha; (b) quem concluiu aparece sempre, marcado "não faz mais parte", fora da conta; (c) quem concluiu aparece e
entra na conta. Recomendei a (a). Nada se perde até a resposta: a conclusão fica no banco.

**A resposta 310 (a) do dono, 06/11/2026 (registrada na fatia 95, VIPCLASS-RESPOSTAS): "como está" -- "Quem fez" lista
só quem pode fazer o curso hoje, e a conta "N de M" é de quem pode.** Nada mudou no código. A conclusão de quem saiu da
lista fica no banco e o certificado continua saindo (a decisão 2; e, desde a fatia 95, pelos "Outros certificados" da
tela do aluno, o D714).

### D718 — A aula, a pergunta e a resposta da VIP Class eram gravadas pelo id do POST, sem conferir o curso (o DV-11) — REGISTRADO em 05/11/2026 — CORRIGIDO em 05/11/2026

Achado pelo desenho da VIP Class (`docs/f3-telas-restantes/tela-vipclass.md`, DV-11; o número veio no commit da fatia que o
conserta). O `aulasSalvar` hidratava o POST inteiro sobre um `getReference` do `id` do corpo -- a aula de outro curso era alterada, e o
`vipclass_id` do corpo a mudava de curso --; a pergunta nova usava o `vipclass_id` do POST, e as alterações de pergunta e resposta, o
`id` do POST, sem ler nada. **Feito em 05/11/2026 (a fatia 82, VIPCLASS-ADMIN-SERVIDOR):** a aula é a do curso da rota
(`VipClassRepository::aulaDoCurso`), a pergunta é a ATIVA do curso do formulário (`::perguntaDoCurso`) e a resposta, a ativa da
pergunta (`::respostaDaPergunta`), na tela antiga e na `/adm/api/cursos`; o que não é dali volta com a frase (a tela antiga) ou 404 (a
API), e nada grava. O serviço grava campo a campo o que a regra devolve, nunca o POST. Provado no `CursoApiTest` e no
`TelasDoVipClassDoErpTest`, nos dois bancos, com o retrato do banco antes e depois; os mutantes P1, P2, P4, O3, O7 e O8, vermelhos.

**O que isto NÃO cobre (a correção da fatia 82, depois da verificação):** a conferência contra o curso vale para o GRAVAR, nas
duas portas, e para as remoções da API (a rota traz o curso e a pergunta). As três remoções da tela antiga -- `aulaRemover`,
`checkpointPerguntaRemover` e `checkpointRespostaRemover` -- acham a linha só pelo id do POST: o `mod-configuracoes.js` manda só o
id, e a rota não traz o curso. O efeito é nenhum além do que o administrador já pode (ele administra todos os cursos, e a tela
antiga sai na 83), mas o texto acima dizia "na tela antiga e na API" sem a ressalva. A pergunta removida é a ATIVA
(`perguntaDoPost`); a resposta removida pode ser uma inativa (sai como sempre saiu), e a correta segue recusada.

### D719 — A resposta correta da VIP Class não tinha regra: a pergunta podia ficar sem ela, ou com duas (o DV-12) — REGISTRADO em 05/11/2026 — CORRIGIDO em 05/11/2026 — PARCIAL em 05/11/2026: perder a correta é recusado, a pergunta nova sem a correta só avisa, e a escolha foi ao lote — CORRIGIDO em 06/11/2026: a pergunta nova nasce com as respostas e uma correta (312 c)

Achado pelo desenho da VIP Class (DV-12). O `resposta_correta` chegava cru, e remover a correta não era recusado: com até 4 perguntas,
uma pergunta sem a correta torna o curso impossível de concluir. Medido na cópia (só SELECT): as 66 perguntas ativas têm exatamente
uma correta, e 3 têm uma resposta só. **Feito em 05/11/2026 (a fatia 82; a decisão 7 do desenho):** marcar uma resposta como correta
desmarca a outra, na mesma transação, com a pergunta travada; desmarcar a correta e remover a correta são recusados ("A pergunta
ficaria sem resposta correta: marque outra como correta antes.", 409 na API); a pergunta com uma resposta só e a pergunta sem a
correta (a nova, antes das respostas) ganham AVISO, e não recusa. Provado no `CursoApiTest`, no `TelasDoVipClassDoErpTest` e no
`RegraDoCursoVipClassTest`; os mutantes R11, S7, S8 e S9, vermelhos; pelo HTTP, os casos 26 a 29 do `ensaiar-cursos-adm-api.php`.

**PARCIAL, e não corrigido (a correção da fatia 82, depois da verificação):** o título fechava "a pergunta podia ficar sem ela", e
isso continua possível. A recusa cobre PERDER a correta que existe (`recusaDaCorreta` olha a que era correta); a pergunta NOVA
nasce sem resposta, e as respostas erradas entram uma a uma sem nenhuma correta -- num curso ATIVO, publicado, ninguém acerta
aquela pergunta (com 4 perguntas, a nota máxima é 75, abaixo do corte de 80), e o servidor só mostra o aviso
`pergunta_sem_correta`, a quem administra. A decisão 7 do desenho diz "cada pergunta tem uma resposta correta", mas não diz como,
com a pergunta e as respostas gravadas em passos separados; fazer valer exige escolher (recusar a errada enquanto não há a
correta; criar a pergunta já com as respostas; ou recusar ativar o curso, e incluir a pergunta no curso ativo, sem a correta), e
a escolha foi ao lote ("VIP Class: a pergunta pode ficar sem a resposta correta enquanto é montada?", D719). Na cópia, as 66
perguntas ativas têm a correta. **A trava da pergunta, agora provada:** no instante do `INSERT` e do `DELETE` da resposta, outra
conexão acha a pergunta presa (`FOR UPDATE NOWAIT`), e a pergunta de outro curso livre
(`CursoApiTest::testARespostaNovaGravaComAPerguntaPresa` e `::testARespostaRemovidaSaiComAPerguntaPresa`, nos dois bancos; os
mutantes K13 e K14, sem a trava, vermelhos) -- o texto acima a afirmava sem caso.

**A tela nova, na fatia 83 (VIPCLASS-ADMIN-TELA, 05/11/2026), segue a (a) enquanto a pergunta não tem resposta:** a
"Pergunta nova" diz, antes de incluir, que a pergunta nasce sem respostas e que ninguém a acerta até a correta entrar; o aviso
`pergunta_sem_correta` do servidor aparece no alto do curso e na linha da lista; marcar a correta diz qual sai; desmarcar ou remover
a correta ouve o 409 do servidor, com o curso relido. Nada da regra do servidor mudou. **O que a (c) pediria**, se for a resposta:
na API, o `POST .../perguntas` aceitando `respostas` (a lista, com uma e só uma `correta`) e gravando a pergunta e as respostas
numa transação, com o 422 por campo; e na tela, o formulário da pergunta com as respostas junto (incluir e tirar linhas, a correta
num grupo de rádio), no lugar dos dois passos de hoje.

**Corrigido em 06/11/2026 (a fatia 95, VIPCLASS-RESPOSTAS; a resposta 312 (c) do dono).** A pergunta nasce com as
respostas: o `POST /adm/api/cursos/:id/perguntas` passou a exigir `respostas` -- pelo menos uma, sem teto, cada uma `{texto, correta}`, e
EXATAMENTE UMA correta (`RegraDoCursoVipClass::conferirRespostasDaPerguntaNova`) -- e a gravar a pergunta e as respostas
numa transação, com a pergunta travada (`VipClassService::criarPergunta`); sem as respostas, sem a correta, com duas, com a
lista vazia, ou com um item que não serve, 422 no campo (`respostas`, `respostas.<i>.texto`,
`respostas.<i>.correta`, `respostas.<i>`), e nada grava. **O mínimo é uma**, e não duas: a decisão 7 do desenho manda a
pergunta com uma resposta só ganhar aviso, e não recusa (na cópia, 3 assim). **Sem máximo**, como a tela antiga e o
incluir resposta da pergunta que existe: a cópia vai de 1 a 4 respostas por pergunta. (A primeira escrita tinha um teto de
dez, "do corpo", sem base no desenho nem na tela antiga; saiu na correção da fatia, com o caso das doze respostas no
`CursoApiTest` e no `RegraDoCursoVipClassTest` -- os mutantes C3 e FF, o teto de volta, vermelhos.) **Medido na cópia (só SELECT de contagem, 06/11/2026): das 66 perguntas ativas,
nenhuma seria recusada** -- 0 sem resposta, 0 sem a correta, 0 com duas; 3 com uma resposta só; de 1 a 4.
A regra vale só para a criação: a pergunta existente não é conferida de novo. Incluir, alterar e remover resposta numa
pergunta existente continuam como a fatia 82 deixou (a recusa de perder a correta, a troca da correta). **Não há outra
porta que crie pergunta** (conferido por grep): a administração antiga saiu do ERP na mesma fatia (a 313 (a), o D722), e o
`salvarPergunta` do serviço passou a só alterar a que existe. O aviso `pergunta_sem_correta` continua, para o dado velho.
Na tela nova, o formulário da pergunta nova pede o texto, a ordem e as respostas (duas linhas para começar, "incluir
outra resposta" sem teto, "tirar" em cada uma) com a correta num grupo de rádio, confere antes de mandar com as mesmas
frases do servidor (o `RegraDoCursoVipClassTest` lê as frases e o mínimo da tela) e manda um pedido só. Provado no
`CursoApiTest` (`testAPerguntaNovaSemACorretaOuComDuasE422ENadaGrava`, `testAPerguntaNovaETodasAsRespostasOuNada` -- a
segunda resposta que falha no `INSERT` desfaz a pergunta e a primeira --, e a pergunta nova com as duas respostas) e no
`RegraDoCursoVipClassTest`, nos dois bancos; pelo HTTP, os casos 25 e 26 do `ensaiar-cursos-adm-api.php`; no front, o
`CursosDaVipClassLigado.teste.tsx` e o `formularioDoCurso.teste.ts`. Os mutantes S3 (a sem a correta aceita), S4 (duas
corretas aceitas), S5 (sem a transação), S6 (a pergunta gravada sem as respostas), F1, F2 e F3, vermelhos. **O que não
foi provado:** a trava da pergunta nova -- a linha recém-criada já nasce presa pelo `INSERT` da própria transação (no
PostgreSQL, outra conexão nem a vê; no MySQL, espera por ela), e nenhum caso distingue a trava explícita da implícita.

### D720 — Desativar o pré-requisito de um curso ativo, ou o curso 17, trancava os cursos e o visagismo sem aviso (o DV-14) — REGISTRADO em 05/11/2026 — CORRIGIDO em 05/11/2026

Achado pelo desenho da VIP Class (DV-14). O cadeado só pergunta pela conclusão do requisito, e o curso inativo nem aparece para ser
feito: desativar o curso 1 trancaria os cursos 2 a 6 e o 17 para quem ainda não fez o 1; e o visagismo só abre a quem concluiu o 17,
sem olhar a situação dele. E o requisito podia ser o próprio curso, formar ciclo, ou ser um id que não existe (500 de chave
estrangeira). **Feito em 05/11/2026 (a fatia 82; a decisão 6 e a (A) da pergunta 5 do desenho, que o dono NÃO respondeu -- o D721):**
desativar o curso que é pré-requisito de um curso ativo é recusado (409 `pre_requisito_de`, com a lista dos cursos), e o 17 também
(409 `libera_o_visagismo`); ativar o curso cujo pré-requisito está inativo é recusado; o requisito novo existe, está ativo, não é o
próprio curso e não forma ciclo (o atual, mesmo inativo, serve). A regra é uma só (`RegraDoCursoVipClass::recusaDoEstado` e
`requisitoRecusado`), aplicada com a linha travada e o grafo relido, na tela antiga (o desativar da lista e o select do editar, tudo
ou nada) e na API. **O que muda para o administrador, medido na cópia (só SELECT):** 11 dos 21 cursos são pré-requisito de um curso
ativo, e mais o 17 -- 12 cursos só se desativam depois de os que dependem deles; nenhum curso hoje tem ciclo ou o próprio curso de
requisito. Provado no `CursoApiTest`, no `TelasDoVipClassDoErpTest` e no `RegraDoCursoVipClassTest`; os mutantes R1 a R10, S4 a S6,
C10 e O6, vermelhos; pelo HTTP, os casos 16 e 18 do ensaio.

**A trava, consertada e provada (a correção da fatia 82, depois da verificação):** "com a linha travada e o grafo relido" não
bastava. Cada gravação travava só a linha do PRÓPRIO curso e lia o grafo sem trava: duas abas -- uma pondo B como requisito de A,
outra A como requisito de B -- travavam linhas diferentes, nenhuma esperava a outra, cada uma lia o grafo sem a mudança da outra,
e o ciclo A↔B passava; o mesmo com desativar A enquanto outra aba o punha de requisito de B. Agora o cadastro, a alteração e o
estado leem o grafo TRAVADO (`VipClassRepository::grafoDosRequisitos(true)`: `FOR UPDATE` em todos os cursos, na ordem do id, a
mesma para todos -- ninguém espera ao contrário), antes da linha do curso: a segunda aba espera a primeira gravar, lê o grafo
depois dela e ouve a recusa. São 21 cursos, e só os administradores escrevem. Provado: no instante do `UPDATE` (e do `INSERT` do
cadastro), outra conexão acha presos o curso da rota E outro curso, e livre uma aula
(`CursoApiTest::testORequisitoEOEstadoGravamComOGrafoPreso`, `::testODesativarGravaComOGrafoPreso` e
`::testOCadastroGravaComOGrafoPreso`, nos dois bancos); os mutantes K9 a K12 (a trava tirada do grafo, ou o grafo sem trava em
cada uma das três escritas), vermelhos. O que não foi provado: duas abas de verdade ao mesmo tempo -- o caso prova a trava no
instante da escrita, e a espera é a do banco.

### D721 — O curso 17 continua sendo a chave do visagismo? — REGISTRADO em 05/11/2026; a pergunta foi ao lote — DECIDIDO em 06/11/2026 (311 a): como está; a constante continua liberando a página do visagismo

A pergunta 5 do desenho da VIP Class (`docs/f3-telas-restantes/tela-vipclass.md`), sem resposta até a fatia 82. O `VisagismoController`
do ERP (`ISCadastro`, a página do teste) só o abre a quem tem uma conclusão do curso 17, o fim de uma cadeia de sete cursos e 31
horas; na cópia, 131 dos 199 consultores visagistas não abrem a página por não o terem feito (o M12). **A API do aplicativo NÃO
pede o curso** (a correção da fatia 82, depois da verificação; o texto dizia "nas duas portas"): o `ISApi\Controller\VisagismoController`
(a foto, o processamento e os testes do mês) não olha a VIP Class -- pelo aplicativo, os 131 fazem o visagismo; o
`CursoApiTest::testAConstanteEOCursoQueOVisagismoLe` pulava esse arquivo calado e agora afirma as duas coisas (o mutante K15, a API
pedindo o curso, vermelho). A fatia 82 seguiu a (A), que o desenho recomenda
e que não mexe no visagismo (fora da migração): a administração marca o 17 como "libera o Visagismo" e recusa desativá-lo (o D720).
**A escolha é do dono** (a pergunta no lote, "VIP Class: o curso 17 continua sendo a chave do visagismo?", D721): (a) como está; (b)
a chave vira uma escolha na administração; (c) o visagismo deixa de exigir o curso. Nada se perde até a resposta: é uma constante
(`RegraDoCursoVipClass::CURSO_DO_VISAGISMO`), conferida contra o `VisagismoController` pelo `CursoApiTest`.

**A resposta 311 (a) do dono, 06/11/2026 (registrada na fatia 95, VIPCLASS-RESPOSTAS): "como está" -- o curso 17 continua
a chave do visagismo na página do ERP, a administração o marca "libera o Visagismo" e recusa desativá-lo.** Nada mudou no
código: a constante `RegraDoCursoVipClass::CURSO_DO_VISAGISMO`, conferida contra o `VisagismoController` pelo
`CursoApiTest`, é a decisão escrita. Pelo aplicativo, o visagismo segue sem pedir o curso.

### D722 — A administração antiga da VIP Class continua no ERP ao lado da nova, e quando ela sai é do dono — REGISTRADO em 05/11/2026; a pergunta foi ao lote — CORRIGIDO em 06/11/2026: a administração antiga saiu do ERP (313 a) — PARCIAL em 06/11/2026: o item do menu do ERP saiu em vez de levar à tela nova; a escolha voltou ao lote — CORRIGIDO em 06/11/2026: o item volta ao menu do ERP e abre a VIP Class na tela nova pelo endereço por tela (314 c)

**Achado na fatia 83 (VIPCLASS-ADMIN-TELA, a F3 de `docs/f3-telas-restantes/tela-vipclass.md`, sem os arquivos).** A
administração da VIP Class está na tela nova ("Configurações › VIP Class" do Console: `CursosDaVipClassLigado.tsx`, a API
`/adm/api/cursos` da fatia 82): a lista com os avisos, os dados com "onde vale" e "para quem", as aulas, o checkpoint, ativar e
desativar com o alcance, e "Quem fez". Os ARQUIVOS (a capa, o fundo do certificado e os materiais) seguem na tela antiga até a
fatia 84, e a vista "Arquivos" da tela nova leva até eles. A tela antiga (`/mod-configuracoes/vipClassAdmin/...`, o
`VipClassAdminController` e o item do menu do ERP; a página "Relatório" dela, o resto do D716, saiu na correção da 83) continua
respondendo -- consertada na fatia 82, pela mesma regra (`RegraDoCursoVipClass`) e pelo mesmo serviço (`VipClassService`, as gravações numa transação, com o
grafo travado): as duas não discordam no que gravam. Convivem com diferenças: a antiga não tem "Quem fez" no lugar dela, a busca
por nome dela não filtra (achada no D716), ativar ainda é pelo `<select>` do editar, e ela não mostra o alcance antes de desativar. O
desenho põe a administração na tela nova (a resposta 144 (a)) e manda o relatório duplicado sair -- ele saiu na correção da 83 (o
D716), e não depende desta resposta --, mas não diz quando a tela antiga sai; o precedente é o D710 e o D715, que foram ao lote. **A escolha é do dono** (a pergunta no lote, "VIP Class: a administração
antiga sai do ERP depois dos arquivos (a fatia 84), ou fica até o corte?", D722): (a) sai na fatia 84, quando os arquivos chegarem
à tela nova (os endereços antigos da administração com 404, e o item do menu do ERP levando à tela nova; o relatório do menu
Relatórios fica, pelo D306); (b) fica até o corte de cada unidade; (c) fica até ele pedir. Recomendei a (a): são
8 administradores, a antiga e a nova gravam pela mesma regra, e depois da 84 nada fica só na antiga. Nada muda até a resposta.

**05/11/2026, a fatia 84 (VIPCLASS-ARQUIVOS):** os arquivos chegaram à tela nova -- a vista "Arquivos" troca a capa e o fundo do
certificado e inclui e remove os materiais, e deixou de levar à tela antiga. **A resposta da pergunta 313 não chegou** (conferido
pelo coordenador em 05/11), e a tela antiga FICA, como esta entrada manda: os envios dela foram consertados pela mesma regra e pelo
mesmo serviço da API (o D723), e as duas não discordam no que gravam. Depois da 84, nada da administração fica só na antiga; o
que a (a) pediria continua igual (os endereços antigos com 404, o item do menu do ERP levando à tela nova, o relatório do menu
Relatórios ficando). O texto da pergunta 313 no lote ganhou a atualização (na correção da 84): a (a) deixou de ser "sair na
84" e passa a ser uma etapa própria e curta, logo depois da resposta.

**Corrigido em 06/11/2026 (a fatia 95, VIPCLASS-RESPOSTAS; a resposta 313 (a) do dono), no molde da 76e.** A administração
antiga saiu do ERP inteira -- derivado do `module.config.php`, do `Module.php` e do `git grep`: o `VipClassAdminController`
(as 19 ações), a fábrica, o invokable e o alias `vipClassAdmin`, as dez views de `vip-class-admin/`, os oito formulários
que só ela usava (`VipClassAdmin`, `...Aula`, `...Capa`, `...Certificado`, `...CheckpointPergunta`, `...Filter`,
`...Index`, `...Material`), as funções `vipClassAdmin*` do `mod-configuracoes.js` (o `?cache=` de 173 para 174), o item
"VIP Class" de Configurações do `layout.phtml` e a linha do `SessaoAcl::menuAtivo`; no `VipClassService`, os seis métodos
que só ela chamava (o `remover`, a `capa` e o `certificado` pelo envio `arquivo`, as duas tabelas em HTML e o
`removerMaterial` pelo id do POST); na `RegraDoCursoVipClass`, o formulário do curso do ERP (`doFormularioDoErp`,
`conferirFormularioDoErp`, `idsOferecidos`), a marca de correta do `<select>` e as recusas dos envios em HTML. Os
endereços `/mod-configuracoes/vipClassAdmin/...` respondem 404 a todos, o administrador inclusive. **No menu da tela nova,
"Configurações › VIP Class" vira botão** (`SO_NA_TELA_NOVA`: o caminho antigo ficou só como a chave da tela nova).
**FICAM:** o LINK PÚBLICO DOS MATERIAIS (`/vipclass/material-download`, a rota `isconfiguracao-vipclass-material-download`),
cuja ação mudou, sem mudar uma linha, para um controller só dela e sem alias (`VipClassMaterialController`) -- o link
copiado na tela nova, o do botão da tela antiga do aluno e os já compartilhados continuam respondendo 200 com o
`nosniff`; o relatório do menu Relatórios (`VipClassRelatorioController`, pelo D306), com o formulário
`VipClassAdminCheckpointResposta` que a view dele usa; e a tela antiga do ALUNO (`VipClassEduController`), que fica até o
corte (a 309 (b), o D715). Provado no `AdministracaoAntigaDaVipClassSaiuTest` (novo, 5 casos: as 19 ações sem
controller, o link público pela rota literal e só com a ação do material, as views e os formulários fora, o menu e o
JavaScript sem a administração), no `VipClassArquivosTest` (o link público pelo controller novo, 200, o PDF e o
`nosniff`), nos dois bancos; pelo HTTP, os casos 30, 31, 32, 47 e 48 do `ensaiar-cursos-adm-api.php` (404 e nada grava) e
o 44 (o link público, 200); no front, o `ConsoleAbreVipClassAdmin.teste.tsx` (o item é botão) e o `menu-do-erp.teste.ts`.
Saíram com ela os testes da tela antiga (`TelasDoVipClassDoErpTest`, `ArquivosDaVipClassNaTelaAntigaTest`, a VIP Class
do `D199AtivarRemoverPorPostTest` e do `D202EdicaoPelaRotaNoErpTest`, as listas da administração do
`ConclusoesDoCursoApiTest` e as funções antigas do `vipClassDaTelaAntiga.teste.ts`): o que eles provavam da regra e do
serviço continua provado pela API (`CursoApiTest`, `ArquivosDoCursoApiTest`, `RegraDoCursoVipClassTest`) -- na correção da
fatia, dois casos da fatia 82 que tinham saído do `CursoApiTest` sem motivo voltaram (`testMarcarACorretaDesmarcaAOutra` e
`testAPerguntaNuncaFicaSemACorreta`: a troca da correta, o 409 de perdê-la, a resposta de outra pergunta com 404 e a marca
booleana; sem eles, o `respostaDa` sem o recorte da pergunta e a marca não booleana aceita ficavam verdes -- os mutantes C1 e
C2, vermelhos com eles). Os portões:
o `conferir-guarda-do-erp` com a CAPACIDADE no controller novo; o `conferir-escape-nas-views` sem as duas views; o
`conferir-links-mortos` com o piso de 380 para 320 (de 391 para 343 chamadas, com o motivo); o inventário regerado (344
para 326 ações); o `conferir-cobertura-por-par` com o ISConfiguracao de 228 para 220 chamadas. Os mutantes S1 (a rota
antiga viva), S2 (o link público morre junto), S10 (sem o `nosniff`), S11 (o item do menu de volta) e F6 (o item com
link no ERP), vermelhos. Nada se perde: os oito administradores já usam a tela nova, e depois da 84 nada ficava só na
antiga.

**Parcial em 06/11/2026 (a correção da fatia 95, achada na verificação dela): o item do menu do ERP não "leva à tela
nova".** A opção (a) que a 313 escolheu diz que "o item do menu do ERP leva à tela nova"; a fatia tirou o item do
`layout.phtml`, como a 76e fez com o Clientes, e só escreveu o desvio no desenho -- sem perguntar e sem dizer ao dono. O ERP não
tem endereço que abra uma tela dentro do `/novo` (o front não lê o endereço; um `href` para `/novo/` abre o Console na página
inicial, e não a VIP Class), e escolher entre tirar o item, apontá-lo para a página inicial da tela nova ou fazer o endereço
que abre a tela é do dono. **Resta isto, e só isto:** a pergunta foi ao lote ("VIP Class: o item do menu do ERP que devia
'levar à tela nova' saiu do menu", D722) com (a) fica como está, (b) o item volta e abre a página inicial da tela nova, (c) o
item volta e abre direto a VIP Class, com o endereço novo na tela nova (uma etapa própria); recomendei a (c). Até a resposta,
fica como está (o item fora do menu, como no Clientes): nada se perde, e os oito administradores abrem "Configurações › VIP
Class" pela tela nova. O resto da 313 (a) -- os 404, o link público, o relatório e a tela do aluno ficando -- está feito.

**06/11/2026:** a resposta 314 (c) do dono (gravada na página do lote em 07/11/2026 01:12 UTC) vira a fatia 98
(ENDERECO-POR-TELA).

**Corrigido em 06/11/2026 (a fatia 98, ENDERECO-POR-TELA; a resposta 314 (c) do dono; o desenho em
`docs/f3-telas-restantes/endereco-por-tela.md`).** A tela nova aceita `/novo/?tela=<chave>` e abre a tela da chave, com a
guarda do menu (`podeAbrirPeloEndereco`, que acha também as telas de primeiro nível; o `podeAbrirNoMenu` dos atalhos não
mudou), na carga com sessão, depois da entrada automática e depois do formulário; a chave desconhecida e a tela que a pessoa
não pode abrir dão a inicial, com "Essa tela não está no seu menu.", sem dizer qual das duas; o endereço acompanha a tela
ativa por `history.replaceState`, e recarregar fica nela. O item "VIP Class" voltou ao menu Configurações do `layout.phtml`
(entre Usuários e WhatsApp) com o `href` `/novo/?tela=%2Fisconfiguracao%2FvipClassAdmin%2Findex`, para quem tem
`vipclass/admin/ler` -- o `ler` da `/adm/api/cursos`, o mesmo do item da tela nova --, e SÓ onde o ambiente serve o `/novo`
(`ISBase\Util\EnderecoDaTelaNova`): a chave `telaNova.endereco` do `local.php` (a forma da `busca` do passo 7c) ou, sem ela,
a `TELA_NOVA_ENDERECO`, que o `docker-compose.teste.yml` põe em `/novo/` (vale no teste do BVIP depois do próximo
`publicar`); sem as duas, o item não aparece -- a produção, a homologação, o laboratório e a cópia local, onde o nginx do ERP
não serve o `/novo`. O passo de ligá-la na produção está no `docs/procedimento-de-corte.md` ("Depois", o item 7). Os casos:
o `EnderecoDaTelaNovaTest` desenha o layout de verdade (desligada, o item não aparece nem a quem tem o privilégio; ligada,
aparece a quem tem e não a quem não tem, com o endereço certo; o valor configurado conferido; a chave e o privilégio contra o
`menu-do-erp.ts` e o `CursosController`), o `ConsoleLigadoEndereco.teste.tsx` e o "endereço por tela" do `menu-do-erp.teste.ts`.
Os mutantes do item sem a guarda, da chave desligada ignorada, do `if` do layout arrancado e do valor sem conferência,
vermelhos nos dois bancos (o último, no MySQL). **A Carteira de clientes NÃO voltou ao menu do ERP:** a resposta não a pediu, e
a pergunta foi ao lote (o D732).

### D723 — O envio dos arquivos da VIP Class conferia só a extensão, dizia "5mb" para um teto de 20 MB, não tinha teto no material, não cortava o nome e trocava apagando antes de gravar (o DV-9) — REGISTRADO em 05/11/2026 — CORRIGIDO em 05/11/2026

Achado pelo desenho da VIP Class (`docs/f3-telas-restantes/tela-vipclass.md`, DV-9; o número veio no commit da fatia que o
conserta, como o desenho manda). Na administração antiga (`VipClassAdminController`, as abas Capa, Certificado e Materiais):
a capa e o fundo eram "JPG" pela extensão (o PNG com nome `.jpg` passava), com o teto de 20 MB e a frase "Arquivo muito grande,
limite 5mb."; o material era "PDF" pela extensão (o HTML com nome `.pdf` passava), sem teto, com `set_time_limit(999999)` e 1 GB de
memória; o nome enviado ia à coluna `nome_original` (100) sem corte -- o MySQL estrito recusava o maior DEPOIS de o arquivo estar
guardado --; o `Content-Disposition` do link público levava o nome cru; e a resposta dizia "Salvo com sucesso." mesmo quando nada
era salvo. A troca da capa e do fundo apagava a imagem antiga ANTES de gravar a linha (a falha no `flush` deixava o curso
apontando para um arquivo que não existe mais), o recorte que falhava guardava o envio cru, e o material gravava a linha com
`arquivo='temp'`, trocava o nome e só então guardava o arquivo, sem olhar se guardou. Remover o material apagava o arquivo antes
da linha.

**Feito em 05/11/2026 (a fatia 84, VIPCLASS-ARQUIVOS; a F4 do desenho):** a seção "OS ARQUIVOS DO CURSO" da
`RegraDoCursoVipClass` -- `conferirImagem` (o JPEG por `getimagesize`, pelo `RegraDoBanner::medirArquivo`; 5 MB, o peso no
disco), `conferirMaterial` (o `%PDF-` no primeiro KB, pela `RegraDoArquivo::conteudoConfere`; o teto do servidor, o menor entre o
`upload_max_filesize` e o `post_max_size`), `nomeOriginal` (sem o caminho, sem controle, sem o que o `utf8mb3` não guarda, cortado
em 100), `nomeFisicoNovo` (aleatório), `imagemQueSai` (nunca o `padrao.jpg`, que é de todos os cursos) e as recusas da tela
antiga, escapadas; o `VipClassService` com a ordem "guarda, grava, e só então apaga" (`trocarImagem`, com a linha do curso
travada e relida; `incluirMateriais`, todos ou nenhum; `excluirMaterial`, a linha antes do arquivo); a tela antiga pelas mesmas
regras, com a recusa própria do corpo acima do `post_max_size` e o "Salvo" só de quem salvou; o link público com o nome limpo
(`RegraDoArquivo::disposicao`) e o `nosniff`; e a `/adm/api/cursos` com `POST .../capa`, `.../certificado`, `.../materiais` e
`.../materiais/:material/exclusao`, em multipart. O `uploadMateriais`, código morto, saiu (a lista "Sai" do desenho). Provado no
`ArquivosDoCursoApiTest` (18), no `ArquivosDaVipClassNaTelaAntigaTest` (8), em cinco casos novos do `RegraDoCursoVipClassTest`
(um deles lê as frases da tela) e num do `VipClassArquivosTest`, nos dois bancos; os mutantes S1 a S22, vermelhos nos dois; pelo
HTTP, os casos 33 a 48 do `ensaiar-cursos-adm-api.php`, nos dois bancos de contrato.

**O que fica de fora, dito:** o envio da tela antiga e o da API não conferem o token CSRF do formulário (o cookie da sessão é
`SameSite=Lax`, e a API tem a barreira de origem do D290); o material de 87 MB da cópia passa do teto deste ambiente (64 MB no
nginx e no PHP: o nginx recusa com o 413, que a tela nova põe no campo com a frase do limite), e o de produção não está no
repositório -- o arquivo que já existe continua saindo pelo link; e o material no disco local continua lido inteiro pelo
`conteudo` ao baixar (com o armazenamento de objetos e a url pública, ele redireciona -- a decisão técnica 7, a fatia 79).

**A correção depois da verificação em três lentes (05/11/2026):** onze achados (dois deles o mesmo), dois médios e os outros baixos, tratados. (1) A releitura
e a trava da linha do curso no `trocarImagem` não tinham caso: o `testATrocaReleACapaDoBancoNaHora` (a capa gravada por outra
troca no meio é a que sai) e o `testATrocaPrendeALinhaAntesDeRelerEDeGravar` (a releitura por `SELECT ... FOR UPDATE`, e a
gravação com a linha presa, sondada por outra conexão com `NOWAIT`); o `refresh` depois do `find` com a trava SAIU -- o achado o
dava como a única releitura, mas no Doctrine 2.20 o `find` com `PESSIMISTIC_WRITE` re-hidrata o curso já carregado
(`EntityManager::find`, `persister->refresh(..., $lockMode)`), e arrancá-lo deixava o caso da releitura verde. (2) "Todos ou
nenhum" com a guarda que falha no meio: o `testOsMateriaisQueNaoSeGuardamTodosNaoDeixamOrfao` (o primeiro sai, o terceiro nem é
tentado) e, na tela antiga, o `testOsMateriaisQueNaoSeGuardamNaoDizemSalvo`. (3) O fundo da tela antiga que dá certo, na coluna
dele (`testOFundoDoCertificadoVaiAColunaDele`). (4) O nome gravado que não é de arquivo (`temp`): a linha sai sem apagar nada, e a
capa e o fundo assim saem sem endereço (`testONomeGravadoQueNaoEDeArquivoNaoViraCaminho`; na cópia, 1 capa e 1 fundo assim, 0
materiais). (5) Na tela antiga, o material (e a imagem) acima do `client_max_body_size` do nginx ouvia "Erro ao enviar.": o
formulário traz a frase do tamanho (`data-frase-do-tamanho`, pela regra), e o `error` do `mod-configuracoes.js` a mostra no 413
(`?cache=173`; os casos no `vipClassDaTelaAntiga.teste.ts` e o `testOsFormulariosLevamAFraseDoTamanho`). (6) Na tela nova, "a de
antes saiu do servidor" só quando a de antes não era a padrão (`fraseDaImagemTrocada`, pela imagem de ANTES da troca; na cópia, 1
capa e 2 fundos padrão). (7) A frase do tamanho do material era uma cópia da dos arquivos: agora é a mesma (`fraseDoTamanho` e
`limiteEmTexto` do `formularioDoArquivo.ts`), e a dica diz "até 64 MB", como a recusa, e não "64,0 MB". (8) O mutante S18 rodou de
novo contra o texto de agora do `CursoJson` (a pasta em literal): vermelho nos dois bancos. Os mutantes novos, um por vez,
devolvidos pelo sha1, vermelhos nos dois bancos: S18b, T2 (o `find` sem a trava), T3 (a trava depois, sem releitura), T3b (a
releitura solta antes da trava -- só o caso da trava a pega), T4 a T9; o T1 (arrancar o `refresh`) era equivalente, e o `refresh`
saiu; no front, G1 a G8.

### D724 — Os campos das escritas da Contas a Pagar e dos filtros da VIP Class saem crus, sem o campo da casa — REGISTRADO em 05/11/2026 — CORRIGIDO em 08/11/2026

Achado pela re-sincronizacao do /design-sync de 05/11/2026 (pedida pelo dono), nas fotos das previas, e conferido no `App.css`. Nao
ha estilo global de campo: o campo da casa (borda `--linha-forte`, canto `--r`, `padding`, `font: inherit`) so vale dentro de uma
lista de conteineres (`:is(.catalogo-busca, .clientes-busca, ..., .carteira-filtros)`, o conserto de 01/10/2026 e o D322), e cada
tela nova que nao entra nela sai com o campo do navegador: borda preta fina, canto reto, sem altura. Ficaram de fora: (1) as cinco
escritas da Contas a Pagar da fatia 78 (`EscritasDasContas.tsx`: despesa nova, lancar parcelas, pagar, dar por quitada, estornar),
no `.campo` que nao e o de `.clientes-campos` -- a caixa "quitada" tambem e a nativa; (2) na administracao da VIP Class da fatia 83
(`CursosDaVipClass.tsx`), os selects dos filtros, com o "Vale na unidade" cortando "qualquer unidade" ("qualquer unidac") por
falta de largura, e o select da unidade do "Quem fez"; o campo de arquivo da vista "Arquivos" (a 84) e o nativo. Os filtros da
propria Contas a Pagar estao no campo da casa, o que deixa a diferenca a vista na mesma tela. **O dono escolheu enviar as previas
como o app esta e consertar depois** (05/11/2026): as celulas `DespesaNova`, `LancarParcelas`, `PagarAParcela`,
`EstornarOPagamento` (Contas a Pagar) e `ListaParaOAdministrador`, `SoOAdministrador`, `QuemFez`, `OsArquivos` (VIP Class) foram
ao Claude Design assim, com a nota. O conserto: acrescentar os conteineres a lista (ou, melhor, trocar a lista por um estilo de
campo das telas novas, que nao esqueca a proxima), com o caso no `recuosDosRelatorios.teste.ts` ou vizinho, e re-sincronizar.

**CORRIGIDO em 08/11/2026 (a fatia 124, TELAS-NOVAS-ACABAMENTO-E-TESTES).** Conferido antes: a lista do `App.css` seguia sem
as escritas da Contas a Pagar e sem o "Quem fez" da VIP Class. Os filtros da busca de cursos já estavam no `.catalogo-busca`
(na lista); o defeito ali era a trilha de 9rem, que cortava o "qualquer unidade". O conserto, o "melhor" do corpo:

- **um campo da casa para o front inteiro, sem lista:** a regra `:where(input:not([type=checkbox], [type=radio], [type=file], ...),
  select, textarea)`, com as mesmas declarações da lista (borda `--linha-forte`, canto `--r`, `padding`, `font: inherit`) e os
  dois estados (desligado e recusado). O `:where` pesa zero: qualquer regra de tela vence esta, e só o campo que nenhuma regra
  estiliza deixa de ser o do navegador -- como o `button`, que já tinha estilo global. A lista antiga fica (pesa mais, e as telas
  dela foram medidas assim); campo novo não precisa entrar nela;
- **o campo de arquivo** com a letra da casa e o botão de escolher (`::file-selector-button`) com a forma do `button`, também
  com peso zero;
- **a busca de cursos** com a classe `vipclass-busca` e a trilha de 11,5rem da busca de campanhas;
- **as escritas da Contas a Pagar** com o campo enchendo a trilha da grade (`.contas-formulario .campo`), como em
  `.clientes-campos`.

A caixa "quitada" já tinha a tinta da casa (o `accent-color` de toda caixa, desde 01/10/2026) e o `.contas-quitar`; não mudou. Os
casos, no `recuosDosRelatorios.teste.ts` (o CSS lido do disco, sem comentários, porque o `?raw` chega vazio): a regra de todo
campo existe com peso zero e é igual à da lista, o arquivo, a trilha da busca de cursos e a largura das escritas, e as telas
ainda pintam as classes. Mutantes S1 a S5 (a regra global presa a um contêiner, a trilha de volta a 9rem, a classe tirada da
tela, a largura das escritas tirada, o botão do arquivo sem o `padding`) vermelhos e devolvidos pelo sha1. **Não visto num
navegador**: a regra global muda todo campo que hoje sai cru, e não só os da Contas a Pagar e da VIP Class -- quais são, só o
navegador diz. As prévias do `.design-sync` desenham os componentes com o `App.css`, e mudam sozinhas; a re-sincronização do
/design-sync é do dono (e está parada pelo projeto movido). A verificação (08/11/2026) achou o `frontend/.design-sync/NOTES.md`
ainda dizendo que o D724 e o D725 estavam "ADIADOS PELO DONO": a nota ganhou o registro do conserto, que a recaptura pedida
"depois do conserto" ainda não foi feita, e que a regra do campo vale para toda célula com campo, e não só para as três. CORRIGIDO.

### D725 — A Capacidade falhada com "todas as unidades" mostra a unidade da sessão no cabeçalho — REGISTRADO em 05/11/2026 — CORRIGIDO em 08/11/2026

Achado pela mesma re-sincronizacao. `Capacidade.tsx` escolhe o cabecalho por `dados ? (recorte.unidade?.nome ?? 'Todas as
unidades') : (unidade ?? 'Unidade')`: sem os dados -- carregando ou falhado --, vale o nome da unidade da sessao, mesmo com o
administrador tendo pedido a rede. Na celula `Falhou` da previa, "VIP CENTRO" acima do titulo e "todas as unidades" no seletor. O
conserto: sem os dados, o cabecalho segue o filtro pedido (a rede quando a escolha e "todas"). O dono escolheu enviar e consertar
depois (05/11/2026). Vale conferir os outros relatorios com a escolha da unidade com a rede pelo mesmo padrao.

**CORRIGIDO em 08/11/2026 (a fatia 124, TELAS-NOVAS-ACABAMENTO-E-TESTES).** Conferido antes: o padrão seguia na Capacidade e
nos Royalties, e o grep achou mais treze telas com a escolha da unidade e o mesmo `(unidade ?? 'Unidade')` sem os dados
(Atendimentos, Carteira, Cota-parte, Datas da equipe, Descontos, NPS, Painel, Relatório de Caixa, Profissional Parceiro,
Retirada, Programa de Indicação, Produtos vendidos, Vendas canceladas). A regra nova `deOndeSemOsDados(filtro, unidade)`
(`frontend/src/telas/deOndeDoRelatorio.ts`) dá o cabeçalho sem os números pelo PEDIDO: "Todas as unidades" quando a escolha é a
rede; o nome da escolhida quando a lista de unidades já chegou, e "Unidade" sem ela (nunca o da sessão); e a unidade da sessão
quando a escolha é ela. As quinze telas passam a usá-la. Com os números, segue o recorte deles, como antes (relendo, o cabeçalho
diz de onde são os números na tela). Os casos: o `deOndeDoRelatorio.teste.ts` (a regra, e as quinze telas lidas do disco -- a
lista DERIVADA de quem declara a prop `filtroDeUnidade`, sem comentários), e carregando e falhado com a rede pedida no
`Capacidade.teste.tsx` e no `Royalties.teste.tsx`. A célula `Falhou` da prévia da Capacidade passa a mostrar "Todas as
unidades" sem mudar a prévia. Mutantes U1 a U6 (a rede tirada da regra, a escolhida sem nome caindo na da sessão, a Capacidade,
os Royalties, o NPS e o Painel de volta ao `(unidade ?? 'Unidade')`) vermelhos e devolvidos pelo sha1. Seis arquivos de caso
esperavam o "Todas as unidades" do cabeçalho como sinal da resposta da rede; passam a esperar também o carregando sair (o D471).
CORRIGIDO.

### D726 — O deploy do teste aceita o esquema novo sem aplicá-lo, e o documento mandava "sem ALTER nenhum" quando a entidade já mapeava a coluna — REGISTRADO em 05/11/2026

Achado pelo dono em 05/11/2026, no servidor de teste do BVIP, depois do deploy da fatia 84: o relatório de caixa, o de
descontos, o de produtos/serviços e outros respondiam "A API respondeu 500 em text/html; charset=UTF-8, e não JSON". A causa
(achada aqui pela leitura, e **confirmada pelo dono no mesmo dia**: com as sete migrações rodadas à mão no banco do teste, os
relatórios voltaram a funcionar): o banco do teste sem as
colunas das migrações de 03 e 04/11/2026, que as entidades já mapeiam -- `Caixa` (`caixas.valor_contado`), `Venda`
(`vendas.cancelado_em`, `cancelado_por`), `VendaProduto` (`custo_unitario`, `desconto_lancado_por`, `vendedor_do_pacote`,
`cota_do_vendedor`), `Produto` (`produtos.cota_parte_pacote`), `ProdutoEstoque` (`motivo`) e `Indicacao` (`porta`). Com a
entidade mapeando a coluna, toda consulta que a carrega pede a coluna, o MySQL responde "Unknown column" e o PHP devolve a
página de erro: os relatórios, e provavelmente o PDV e o caixa. O `deploy-teste.sh` nao roda migração nenhuma: ele recusa
a troca de código quando um `scripts/migracao-*.sql` muda, e passa com `ACEITAR_ESQUEMA=sim`, que so registra a decisao.
E o `docs/ambiente-teste.md` dizia, a cada migração desde 03/10, que o `ACEITAR_ESQUEMA=sim` era "o certo, sem ALTER
nenhum" -- verdade enquanto toda coluna nova era lida pela `ColunaNoBanco` (o D473), e falso desde que as fatias de 03/11
passaram a mapear as colunas. Nenhum portão compara o mapeamento com o banco do teste. **Corrigido no documento em
05/11/2026**: a seção do esquema diz quando o "sem ALTER" deixou de valer, manda rodar as sete migrações (idempotentes) e
traz a conferência que só lê -- `orm:schema-tool:update --dump-sql` filtrado para as colunas e tabelas que faltam, vazio na
cópia local --, depois de todo `atualizar` que recusar pelo esquema e de todo `base`. Fica aberto: o deploy aplicar ele
mesmo as migrações idempotentes que mudaram entre os dois commits, ou ao menos rodar a conferência no `conferir` e recusar
publicar com o esquema atrás do código (mexe no `deploy-teste.sh`, e a decisão é do dono); e a regra para quem escreve
migração nova: coluna mapeada pela entidade pede a migração no teste e no passo 0 do corte; coluna lida pela `ColunaNoBanco`
pode esperar a cópia exportada.

### D727 — O erro 500 em HTML diz ao usuário que "a sessão do ERP caiu" — REGISTRADO em 05/11/2026 — CORRIGIDO em 08/11/2026

`frontend/src/api/cliente.ts` (o `pedir`, e a mesma frase no download de arquivo): quando a API manda algo que não é JSON,
a mensagem é "A API respondeu <status> em <tipo>, e não JSON. Se veio HTML, provavelmente é a tela de login: a sessão do ERP
caiu." Para a tela de login, que responde 200 em text/html, está certa; para um 500 (o fatal do PHP, a exceção do banco), ela
manda o usuário entrar de novo, e entrar de novo não resolve -- foi o que o dono leu no servidor de teste em 05/11/2026 (o
D726), em vários relatórios. O conserto: separar pelo status -- 200 em HTML é a sessão; 5xx em HTML é "o servidor falhou ao
montar a resposta", sem a sugestão do login --, com o caso no teste do `cliente` e a frase conferida nas telas que a mostram.

**CORRIGIDO em 08/11/2026 (a fatia 124, TELAS-NOVAS-ACABAMENTO-E-TESTES).** Conferido antes: as duas frases seguiam no `pedir` e
no download. A frase passa por `fraseDoQueNaoVeio(status, tipo, esperado)` (`frontend/src/api/cliente.ts`): o 5xx diz "A API
respondeu 500 em text/html, e não JSON. O servidor falhou ao montar a resposta.", sem a sugestão do login; o resto (o 200 da
tela de login, que é o caso do D146) segue com a frase da sessão. No download, o 5xx com corpo que não é JSON (a página de erro
do PHP) passa a dizer o mesmo, no lugar de "sem o corpo de erro que a convenção pede" -- que estava certo, mas era técnico --; o
5xx vazio e o JSON sem o envelope seguem com a frase da convenção. O código do erro não mudou (`resposta_nao_e_json`,
`resposta_nao_e_arquivo`, `sem_corpo_de_erro`), e o grep pela frase nos testes achou só o `cliente.teste.ts` e os casos do 200
(Pacotes, Sorteios, Arquivos), que seguem. Os casos, no `cliente.teste.ts`: o 500, o 502 e o 503 em HTML no `pedir`, o 200 e o
500 em HTML e o 500 vazio no `baixar`, e a frase pura. Mutantes C1 a C3 (sem o ramo do 5xx, o download sem ele, o `pedir`
mandando sempre o 200) vermelhos e devolvidos pelo sha1. Fica de fora o 4xx em HTML (um 404 do servidor web, por exemplo),
que segue com o palpite da sessão: o corpo pedia só o 5xx. CORRIGIDO.

### D728 — O ensaio geral do corte não roda as migrações do passo 3 que apagam linha, e a carga da cópia para nas conclusões da VIP Class — REGISTRADO em 06/11/2026 — PARCIAL em 06/11/2026: as duas cópias locais limpas, a carga não acha mais par repetido; o ensaio segue sem as migrações do passo 3 que apagam linha

Achado pela fatia 85 (VIPCLASS-CONCLUSAO-UNICA), em 06/11/2026, lendo `scripts/ensaiar-corte.sh` sem rodá-lo.

- A fase `passo3` do ensaio roda só o dicionário de CEP e a migração do Líder (`scripts/ensaiar-corte.sh`, `passo3()`); as três
  migrações do passo 3 que apagam linha e criam chave única -- `migracao-privilegios-unicos.sql`,
  `migracao-sorteios-vendas-unicos.sql` e, desde 06/11/2026, `migracao-vipclass-conclusao-unica.sql` -- não estão nele. As duas
  primeiras não faziam falta porque a cópia local já as recebeu (as respostas 125 (a) e 218 (a)), e a tabela chegava limpa.
- A das conclusões ainda não está na cópia: a 213 (a) autoriza a limpeza nas duas cópias locais, mas a fatia 85 não a rodou (o
  D372). O `schema()` do ensaio regera o `schema.sql`,
  que tem a chave (`$unicidades`), e a `carga()` (`migrar-dados.sh`) copia a cópia, com os 167 pares: o `COPY` de
  `vipclass_checkpoints_usuarios` é recusado, e o ensaio para ali. O ensaio roda o passo 3 no `BANCO_MYSQL`, que por padrão É a
  `franquiabv_dev`: pôr a migração na fase `passo3` seria apagar as 191 linhas da cópia ao ensaiar -- o que a 213 (a) já autoriza, mas escondido dentro de um ensaio.
- **O conserto:** a migração roda nas duas cópias uma vez (o coordenador, pela 213 (a)), e o ensaio passa sem mudar; enquanto
  isso não acontece, a fase `passo3` do ensaio passa a rodar as migrações do passo 3 num clone, ou a carga do ensaio fica sem esta
  chave. Não rodei o ensaio (a fatia não o roda); o que está acima é leitura.

**06/11/2026, o coordenador:** a limpeza rodou nas duas cópias locais (o D372, com os números e a conferência par a par), e as duas têm a chave; a carga da cópia não tem mais par repetido para o `COPY` recusar. Não rodei o ensaio do corte (ele chama o `migrar-dados.sh`, que é do dono). **Resta** a outra metade: a fase `passo3` do ensaio continua sem as três migrações que apagam linha, e uma quarta, numa cópia nova que ainda não as recebeu, pararia do mesmo jeito.

### D729 — A tela de uso dos assistentes pede o privilégio da agenda, e o menu pede o de usar assistentes: 60 pessoas veem o cartão e levam 404, e 51 abrem sem ter o menu (o Dβ) — REGISTRADO em 06/11/2026 — CORRIGIDO em 06/11/2026 — DECIDIDO em 06/11/2026 (315 a): como está; só "Assistente IA - Usuários"

Achado pelo desenho dos Assistentes (`docs/f3-telas-restantes/tela-assistentes.md`, o Dβ da seção 2.4; o número veio no
commit da fatia que o conserta, a 86, ASSISTENTES-API-DE-USO). O item "Assistentes VIP" do menu pede
`assistentes/usuarios/ler` ("Assistente IA - Usuários", `layout.phtml`; `menu-do-erp.ts`), e o `AssistentesUsuariosController`
pedia `agendas/visualizar` -- o `ler` na lista e o `escrever` no abrir (`getAcesso()` sem argumento) --, copiado da agenda
(o próprio controller dizia `$this->controller = 'agendas'`). Medido no desenho, na cópia (Q12): 60 pessoas viam o cartão e
levavam 404 ao abrir (53 Gerente unidade e 7 Colaborador), e 51 abriam pela URL sem ter o item no menu (45 Colaborador caixa,
5 Líder e 1 Colaborador).

**Feito em 06/11/2026 (a fatia 86; a F1b do desenho):** o construtor passa a `modulo = 'assistentes'` e
`funcionalidade = 'usuarios'`, e o abrir pede o `ler`, como a lista: quem vê o cartão abre o assistente do cartão, e o recorte
do D340 continua decidindo qual. É a recomendação A da P3 do desenho ("só usar assistentes"), que a tabela "O que ficou para
depois" de `docs/f3-telas-restantes/telas-consolidado.md` manda seguir até o dono dizer outra coisa; a volta é uma linha. A API
nova (`/adm/api/assistentes`, a F2) pede o mesmo privilégio. Como a P3 nunca tinha ido ao lote, ela foi, na verificação da
fatia, como "feito; quer voltar?" (`perguntas-do-lote.md`, "Assistentes: quem pode usar um assistente"): se o dono escolher a B
(usar assistentes **e** escrever na agenda), a tela antiga e a API passam a pedir os dois, uma guarda a mais em cada.

**Provado** no `AssistentesPelosControllersTest`, com o controller montado com o módulo e a funcionalidade do construtor de
verdade (pela fábrica; até aqui o caso os escrevia à mão, e trocar o privilégio no construtor passava verde): quem só tem o
`ler` de usar assistentes vê os cartões e abre os mesmos; quem tem a agenda (ler e escrever) e não pode usar assistentes leva
404 na lista e no abrir. Os dois mutantes -- o construtor de volta à agenda, o abrir pedindo o `escrever` -- vermelhos nos dois
bancos.

**O efeito na rede**, lido do desenho e não remedido: as 60 pessoas passam a abrir; as 51 deixam de abrir (50 delas não viam
cartão nenhum). Quem administra continua passando sem linha (o grupo administrador).

**06/11/2026: decidido pela resposta 315 (a) do dono** (gravada na página do lote em 07/11/2026 01:13 UTC, sem observação):
"Fica como está: só 'Assistente IA - Usuários'". O código da correção acima fica como está; nada muda.

### D730 — O login do ERP não conta tentativas: a senha pode ser tentada sem limite, pela página antiga e pela tela nova — REGISTRADO em 06/11/2026

Achado pelo desenho da entrada no `/novo` (`docs/f3-telas-restantes/tela-entrada.md`, o DE-1 da seção 1), e conferido pela
fatia 94 (ENTRADA-NO-NOVO), em 06/11/2026: nenhum débito tinha o assunto no título (o D286 trata do código de resgate, e diz
de passagem que "não há limite de tentativas em lugar nenhum").

- `AutenticacaoController::entrarAction` confere a senha a cada POST, sem contar as recusas por login, por endereço ou por
  tempo, e responde na hora. Quem tem um login (eles seguem um padrão: `user<id>` na cópia, nomes na produção) pode tentar
  senhas sem freio; a única demora é a do `password_verify`.
- A fatia 94 NÃO MUDOU ISSO: o formulário do `/novo` manda o mesmo POST para a mesma porta, e o risco é o de antes -- nem
  maior, nem menor. O `POST /adm/api/sessao/retomar` não confere senha (só o cookie que o ERP já aceitaria) e não abre
  porta nova para tentativa.
- **O conserto, quando o dono decidir:** contar as recusas por login e por endereço numa janela (uma tabela pequena, ou o
  cache do servidor) e, passado o teto, recusar com a mesma frase por um tempo -- sem dizer que o login existe (a regra do
  D349: a frase não pode virar oráculo). É mudança de regra de entrada para toda a rede, e o teto e a janela são do dono.

### D731 — O pacote sem validade sai vendido já vencido: a venda grava 31/12/1969 como validade das sessões, e a tela nova mostra o mesmo pacote como "sem prazo" — REGISTRADO em 06/11/2026

Achado pela verificação da fatia 97 (PACOTES-PLANILHA-COLUNAS), em 06/11/2026, ao conferir o que a coluna "Validade (dias)"
da planilha dos pacotes deveria mostrar quando a validade está vazia. Nenhum débito tinha o assunto (procurados "1969",
"sem prazo" e `pacote_validade_dias`).

- A coluna `produtos.pacote_validade_dias` aceita NULL (no MySQL e no PostgreSQL), embora a entidade diga
  `nullable=false`. O cadastro de hoje já exige a validade nas duas telas -- o `PacoteFilter` da antiga (`required`,
  `NotEmpty`) e a `RegraDoPacote` da nova (um inteiro de 1 a 36.500) --, mas sobra pacote antigo sem ela: **1 dos 239
  pacotes da cópia** (contagem no `franquiabv_dev`, só `COUNT`).
- **Três leituras diferentes do mesmo vazio:** a tela nova do cadastro mostra "sem prazo" (`frontend/src/telas/Pacotes.tsx`,
  a função `validade`); o formulário antigo mostra o campo vazio; e a venda (`VendaPagamentoService::salvarPacotes`, a linha
  do `setValidade`) monta `strtotime(date('Y-m-d') . ' + ' . null . ' days')`, que é `false` -- e `date('d/m/Y', false)` é
  **31/12/1969** (conferido em PHP no contêiner). As sessões do cliente nascem vencidas.
- Na cópia, nenhuma sessão de cliente tem validade antes de 1971 (o único `clientes_pacotes` ligado ao pacote sem validade
  tem outra data): o defeito está armado, não disparado.
- **A planilha da fatia 97 não escolhe:** a célula da validade vazia fica em branco (nem zero, que seria "vence no dia", nem
  "sem prazo"), e o comentário de `PlanilhaDosPacotes::linhas` remete a este débito.
- **O que o vazio quer dizer é regra de negócio, do dono:** a pergunta foi ao lote (`TELAS-RESTANTES [vendas]`, "Pacote sem
  validade"). O conserto depende da escolha -- ou a venda grava as sessões sem vencimento (e a tela nova já está certa), ou a
  venda recusa o pacote sem validade até alguém preenchê-la (e a tela nova troca "sem prazo" por um aviso). Nenhuma das duas
  apaga linha.

### D732 — Os itens do menu do ERP que saíram para a tela nova não levam a ela: a Carteira de clientes saiu de Relatórios sem destino — REGISTRADO em 06/11/2026; a pergunta foi ao lote

Registrado na fatia 98 (ENDERECO-POR-TELA), em 06/11/2026. Até ela, o ERP não tinha endereço que abrisse uma tela dentro do
`/novo`, e cada tela antiga que saiu do ERP levou junto o item do menu dele: a Carteira de clientes (o Clientes antigo saiu na
CA4, a fatia 76e, 04/11/2026; o item "Clientes" do menu Relatórios do `layout.phtml` saiu com ele), as Datas da equipe (o
Aniversariantes, a DE2), a Capacidade × realizado (o Potencial, a CR2), o Profissional Parceiro e a administração da VIP Class
(a fatia 95). Quem usa o ERP antigo deixou de achar essas telas pelo menu dele: só pela tela nova.

A fatia 98 fez o caminho -- `/novo/?tela=<chave>`, e o `ISBase\Util\EnderecoDaTelaNova::doItem` para o item do ERP, com a
chave e o privilégio da tela, escondido onde o `/novo` não é servido -- e o usou só na VIP Class, que é o que a resposta 314 (c)
pediu. A resposta cita a Carteira como exemplo ("o mesmo caminho serviria às outras telas que saírem do ERP, como a Carteira de
clientes"), mas não pede que ela volte; voltar com um item é escolha do dono. **A pergunta foi ao lote**
(`TELAS-RESTANTES [fila]`, "O item Carteira de clientes do menu Relatórios do ERP volta, abrindo a Carteira na tela nova?"),
recomendando que sim. Com a resposta, o conserto é o mesmo da VIP Class: o item no `layout.phtml` com o `doItem`, a chave
`/isrelatorio/rel-clientes/index` do `menu-do-erp.ts` (a `CAMINHO_DA_CARTEIRA`) e o privilégio e a guarda da política da
Carteira (a `carteira` do `PoliticaDoRelatorio`, o `relatorioCarteira` da sessão), com o caso do layout desenhado. As outras três
telas da lista acima que ainda não voltaram (as Datas da equipe, a Capacidade × realizado e o Profissional Parceiro) ESTÃO NA
PERGUNTA, como a opção (c), "a Carteira e as outras três voltam" (a verificação da fatia 98, 06/11/2026: o texto dizia que elas
entravam "só se o dono quiser", e a pergunta não as citava -- o dono não teria como querer o que não lhe foi mostrado). Com a
(c), o conserto é o mesmo para cada uma, com a chave, o privilégio e a guarda do item dela no `menu-do-erp.ts`.

### D733 — O "Esqueci minha senha" diz se o e-mail tem conta: "O e-mail informado não está cadastrado." responde quem é cliente do sistema, pelas duas telas — REGISTRADO em 06/11/2026; a pergunta foi ao lote

Achado pela fatia 99 (REDEFINIR-SENHA-NO-NOVO, 06/11/2026), ao pôr a página de pedir o link dentro do `/novo`. Nenhum débito
tinha o assunto (procurados "não está cadastrado", "enumera" e o `senhaRedefinir`; o D349 trata do par, e o D730 do limite de
tentativas do login).

- `AutenticacaoController::senhaRedefinirAction` responde ao POST de `/autenticacao/senha/redefinir`, sem sessão, com três
  frases diferentes: "O e-mail informado não está cadastrado." (nenhum ativo com o e-mail), a frase do par (dois ativos, o D349)
  e "Obrigado! Confira (<e-mail>) para obter o link..." (um ativo: o e-mail sai). **Quem tem uma lista de e-mails descobre,
  um por um, quais são de alguém do sistema** -- e de quem tem dois cadastros. Cada acerto ainda manda um e-mail de verdade à
  pessoa (e troca o link que ela tinha, o D172).
- **Não há limite de pedidos**, como no login (o D730): a lista pode ser inteira.
- **A fatia 99 NÃO MUDOU ISSO:** a tela nova manda o mesmo POST à mesma porta e mostra a frase do servidor sem reescrita -- a
  página antiga usa a mesma porta, e mudar a frase é mudar as duas. O risco é o de antes, nem maior nem menor. O caso
  `RedefinicaoDaSenhaNaTelaNovaTest::testARecusaDoPedidoContinuaComoEra` prende a frase de hoje: quem consertar o vê vermelho
  e o troca junto.
- **O conserto, se o dono escolher a frase única:** o pedido responde sempre a mesma coisa -- "Se o e-mail estiver cadastrado,
  o link chega em instantes." --, com o e-mail saindo só quando há um ativo, e o par deixando de se anunciar a quem não sabe a
  senha (o pedido do par não manda nada, e o administrador o acha pelo relatório do D349). As duas telas mudam juntas, porque
  a frase é do servidor. O e-mail vazio pode continuar com a frase dele (não diz nada de ninguém).
- A pergunta foi ao lote: `TELAS-RESTANTES [seguranca] (06/11/2026) -- "Esqueci minha senha" diz se o e-mail tem conta (D733)`.
  **Na verificação da fatia 99 (07/11/2026)** ela ganhou o caso do par, que faltava ao dono para pesar a (a): com a frase única,
  quem tem o e-mail em dois cadastros ativos lê "o link chega em instantes" e nada chega, sem pista na tela; e uma opção (c),
  a frase única menos para o par (que continua com a frase de hoje, e continua descobrível de fora). A recomendação segue a
  (a), com o custo escrito.

### D734 — O finalizar soma ao `valor_final` do caixa o recebido menos o troco, e o recálculo e o cancelamento contam o recebido inteiro: entre dois recálculos a coluna fica abaixo do saldo pelo troco — REGISTRADO em 07/11/2026

**Achado na verificação da fatia 101 (FINALIZAR-E-CANCELAR-SEM-DINHEIRO-EM-DOBRO), lido no código e contado na cópia, só com
SELECT.** A verificação o leu ao contrário -- *"o cancelamento tira do caixa o recebido inteiro, troco incluído"* --, e o
código diz outra coisa: o saldo do caixa, pela convenção da casa, **não desconta o troco** (`ContasDoCaixa::saldo`, *"inicial +
recebido - retiradas ativas"*, a mesma conta de `CaixaService::atualizar`, o recálculo que a retirada, o fechamento e a tela de
fechamento fazem sob a trava; o S16 de `docs/f3-relatorios.md`); a gaveta desconta o troco à parte (`ContasDoCaixa::gaveta`,
`VendaRepository::totalTrocoPorCaixa`). O cancelamento (`VendaService::cancelar`, desde a fatia 101 por
`CaixaRepository::somarAoSaldo`) tira o recebido inteiro -- o que o recálculo tiraria. **Quem destoa é o finalizar**: o
`$paraOCaixa` de `VendaPagamentoService::gravarAteAVendaFechada` soma o recebido MENOS o troco quando o troco não tem destino
(com destino, o crédito do cliente ou a gorjeta, o recebido inteiro), desde a importação do ERP.

**O efeito:** a coluna `caixas.valor_final` (um cache, como diz o `CaixaJson`) fica abaixo do saldo pelo troco de cada venda
finalizada desde o último recálculo, e o próximo recálculo a acerta. Venda de 80 paga com 100, troco de 20 devolvido: o
finalizar soma 80, o recálculo contaria 100; cancelada antes de um recálculo, a coluna sai 20 abaixo de onde estava, e o
recálculo seguinte também a acerta. Nada sai a mais da gaveta. Quem lê a coluna sem recalcular: a coluna "valor final" do
relatório de caixas do ERP (`is-relatorio/caixas/index.phtml`), o `valorFinalRegistrado` do `CaixaJson` e a conferência da
retirada na tela antiga (`CaixasController::validarFormRetirada`, antes do serviço, que recalcula sob a trava). O caixa já
fechado foi recalculado e carimbado no fechamento; a venda que termina depois dele (o estado aceito do D228) soma o recebido
menos o troco ao carimbo, e o cancelamento depois dele tira o recebido inteiro.

**Na cópia (07/11/2026, só contagem):** 2.358 vendas finalizadas com troco sem destino (`valor_troco > 0`; com destino o
finalizar o zera), R$ 48.074,19 de troco; 279 canceladas com troco, R$ 13.078,41. Não medido: quantos caixas têm hoje a
coluna diferente do recálculo.

**O conserto** é o finalizar somar o recebido inteiro, como o recálculo e o cancelamento -- uma linha, mas muda o número que a
coluna mostra entre recálculos em todas as lojas e os casos que contam o caixa depois do finalizar (o D228 e o D321), nos dois
bancos: uma fatia técnica própria. Não é escolha do dono: a convenção do saldo está escrita (o S16) e as três outras contas a
seguem.

### D735 — As linhas de `confirmacoes` não têm prazo de guarda: a tabela só cresce — ESPERA O DONO em 07/11/2026

**Achado na fatia 103** (IDEMPOTENCIA-DAS-ESCRITAS-DE-DINHEIRO; o D162 e o D285). Cada pagamento da comanda, compra e
devolução de crédito confirmados pela tela nova deixam uma linha em `confirmacoes`, com a primeira resposta inteira (no
pagamento, a comanda toda em JSON); desde a correção da fatia, a quitação do fiado também. Os nomes dentro da resposta,
na cópia anonimizada, são o **D736**. A nova tentativa precisa dela por minutos; ninguém mais a lê. Nada a apaga: apagar
linha é decisão do dono, e a fatia não decidiu. A conta: a rede faz perto de mil vendas por dia (407.544 de 2025 até
31/08/2026, a medida do D28), e cada uma com a tela nova é ao menos uma linha -- algumas centenas de milhares por ano, com
a resposta dentro (não medido o tamanho médio na produção).

**A pergunta** está no lote: "TELAS-RESTANTES [vendas] (07/11/2026) -- Quanto tempo guardar o número de cada confirmação
(D735)". O conserto, depois da resposta: uma rotina diária que apaga as linhas mais velhas que o prazo (o índice
`IDX_CONFIRMACOES_data` já existe para ela), nos dois bancos, com caso.

### D736 — A tabela `confirmacoes` guarda o nome do cliente e do operador dentro das respostas, e a anonimização da cópia não a limpa — REGISTRADO em 07/11/2026; a pergunta foi ao lote

**Achado na verificação da fatia 103** (IDEMPOTENCIA-DAS-ESCRITAS-DE-DINHEIRO; o D162 e o D285). A coluna
`confirmacoes.resposta` guarda a primeira resposta inteira de cada escrita confirmada pela tela nova, para devolvê-la igual
na nova tentativa. No pagamento da comanda essa resposta é a comanda toda (`ComandaJson::completo`), com o cliente e o
operador como `{id, nome}`; na quitação, o débito com o nome e o telefone do cliente (`DevedorJson::completo`). Depois do
corte, a produção junta essas linhas com nomes reais, e nada as apaga (o **D735**).

**O que falha:** a cópia da produção que passa por `scripts/anonimizar.sh` ou pelo `scripts/exportar-base-teste.sh` (o
`mysqldump` do banco inteiro, o `anonimizar.sql.modelo` e o `endurecer-base-teste.sql`) troca `clientes.nome` por
"Cliente <id>", mas nenhuma linha toca `confirmacoes`, e o `conferir-base-teste.sql` não a confere. Os nomes e telefones
reais ficariam em JSON na cópia de desenvolvimento e na base do teste do BVIP, na primeira cópia exportada depois do corte.
Hoje não há vazamento: a tabela está só nas duas cópias locais, e o `franquiabv_dev` tem 0 linhas nela (SELECT COUNT em
07/11/2026); a produção só a ganha no passo 0.

**Por que não foi consertado aqui:** o conserto natural (esvaziar a tabela na anonimização, e conferi-la no
`conferir-base-teste.sql`) mexe nos cinco arquivos selados da cópia do teste (`ARQUIVOS_DA_COPIA` do
`scripts/deploy-teste.sh`), que não mudam sem o dono. Guardar a resposta sem os nomes quebraria a resposta 87 (a) ("o
servidor responde ao número repetido o que respondeu da primeira vez"): a tela mostra a comanda pela resposta. A pergunta
está no lote: "TELAS-RESTANTES [seguranca] (07/11/2026) -- A cópia anonimizada levaria os nomes guardados nas confirmações
(D736)". O prazo do D735, quando vier, encurta a janela (a cópia leva só as linhas do prazo), mas não a fecha.

**Vale para a próxima tabela** que guarde resposta ou texto livre: nenhum portão cobra que tabela nova com dado pessoal
entre na anonimização (grep por `anonimizar.sql.modelo` em `scripts/conferir-*` e em `tests/` não acha nada).

### D737 — O resgate de pontos não pede "Clientes crédito": a pergunta 99 o citava, e as opções não — ESPERA O DONO em 07/11/2026

**Separado do D123 na fatia 104** (CREDITO-DO-CLIENTE-PEDE-PERMISSAO). A resposta 99 (a) fez comprar e devolver crédito
pedirem "Clientes crédito" nas quatro portas (o ERP e a API). O contexto da pergunta dizia que "comprar crédito, devolver
crédito e resgatar pontos na ficha do cliente passam pela permissão do cadastro", mas o título e as opções falam só de
comprar e devolver, e a tela nova — que a (a) manda seguir — nunca pediu o crédito para o resgate.

**Como está:** `Adm\FidelidadeController::resgatarAction` pede o cadastro (`clienteParaEscrever` sem a segunda
funcionalidade); `ClientesController::programaFidelidadeResgatarPontuacaoAction` (o PDV antigo) não confere privilégio
(o `conferir-guarda-do-erp.php` o tem como OUTRA-GUARDA: o código do aplicativo do cliente ou a senha de um gerente da
unidade, o D88). O caso `D123CreditoPedePermissaoTest::testOResgateSegueSoNoCadastro` fixa isto.

**Medido na cópia (SELECT de contagem, 07/11/2026):** 5 não administradores ativos com "Vendas nova" não têm "Clientes
crédito" — são eles que perderiam o resgate no balcão se ele passasse a pedi-la.

**A pergunta está no lote:** "TELAS-RESTANTES [seguranca] (07/11/2026) -- O resgate de pontos também passa a pedir a
permissão "Clientes crédito"? (D737)". Pela (b), o conserto é o mesmo das outras: o `self::FUNCIONALIDADE_DO_CREDITO` na
chamada de `clienteParaEscrever` do `resgatarAction`, o `getAcesso2` no começo da ação do PDV (e do gerar código), o
`fidelidade.credito` no lugar do `escrever` em `podeResgatar` da `FidelidadeDoCliente.tsx`, e o aviso do corte.

### D738 — A venda guarda um convênio sem nenhum item com o desconto dele: a comanda trocada de cliente, e o item do convênio que sai da venda — ESPERA O DONO em 07/11/2026 — PARCIAL em 08/11/2026 (a 136: a venda revê o convênio na troca de cliente e na saída do item do convênio, a resposta 362 (a); três ramos voltam ao dono)

**Achado ao montar a fatia 132 (CONVENIO-SEGUE-O-ITEM; o D632, a resposta 358 (c) com a (b)); o caminho 2, na verificação
dela.** Desde a 132 a venda segue o convênio do item que o recebe (`VendaProdutoService::aVendaSegueOConvenioDoItem`), e a
letra que o dono escolheu é essa: o item sem o desconto do convênio não mexe no convênio da venda. **A (b) prometia mais:**
o texto dela dizia "Resolve os casos 1 e 2", e o caso 2 da pergunta do D632 era a comanda trocada de cliente que fica com o
convênio do anterior "embora os itens saiam com o desconto do convênio de B, **ou sem desconto nenhum**". A regra só age no
item que recebe o desconto; a metade "sem desconto nenhum" não tem item que a chame, e não foi decidida no lugar do dono.
Sobram dois caminhos em que a venda termina com um convênio sem nenhum item dele:

1. **A troca de cliente sem item do convênio novo.** `VendaService::comandaAbrir`, ao reaproveitar a venda em andamento do
   operador para a comanda de outro cliente (o ramo `else`, a 15 (c) do D384, sem pagamento), troca o cliente e não revê o
   convênio. Se o cliente novo não tem convênio (ou o dele não vale naquele dia, ou só leva produto), nenhum item chama a
   regra, e a venda fecha "do convênio Y" do cliente anterior, sem desconto nenhum dele: conta na lista de vendas do Y e
   atualiza a "última venda" do Y. O mesmo acontece, por outra porta, com a venda que nasce com o convênio do cliente
   (`VendaService::insert`, `comandaAbrir`, `comandaAbrirAgenda` gravam o do cliente se ligado, sem perguntar se vale na
   unidade e no dia) e cujos itens saem sem ele.
2. **O item do convênio que sai da venda (aberto pela fatia 132).** A venda sem convênio, ou com o Y, recebe o serviço com
   o desconto do X e passa ao X; depois o item sai -- tirado (`VendaProdutoService::remover`, o PDV antigo e a API, que não
   tocam no convênio da venda), ou a mesma linha relançada com o cupom vencendo (`alterar`, a troca pelo cupom: a linha
   fica só com o cupom e não chama a regra). A venda fecha "do X" sem item do X, e o convênio de antes se perdeu quando o
   item entrou. Antes da 132, ela ficava sem convênio ou com o Y. Com os itens 1 do X e 2 do Y, tirado o 2, o lançamento
   deixa a venda no Y, e o roteiro do passado (`scripts/acertar-convenio-das-vendas.sql`), rodado depois, a passa ao X --
   o último item do convênio que ela tem.

**Medido na cópia (franquiabv_dev, só SELECT de contagem, 07/11/2026):** 4.903 vendas concluídas desde 2025 guardam um
convênio, têm serviço e não têm desconto nenhum em item nenhum (nem sessão de pacote). É o teto do caminho 1 -- a troca de
cliente e o convênio fora do dia --, e o banco não guarda a história do cliente da venda para separá-los. O caminho 2 só
existe com o código novo.

**Os casos que o fixam (o de hoje, para quem implementar a resposta o ver mudar):**
`D632VendaSegueOConvenioDoItemTest::testPelaApiAComandaTrocadaDeClientePassaAoConvenioDoItem` (a troca, sozinha, deixa o
convênio do anterior) e `testOItemDoConvenioQueSaiDeixaOConvenioNaVendaAteAResposta` (o item tirado e o cupom que vence na
linha relançada deixam a venda com o convênio do item que saiu; o mutante M7, o `remover` que limpa o convênio, o deixa
vermelho). Nada mudou no código por este débito.

**A pergunta está no lote:** "TELAS-RESTANTES [vendas] (07/11/2026) -- A venda que fica sem nenhum item do convênio que
guarda continua com ele? (D738)", com o aviso de que a (b) da 358 prometia resolver a troca.


**Parcial em 08/11/2026 (a fatia 136, CONVENIO-REVISTO-NA-VENDA; a resposta 362 (a) do dono, gravada na página do lote
em 07/11/2026: "A venda revê o convênio quando perde o motivo dele: ao trocar o cliente da comanda, e ao sair da venda o
item do convênio (tirado, ou com o cupom vencendo na linha), a venda passa ao convênio do último item que ficou com o
desconto de um convênio; se nenhum ficou, ao convênio do cliente de agora (ou a nenhum), como se fosse aberta agora").**

- **A regra, uma só, no escritor da 132:** `VendaProdutoService::aVendaReveOConvenio`. O "último item" é o de **maior id**
  entre os que ficaram com o motivo que o sistema escreve (`RegraDoItemDaComanda::eDoConvenioPeloMotivo`: começa
  exatamente por "Convênio ", sem a marca do cupom), e o convênio é o **único** cujo nome o motivo cita, byte a byte
  (`oMotivoCitaOConvenio`: o motivo inteiro, cortado em 100, ou "Convênio <nome>; ..." do balcão) -- o mesmo critério do
  roteiro do passado, para os dois dizerem a mesma coisa. O nome que não bate com exatamente um convênio (renomeado,
  apagado, ou o nome de dois) deixa a venda como está, como o roteiro (a 358 (c)). Sem item do convênio, o da abertura,
  que passou a morar num lugar só (`VendaService::convenioDaAbertura`, sem mudar a conta: o do cliente, se ligado; as três
  aberturas e a revisão leem a mesma; a abertura não pergunta o dia, e a revisão também não).
- **Quem chama (derivado do `module.config.php` e do grep, não da memória):** a troca de cliente -- `VendaService::comandaAbrir`,
  o ramo que reaproveita a venda em andamento (o "Abrir comanda" do PDV antigo, `VendasController::comandaAbrirAction`, e o
  `POST /adm/api/comandas`, `Adm\ComandasController::abrirAction`; só na troca de verdade: o mesmo cliente não mexe), e
  `atualizarCliente` (a venda sem cliente que ganha o do pedido no PDV antigo); a saída do item -- `VendaProdutoService::remover`
  (as duas portas que tiram item: `VendasProdutosController::removerAction` e `Adm\ComandasController::removerItemAction`;
  nenhuma outra escrita apaga linha de `vendas_produtos`), quando um item do convênio sai, também o que vira a sessão do item
  tirado (`passarASessaoDoItemTirado`, o motivo passa ao do pacote); e o relançamento -- `alterar` (a troca pelo cupom,
  `usarCupomAction`, o "+" e o "-"), quando a linha perde o convênio. A comanda da agenda não troca de cliente (acha a
  comanda pelo cliente). O item sem convênio que sai não mexe na venda. O dinheiro não muda: só `vendas.convenio`.
- **Os casos** (`D632VendaSegueOConvenioDoItemTest`, a seção "A revisão", nos dois bancos): o item do convênio que sai pelas
  quatro portas (o serviço, o PDV antigo, a API e o cupom que vence) devolve a venda ao do último que ficou -- três cortes,
  20%, 10% e 20%, sai o terceiro, a venda vai ao de 10% --; sem item que ficou, ao do cliente de agora (outro convênio, sem
  convênio, convênio desligado), tirado ou vencido pelo cupom; o item sem convênio que sai não mexe; a troca de cliente
  pelas duas portas (sem item: o do cliente novo ou nenhum; com o item do anterior: o dele; o mesmo cliente: nada); a venda
  sem cliente que ganha o do pedido; e o nome do último item (o de 100 caracteres, o balcão por cima; o de dois convênios,
  o que não existe e a outra maiúscula ficam). Os dois casos que fixavam o de antes mudaram: a troca agora revê
  (`testPelaApiAComandaTrocadaDeClienteReveOConvenioEOItemOSegue`) e o caso do item que sai deu lugar aos de cima. Na
  verificação, dois casos a mais: a lavagem relançada pelo "+" da tela nova não mexe na venda (a guarda do `alterar`:
  `testALinhaSemConvenioRelancadaNaoMexeNaVenda`), e o corte do convênio que herda a sessão do pacote do corte tirado
  perde o motivo e leva a venda ao convênio do cliente de agora (`testOItemDoConvenioQueViraASessaoDoItemTiradoReveAVenda`).
  **O lançamento e o roteiro dizem o mesmo**, fora dos três ramos de baixo (`AcertoDoConvenioDasVendasTest`): a revisão, nas dez vendas do cenário do roteiro
  que têm item do convênio, dá o convênio que o roteiro dá, e o roteiro depois conta zero a mudar; e o exemplo da pergunta
  (o item do X, depois o do Y, tira-se o do Y), pelo serviço de verdade, deixa a venda no X, e o roteiro a conta "já certa".
- **O que fica, e por quê:** (1) o dia sem convênio na abertura fica como está (a 362 (a) o diz; a (b) não foi escolhida).
  (2) **Três ramos que a 362 (a) não decide voltam ao dono** (a verificação da fatia 136; as três perguntas
  "TELAS-RESTANTES [vendas] (08/11/2026) ... (D738)" no lote), e o código segue o critério do roteiro até a resposta:
  - **o nome do último item que não é de um convênio só** (o de dois convênios -- na cópia, 12 repetem o nome de outro --,
    o renomeado, o apagado): a venda fica como está, e, com o item do Y tirado, fica no Y sem nenhum item dele -- o
    sintoma deste débito. Os casos "fica no do item que saiu (espera o dono)" do
    `testOConvenioDoUltimoItemEOUnicoQueOMotivoCita` fixam o que o código faz hoje, e não a regra;
  - **o "+" ou o "-" de uma linha antiga depois de o convênio do cliente mudar**: a linha relançada sai com o convênio
    novo e a 132 leva a venda a ele (o último lançado, a 358 (b)); a revisão não corre, porque nenhum item do convênio
    saiu; o roteiro, rodado depois, a põe no do maior id. **Basta uma mudança** (o cadastro do cliente, ou a comanda
    trocada para um cliente de outro convênio) com dois itens do convênio antigo -- e não só o convênio indo e voltando,
    como este texto dizia antes da verificação. Não foi medido;
  - **o motivo "Convênio X" digitado à mão no balcão** (as portas passam o motivo cru; só a marca do cupom é recusada):
    a linha é igual à do sistema, e a revisão a conta como item do X -- uma troca de cliente ou um item do convênio tirado
    liga a venda ao X pelo texto. O roteiro faz o mesmo no passado (a 358 (c)). Desde a fatia 71 a linha digitada guarda
    quem digitou (`desconto_lancado_por`), e a do convênio do sistema sozinho não: daqui para a frente as duas se
    separam.
  (3) A comparação byte a byte só pesa no MySQL (a colação da coluna iguala maiúscula e acento): no PostgreSQL a consulta
  já compara exato, e o mutante que a arranca fica verde lá, como devia.
- **A marca é PARCIAL** até as três respostas; o resto do débito (a troca de cliente, o item do convênio que sai, o cupom
  que vence na linha, o item que vira a sessão do tirado) está no código, com caso.

### D739 — Quem pode receber a gorjeta do troco na comanda nova: a lista de todos os ativos da unidade, e não a do PDV antigo — ESPERA O DONO em 07/11/2026 — CORRIGIDO em 08/11/2026 (a fatia 137, a resposta 372 c)

**Achado na verificação da fatia 116 (API-ADM-IDS-E-PDV; o D517).** Na finalização com troco, a comanda nova oferece
"crédito do cliente" ou "gorjeta para <nome>" (`Finalizar`, `frontend/src/telas/Comandas.tsx`), e a gorjeta sai do
caixa em dinheiro, sem estorno. A lista dos nomes é, desde a comanda nova, a de todos os ativos da unidade que não são
administradores, de qualquer grupo e com qualquer "Aparece no PDV" (`GET /adm/api/usuarios` sem a porta, até a fatia 137 `listarQuemRecebeGorjeta`, que saiu com ela). O PDV
antigo oferece outra: só o grupo 1, "Colaborador", com qualquer "Aparece no PDV"
(`VendaPagamentoService::tabelaPagamentos`, `popularCombobox(1, $unidadeId)`). O D517 tirou de quem atendeu quem tem
"Aparece no PDV: não", e a mesma lista ia à gorjeta: a verificação o achou, e a gorjeta voltou ao que era antes da
fatia. Quem pode receber gorjeta é regra de negócio, e nenhum desenho a decide.

**Medido na cópia (`franquiabv_dev`, só SELECT de contagem, 07/11/2026):** 9 ativos do grupo Colaborador com
`visivel_pdv = 0` (o PDV antigo lhes oferece gorjeta; a lista do PDV da comanda nova não os teria). Quantos de outros
grupos a comanda nova oferece a mais que o PDV antigo não foi contado.

**O que está no código hoje:** a lista de antes (todos os ativos da unidade), sem decisão. **E o servidor não confere
quem recebe (lido, não medido):** `Adm\ComandasController::finalizarAction` exige só que o `destinoTroco` não venha
vazio, e `VendaPagamentoService::finalizar` grava a gorjeta com `getReference(Usuario::class, $data['destinoTroco'])`,
o id cru do corpo -- nem a existência, nem a unidade, nem o teto da coluna (o D516). O conserto depende da resposta:
a regra de quem recebe é a mesma conferência que o servidor terá de fazer.

**A pergunta está no lote:** "TELAS-RESTANTES [vendas] (07/11/2026) -- Quem pode receber a gorjeta do troco na comanda
nova? (D739)".

#### CORRIGIDO em 08/11/2026 (a fatia 137, GORJETA-DE-QUEM-APARECE-NO-PDV)

**A resposta do dono, 372 (c):** "Só quem aparece no PDV: a mesma lista de 'quem atendeu'. Quem tem 'Aparece no PDV:
não' deixa de poder receber gorjeta nas duas telas." **Medido antes, no `franquiabv_dev` (só SELECT de contagem):** 9
ativos do grupo Colaborador com `visivel_pdv = 0` (perdem a gorjeta no PDV antigo); 88 ativos de outros grupos com
`visivel_pdv = 1` (passam a poder recebê-la no PDV antigo, que não os oferecia); 2 administradores com unidade e
`visivel_pdv = 1`; das 846 gorjetas de vendas desde 01/01/2026, 47 estão no nome de quem hoje tem `visivel_pdv = 0` e 6
no de quem não é do grupo 1.

**As duas telas montavam "quem atendeu" por caminhos diferentes, e cada gorjeta segue a da sua tela:** o PDV antigo pela
consulta do formulário (`ISVenda\Form\Venda`: ativos da unidade com `visivel_pdv = 1`, de qualquer grupo, os
administradores também); a comanda nova pela porta do PDV (`GET /adm/api/usuarios?porta=pdv`, o `listagemIndex`: os
mesmos critérios e, para quem não é administrador, sem os administradores -- o teto do D204). A consulta do PDV antigo
virou um lugar só, `UsuarioRepository::quemAtendeNoPdv($unidade, $comAdministradores = true)`, e serve ao "quem atendeu"
do formulário, ao seletor do troco (`VendaPagamentoService::tabelaPagamentos`, que era `popularCombobox(1, $unidadeId)`) e
à conferência ao gravar; com `false`, os mesmos critérios com o teto. Na entrega, a porta do PDV (`listagemIndex`)
repetia os critérios numa DQL dela, e um caso só conferia a igualdade nas fixtures; desde a verificação (abaixo), a
porta usa a mesma subconsulta (`QUEM_ATENDE_NO_PDV`), e o caso confere os mesmos ids nas duas sessões. A comanda nova não tem mais a lista à parte (`listarQuemRecebeGorjeta` e `catalogos.gorjeta`
saíram): o `Finalizar` recebe os `colaboradores`, o mesmo pedido de "quem atendeu".

**O servidor confere ao gravar, nas duas portas:** `VendaPagamentoService::gorjetaForaDaLista` (a regra pura
`ISVenda\Regra\RegraDaGorjetaDoTroco`: o destino que não é `cliente` tem de ser um id, inteiro ou só algarismos, da
lista da unidade da venda). O `finalizar` recusa por ela antes de qualquer escrita (logo depois da linha de crédito, antes
do fechamento condicional da venda): nem a venda, nem o caixa, nem o crédito. As portas dizem a frase antes de chamar o
serviço (`RegraDaGorjetaDoTroco::FRASE`): a API com 422 no `destinoTroco` (`Adm\ComandasController::finalizarAction`,
com o teto do administrador), o PDV antigo no aviso (`VendasPagamentosController::validarFinalizarVenda`); e de novo
depois da recusa do serviço (quem saiu da lista entre a conferência e o serviço), no lugar do "Não foi possível
finalizar a venda.". O `getReference` sobre o id cru do corpo deixou de receber o que não está na lista -- de outra
unidade, inativo, o texto "90001003x" ou o número que a coluna não guarda (o resto do D516 que esperava esta resposta).

**Os testes:** `D739GorjetaDeQuemApareceNoPdvTest` (9 casos, nos dois bancos: o serviço recusa sem gravar nada e o
controle com "sim"; o de outra unidade, o inativo, o texto, o número grande; o crédito do cliente como era; a API com 422
e nada gravado, e o gerente, que o grupo 1 deixava de fora, recebendo; o administrador recusado para quem não é
administrador e aceito para quem é; a porta do PDV igual à consulta; o PDV antigo com a frase e nada gravado, e o
controle; o seletor do troco igual ao "quem atendeu"; e quem sai da lista no meio, com a frase nas duas portas); os
vizinhos (832 casos) nos dois bancos; no front, `ComandasLigada.teste.tsx` (a gorjeta pela mesma lista, um pedido só a
`/adm/api/usuarios`; a recusa 422 com a frase), `catalogo.teste.ts` e `Console.teste.tsx` (a fila sem o pedido da
gorjeta). **Os mutantes**, um por vez e devolvidos pelo sha1, vermelhos nos dois bancos: o seletor do PDV antigo de volta
ao grupo 1; o serviço sem a conferência (seis casos, as duas portas); a recusa depois do fechamento da venda (a que
grava pela metade); a porta da API sem a conferência; a do PDV antigo sem ela; a consulta sem o teto do administrador; as
duas frases depois da recusa do serviço; a regra sem a conferência dos algarismos (no PostgreSQL, erro do banco); e, no
front, a lista à parte de volta (o pedido sem a porta).

**O que não mudou:** a lista de "quem atendeu" das duas telas; o crédito do cliente do troco; a retirada do estoque (o
D517, item 2) e o POST escrito à mão do item no PDV antigo (o D517, item 3). **Não visto:** nada num navegador (o coordenador confere a comanda depois).

#### A verificação da fatia 137 (08/11/2026)

Seis achados confirmados; quatro mudaram o código ou o texto, um foi ao dono, e as contas do texto acima já estão
corrigidas (eram "cinco casos" para o serviço sem a conferência, são seis; o texto do caso é "90001003x", e não
"90001000x", também no docblock de `RegraDaGorjetaDoTroco::recebe`; o corpo original deste débito e o item 1 do D517
diziam que a lista à parte da gorjeta existia, e agora dizem que saiu).

1. **A lista que a comanda nova mostra e a que o servidor confere vinham de duas consultas.** A porta do PDV
   (`listagemIndex` com `visivelPdv`) repetia os critérios numa DQL dela, e `quemAtendeNoPdv` tinha outra; a nota da
   fatia pedia "um lugar só decide quem aparece". O cenário do achado (alguém sem grupo) não acontece: `usuarios.grupo` e
   `usuarios.unidade` são NOT NULL com chave estrangeira nos dois bancos. Mas as cópias divergiam no ativo: a porta com
   `?estado=inativo` trazia quem está inativo com "Aparece no PDV: sim". **Agora** os critérios moram em
   uma subconsulta, a constante `UsuarioRepository::QUEM_ATENDE_NO_PDV` (ativo e "Aparece no PDV: sim"; a
   `..._SEM_ADMINISTRADORES` acrescenta o teto do D204), e as duas listas a usam: `quemAtendeNoPdv` e a porta do PDV
   (`usu.id IN (...)`); a unidade é o recorte de quem a usa, e os filtros da listagem (estado, busca, grupo) só
   estreitam. Constante, e não texto montado por um método: o `conferir-injecao-dql.php` conta o literal de consulta
   colado a chamada (a primeira forma, com o método, o levou de 6 a 8 sítios), e a constante da própria classe é
   literal com literal; a linha de base segue 6.
2. **Quem só entra pelo PDV recebia, sem a porta, a lista de antes**, com quem tem "Aparece no PDV: não" e, com
   `?estado=inativo`, os inativos -- nomes que nenhuma tela lhe mostra desde que a comanda nova deixou de pedir a lista
   à parte. A verificação da 116 tinha reaberto esse caminho só por causa da gorjeta. **Agora** quem não tem
   `configuracoes/usuarios/ler` recebe a lista do caixa com ou sem a porta, como na 116 antes da verificação dela.
   Quem tem o cadastro segue vendo a lista inteira sem a porta.
3. **A (c) dá gorjeta no PDV antigo a gerentes, caixas e administradores que aparecem no PDV** (88 ativos de fora do
   grupo 1 na cópia, 2 deles administradores), e o texto da opção não o disse, embora o tenha apontado como defeito da
   (b). O código segue a letra da (c); a pergunta de confirmação está no lote: "TELAS-RESTANTES [vendas] (08/11/2026)
   -- No PDV antigo, gerentes, caixas e administradores que aparecem no PDV podem receber a gorjeta do troco? (D739)".

**Os testes da verificação:** `D517QuemAtendeNoPdvTest::testAPortaDoPdvSoMostraQuemApareceNoPdv` (sem a porta, quem só
entra pelo PDV recebe a lista do caixa) e o caso novo `testAPortaDoPdvNaoTrazOInativo` (o inativo com "Aparece no PDV:
sim" fora da porta, para quem tem o cadastro e para quem só entra pelo PDV, com e sem a porta; o controle: o cadastro sem
a porta ainda o vê); `D517` e `D739` (16 casos) e os vizinhos (833 casos pelo filtro da fatia, 734 pelo da listagem de
usuários) nos dois bancos, saída 0. **Os mutantes**, um por vez, devolvidos pelo sha1, vermelhos nos dois bancos: a
porta de volta à DQL própria (o caso do inativo); a porta só com `?porta=pdv` para quem só entra pelo PDV (os dois
casos da porta); e a subconsulta sem o "Aparece no PDV" -- vermelhas juntas as duas listas, a porta do D517 (dois casos)
e a conferência e o seletor do D739 (seis casos): um lugar só decide.

### D740 — As datas digitadas que vão ao banco sem caminho traçado até o flush: o levantamento que o D448 deixava ao item que o consertasse — REGISTRADO em 07/11/2026

**Achado na verificação da fatia 118 (UNIDADES-TELA-ANTIGA).** O corpo do D448 listou os vizinhos das três datas dele e
deixou o resto "para o levantamento do item que consertar este". A fatia 118 consertou as três (o nascimento do sócio e o
do usuário; a contratação já não tinha o defeito) e a inauguração da unidade, e fechou o D448; o levantamento não foi
feito. Ele fica aqui, para não sair da contagem sem dono.

**O que falta traçar** (a origem do texto e o caminho até o `stringToDate` ou o `stringToDia`, que devolvem `false` e
fazem o `flush` morrer na conversão, com 500): as férias do usuário, a validade do pacote do cliente, as parcelas e os
pagamentos das saídas, a mensagem, a notificação e a fatura de royalties -- nenhuma tem o campo num filtro de formulário.
As telas novas da `/adm/api` têm a conferência delas; o que interessa é o caminho das telas antigas e das rotinas.

**O que os validadores `Date` deixam passar** (medido no PHP do contêiner em 07/11/2026, o `Laminas\Validator\Date` com
o formato de cada filtro, sem banco): o do cliente (`ClienteFilter`, `d/m/yy`) e o do convênio (`ConvenioFilter`,
`d/m/Y`) recusam `1/02/02026`, `31/02/2020` e `2026-02-01`, e aceitam a data sem o zero (`1/2/2026`), que o
`createFromFormat('!d/m/Y')` lê certo. **O do convênio aceita o ano de dois dígitos** (`10/01/90`), que o
`createFromFormat('!d/m/Y')` lê como o ano 90 (`0090-01-10`), e não 1990: o vencimento do contrato gravaria um ano
errado, calado, se a tela antiga do convênio não tiver outra conferência antes -- não traçado (o convênio tem a
`RegraDoConvenio` nas telas novas). O do cliente recusa os dois dígitos.

**A direção** é a do D448 e do D432: cada caminho que grava data digitada pergunta ao `DataHora::dataValida` antes do
`flush`, com a frase de data inválida ao lado da do vazio. Não pede decisão do dono enquanto for só recusar o que não é
data; o ano de dois dígitos do convênio, se a tela antiga o gravar, é defeito pela mesma regra (a data em `dd/mm/aaaa`).

**Lido na fatia 121 (DATAS-NOS-FILTROS, 08/11/2026), e nada levado:** a fatia consertou leituras de filtro (o D447), e as
daqui são escritas, cada uma com a frase e o formulário da sua tela -- o traçado de cada caminho até o `flush` é o trabalho
que falta, e não coube. O `DataHora::dataDoFiltro` novo não serve aqui: troca a data ruim por um padrão, e quem grava tem de
recusar.

### D741 — O ERP antigo grava o valor do POST cru nos campos do catálogo que o formulário não troca pelo conferido: a categoria fora da lista, os valores, os booleanos e a quantidade — REGISTRADO em 07/11/2026

**Achado na verificação da fatia 119 (CATALOGO-CADASTRO-SEM-POST-CRU).** O D679 fez o `insert` dos três serviços do catálogo
(`ServicoService`, `ProdutoService`, `PacoteService`) hidratar só a lista de campos da tela (`CAMPOS_DO_CADASTRO`); o
`atualizar` já usava a `CAMPOS_DA_EDICAO` (o D202). As listas filtram as **chaves**. O **valor** de cada campo da lista ainda
vem do `$request->getPost()->toArray()`: o `novoAction` e o `editarAction` das três telas trocam pelo do `getData()` só o
nome (o D344), o tipo (o D531), a etiqueta e a área (o D536 e o D537) e a cota do pacote (a fatia 74). O resto vai cru ao
setter:
- **a categoria**: o `Select` dela tem o `disable_inarray_validator` (`Form/Servico.php` e `Form/Produto.php`), e o
  `ServicoFilter` e o `ProdutoFilter` só têm `StripTags`, `StringTrim` e `NotEmpty`. Um POST montado à mão com
  `categoria=<b>qualquer</b>` passa no formulário e grava o texto com a marcação, fora de `Arrays::servicosCategorias()` ou
  `Arrays::produtosCategorias()`, em `produtos.categoria` -- a API recusa a mesma coisa (`RegraDoCatalogo::conferir`,
  "Obrigatória, uma de: ...");
- **os valores** (`valor_compra`, `valor_venda`; no pacote `valor`), **o estado**, **o "visível no app"**, **o insumo**, **a
  linha VIP**, **a quantidade inicial** do produto, **os períodos** do serviço e **a validade** do pacote: sem a regra da API
  (os booleanos de verdade, a quantidade inteira de 0 a `MAIOR_QUANTIDADE`, a categoria da lista).

**O que se sabe da gravidade:** não abre privilégio (quem posta já tem o da tela) e não achei saída sem escape (a lista antiga
não mostra a categoria; o `select` e o React escapam). O que não foi traçado: o texto que não é número nem booleano num
campo inteiro ou booleano (provável 500 no PostgreSQL, como o D572 dos Arquivos). **Lido no código, não medido** -- a fatia 119
não rodou nada para este débito.

**Como fechar:** gravar o `getData()` também nesses campos (o molde do D344), e conferi-los pela mesma regra da API: a
`RegraDoCatalogo::conferir`, com a frase no campo. **Cuidado com o item antigo:** a API só confere a categoria ao criar ou
quando ela vem no corpo, porque a maioria do banco não tem categoria (o comentário do `conferir`); o `editarAction` do ERP
manda a categoria sempre, e recusar a gravada fora da lista obrigaria quem edita um item antigo a escolher uma -- o molde do
D110 é poupar o valor já gravado e conferir só o novo ou mudado. Um caso por tela com o valor forjado, contra o banco, nos
dois. Não pede decisão do dono enquanto seguir a regra que a API já aplica.

### D742 — As exportações de vendas da unidade pela API leem a data da URL sem conferir: a v1 não converte a barra que a página promete, e a data torta dá 500 — REGISTRADO em 08/11/2026 — CORRIGIDO em 08/11/2026: as três versões leem a barra e o traço, e a data torta recebe o 400 com a frase

**Achado na verificação da fatia 121 (DATAS-NOS-FILTROS).** O D447 dizia que as consultas do `CronJobService` recebiam datas
montadas pelo código; não é assim. `CronJobController::unidadeVendasAction`, `unidadeVendasV2Action` e
`unidadeVendasV3Action` (a `/api/unidade/vendas`, a v2 e a v3, autenticadas pelo hash da unidade) passam
`fromQuery('inicio')` e `fromQuery('fim')` a `CronJobService::unidadeVendas`, `unidadeVendasV2` e `unidadeVendasV3`, e a
página `/api/docs` da unidade (`ApiDocsController::getUnidadeEndpoints`) promete às três "YYYY-MM-DD ou dd/mm/yyyy".

- **A v1 não converte nada:** manda o texto cru à `VendaRepository::vendasPorPeriodoUnidade`, que compara
  `DATE(ven.dataCriacao) >= :inicio`. Com a barra que a página promete: no MySQL da cópia, `DATE(data_criacao) >= '01/02/2026'`
  dá `1525 Incorrect DATE value` (o 500); no PostgreSQL da cópia (`DateStyle` `ISO, MDY`), `'01/02/2026'::date` é 2 de janeiro
  -- a resposta de outro período, sem aviso -- e `'28/02/2026'::date` dá `date/time field value out of range` (o 500). Medido
  só com SELECT de contagem e de conversão, sem despachar a rota.
- **A v2 e a v3** convertem a barra pelo `stringDate` (pela posição) e conferem a forma `aaaa-mm-dd` por expressão regular;
  a data torta (`1/02/2026`) falha na expressão e o serviço lança `\Exception` -- o 500 no lugar de uma resposta que diga o
  erro (lido, não despachado). A data com a forma certa e o dia que não existe (`2026-02-31`) passa na expressão e vai ao
  banco (não conferido).

Não é filtro de tela, e por isso ficou fora do D447. **A direção**, sem decidir aqui: as três versões conferem `inicio` e
`fim` pelas duas formas que a página promete (o `dataValida` para a barra, o `checkdate` para o traço), convertem para
`aaaa-mm-dd` antes da consulta, e respondem um erro de pedido (400 com a frase, no JSON) no lugar do 500. Quem consome a
rota é o sistema de cada unidade, fora do repositório: o que ele manda hoje não está medido, e a forma do erro muda o
contrato com ele -- vale conferir com o dono antes de trocar o 500 por outro código.

#### CORRIGIDO em 08/11/2026 -- as três versões leem a barra e o traço, e a data torta recebe o 400 com a frase (a fatia 138, RESPOSTAS-DE-08-11-API-E-CADASTRO)

**A resposta 374 do dono** (08/11/2026) foi a **(a)**: *"as três versões passam a entender a barra e o traço; a data que não
existe recebe a resposta 'pedido inválido' (o código 400) com a frase do erro, no lugar do erro do servidor."*

- **Uma leitura só, no controller:** `CronJobController::periodoDoPedido`, depois da guarda do hash (quem não apresentou a
  credencial continua recebendo o "Acesso negado.", e não fica sabendo que a data estava torta), lê `inicio` e `fim` pelo
  `DataHora::dataDaApi` -- irmã do `dataDoFiltro` da fatia 121, e o contrário dele: aceita `aaaa-mm-dd` e `dd/mm/aaaa`
  (a forma e o calendário, pelo `dataValida` e pelo `checkdate`), devolve sempre `aaaa-mm-dd`, e o nulo na data que não existe;
  quem lê data de API recusa, e não troca pelo padrão. O nulo vira o `erroPedido` (400, `pedido_invalido`, a frase
  `CronJobController::FRASE_DA_DATA` com o campo). As três ações mandam ao serviço a data já lida; a leitura própria da v2 e da v3
  no `CronJobService` (a barra pela posição, a expressão e a `\Exception`) saiu. A guarda das três (o `die()` do hash, a linha
  delas no `ACOES_QUE_GRAVAM_PELO_GET` do `conferir-remocao-por-post.php`) não mudou.
- **A página `/api/docs/unidade`** diz as duas formas, o exemplo de que `2026-02-01` e `01/02/2026` são o mesmo dia, que o
  fim entra no período, e o 400 com o JSON do erro, nas três versões.
- **Provado** nos dois bancos pelo `D742DatasDaExportacaoDaUnidadeTest` (5 casos: a regra; a barra, o traço e os dois
  misturados dão as mesmas vendas da unidade fiscal 90001100 nas três versões; de 01/03 a 09/03 não há venda -- lido como
  mês/dia, ia de 3 de janeiro a 3 de setembro e pegava as de 10/03; a data torta nos dois campos e nas três versões é 400 com a
  frase; a página diz o que o controller faz), e por HTTP pelo caso 4 do `ensaiar-erro-json-da-api.php` (18 conferências; 28 de
  28 no MySQL do contrato e no PostgreSQL do contrato). Os mutantes, um por vez e devolvidos pelo sha1, vermelhos nos dois
  bancos: cada versão sem a barra (a v1, a v2 e a v3 mandando a data crua: o 1525 no MySQL, o outro período no PostgreSQL), a
  data torta de volta ao 500, e a barra lida como mês/dia; e, pelo ensaio, a v1 sem a barra no PostgreSQL e a torta de volta ao
  500 no MySQL.

**O que não se mediu:** o sistema de cada unidade, que chama a rota, não está aqui -- se algum tratava o 500 de um jeito e trata
o 400 de outro, só a produção dirá (a ressalva da pergunta). O pedido sem `inicio` ou sem `fim` continua no "Acesso negado.",
como antes: é a guarda do hash que o recusa, e ela não mudou.

**A verificação da fatia 138** achou duas coisas que o texto acima não dizia:

- **A v1 ficou mais estreita:** ela lia pelo banco formas que a página nunca prometeu -- a data com hora
  (`2026-02-01 00:00:00`, `2026-02-01T00:00:00`), sem o zero (`2026-2-1`) e junta (`20260201`) -- e respondia o período certo
  (conferido só com constantes, num `SELECT` sem tabela, nos dois bancos). Agora são o 400, como a data torta. A pergunta 374
  não contou isso; o que fazer com elas é do dono, e ficou no **D744**. A v2 e a v3 já as recusavam (com o 500). O estado de
  agora fica travado por `D742DatasDaExportacaoDaUnidadeTest::testAsFormasQueAV1LiaPeloBancoSaoRecusadasAteODonoDecidir`.
- **A data vazia não é a torta:** sem `inicio` ou sem `fim`, ou com um deles vazio (ou `0`, pelo `empty`), a guarda do hash
  (`unidadeAutorizada`) recusa antes de ler a data, e a ação responde o texto "Acesso negado." (o 200, sem JSON). A página dizia
  que a data "noutra forma" recebia o 400 e calava esse caso; agora diz as duas coisas nos campos e no exemplo de resposta das
  três versões, o `testAPaginaDizAsDuasFormasEO400` confere o texto, e o caso 4 do `ensaiar-erro-json-da-api.php` confere a
  resposta por HTTP (o início e o fim vazios, nas três versões). O comentário do passo dele no `ci.yml` diz agora que o caso 4
  depende das fixtures (a unidade 90001100, o hash dela e as duas vendas de 10/03/2026).

### D743 — O `varrer()` do `coletar-producao.sh` conta em dobro quando o diretório não tem log rotacionado — REGISTRADO em 08/11/2026

**Achado na fatia 128 (ROTEIROS-DO-CORTE-E-DA-CARGA)**, ao tomar o `varrer()` do `scripts/coletar-producao.sh` por molde
do conserto do **D332**. Ele faz `zgrep -hE "$1" "$LOGS_NGINX"/*.log "$LOGS_NGINX"/*.log.* 2>/dev/null || grep -hE "$1"
"$LOGS_NGINX"/*.log`. Sem nenhum `*.log.*` no diretório, o glob fica literal, o `zgrep` imprime o que achou nos `*.log` e
sai com 2 pelo arquivo que não existe -- e o `||` roda o `grep` nos mesmos `*.log`, que imprime tudo de novo. **Medido**
num diretório inventado, com duas linhas de `/api/versao/app` num só `site.log`: o `varrer` devolveu 4; com um `.gz`
rotacionado ao lado (sem o termo), 2. O mesmo acontece com um arquivo que o `zgrep` não consiga ler. **O efeito:** as
contagens da coleta (as rotas do aplicativo, as gerações por rota, o `/app.json` contra o `/api/versao/app` do D36) saem
dobradas quando a produção não tiver rotacionado nada -- a razão entre elas fica, os números não. Com a rotação de
costume do nginx há sempre um `*.log.1`, e por isso a coleta de 16/08 não deve ter sentido; não conferido. **O conserto**,
sem decisão do dono: a lista de arquivos que existem e o `zgrep` arquivo por arquivo, como o `varrer()` do
`remediacao-dia0.sh` desde a fatia 128; com um ensaio de logs inventados (o diretório sem rotacionado conta uma vez).

### D744 — A v1 das vendas da unidade pela API passou a dar 400 às formas de data que o banco lia: com hora, sem o zero, junta — REGISTRADO em 08/11/2026

**Achado na verificação da fatia 138 (RESPOSTAS-DE-08-11-API-E-CADASTRO).** Até a fatia 138, a
`CronJobController::unidadeVendasAction` (a `/api/unidade/vendas`, a v1) mandava `inicio` e `fim` crus ao
`DATE(ven.dataCriacao) >= :inicio AND ... <= :fim` da `VendaRepository::vendasPorPeriodoUnidade`, e os dois bancos liam como
data formas que a página `/api/docs/unidade` nunca prometeu: `2026-02-01 00:00:00`, `2026-02-01T00:00:00`, `2026-2-1` e
`20260201` (conferido em 08/11/2026 só com constantes, num `SELECT` sem tabela, no MySQL e no PostgreSQL do conteiner). Com elas
a v1 respondia o período certo -- com uma ressalva: o início com hora diferente de meia-noite (`2026-02-01 12:00:00`) tirava o
dia 1 inteiro no MySQL, e o mantinha no PostgreSQL.

Desde a fatia 138 (o D742, a resposta 374 (a)), as três versões leem a data pelo `DataHora::dataDaApi`, que aceita só
`aaaa-mm-dd` e `dd/mm/aaaa`: essas formas recebem o 400 `pedido_invalido` com a frase. Para a v2 e a v3 não muda nada (já as
recusavam, com o 500). A pergunta 374 falava da barra e da data que não existe, e não contou esse estreitamento da v1; o sistema
de cada unidade, que chama a rota, não está aqui, e não se sabe se algum manda a data num desses jeitos.

**Espera o dono:** a pergunta "As formas de data que a versão 1 da exportação de vendas aceitava" (o lote de 08/11/2026): (a) fica
como está; (b) a v1 volta a aceitá-las lendo só o dia; (c) as três as aceitam lendo só o dia. Até a resposta, o estado de agora
(o 400) fica travado por `D742DatasDaExportacaoDaUnidadeTest::testAsFormasQueAV1LiaPeloBancoSaoRecusadasAteODonoDecidir`; a
resposta muda o caso. Nada foi escrito em banco nenhum, e nada roda na produção antes de ela publicar.

### D745 — O portão das referências da documentação não lê as subpastas de `docs/`: a citação morta ou com a linha velha num desenho fica verde — REGISTRADO em 08/11/2026

**Achado na verificação da fatia 139 (ASSISTENTES-DESENHO-DAS-RESPOSTAS).** O
`scripts/conferir-referencias-docs.php` lê só `glob($raiz . '/docs/*.md')` e o `README.md` (a linha 124): as 40
páginas de `docs/f3-telas-restantes/`, `docs/f3-relatorios/` e `docs/diagramas/` nunca são lidas, e nenhum outro
portão de `scripts/` as lê. O roadmap da 139 citava o verde dele como prova do desenho dos assistentes, e o verde não
dizia nada: o `assistentes-anthropic.md` cita entre crases, com o caminho inteiro, três arquivos que ainda não existem
(são planejados: o arquivo à parte do dublê em config/autoload, a tela dos assistentes em frontend/src/telas e a
migração em scripts; escritos aqui sem crases, porque este registro o portão lê), e cita `menu-do-erp.ts:909`, uma
linha que tinha andado para a `:924`
(consertada à mão na conferência da 139). O portão também só confere que o arquivo existe, e não a linha.

**Por que não foi consertado na 139:** ligar as subpastas no portão deixaria vermelho todo arquivo planejado que os
desenhos citam, e eles são muitos; antes é preciso um jeito de o desenho marcar o que é planejado (uma lista no
próprio portão, como as `$explicadas`, ou uma marca no texto), e decidir se a linha citada também é conferida. É
trabalho de portão, de uma fatia P, e não de uma fatia de documentos.

**Enquanto isso:** a fatia que escreve um desenho confere à mão as citações com linha que acrescentou, e o roadmap
não cita este portão como prova de desenho fora de `docs/*.md`.

### D746 — Os assistentes falam com a API da Anthropic só pela porta do servidor: ninguém a chama, e a primeira chamada de verdade é do dono — PREPARADO em 08/11/2026

**Registrado na fatia 133 (ASSISTENTES-PORTA-DO-CLAUDE), pela decisão do dono de 07/11/2026** ("as fatias de
assistentes serão substituídas do zapier para Anthropic") e pelas respostas 365 (a), 366 (c), 367 (a) e 369 (c);
o desenho é `docs/f3-telas-restantes/assistentes-anthropic.md`.

**O que ficou pronto:** o esquema que só acrescenta (as cinco colunas de `assistentes` e as quatro tabelas
`assistentes_conversas`, `assistentes_mensagens`, `assistentes_cota` e `assistentes_uso`, pela
`migracao-assistentes-anthropic.sql` no passo 0 do corte e nas duas cópias locais); o cliente HTTP por cURL com o
streaming, os tempos e a repetição única (`ISBase\Claude\ClienteDoClaudeCurl`); a regra pura
(`RegraDoAssistente`: a lista dos modelos com o Haiku 5.5 como padrão, o pedido, o reenvio depois da troca de
modelo, o tempo derivado do teto de tokens, a tabela de preços datada, a regra do 400 e a frase de cada erro); e a
conversa do lado do servidor (`ConversaDoAssistenteService`: o `system` congelado, a cota do ERP por UPDATE
condicional numa transação que fecha antes da chamada, o teto do mês do ERP, as vagas, a recusa e o erro fora do
histórico, o 400 do raciocínio com o `drop_block` para sempre). Testado só com o dublê
(`tests/Caracterizacao/DubleDoClaude.php`) e com servidores de mentira no 127.0.0.1; o portão
`scripts/conferir-sem-anthropic-nos-testes.php` prende que o endereço real não está escrito no código, nos testes,
nos roteiros nem na tela.

**O que falta, e de quem:**

- a rota que a tela chama e a tela de uso (a fatia 87); a administração antiga com as instruções e o modelo (a
  134, feita em 08/11/2026: o parágrafo dela, abaixo); as páginas com o login e o lado delas na cota (a 89);
- a chave, o endereço e o sal no `local.php` **da produção**, a conferência do `pm.max_children` (as vagas) e a
  primeira conversa de verdade (a 88, do dono);
- o esquema na produção, pelo passo 0 do corte (até lá, sem as colunas, o assistente diz "indisponível", sem erro);
- **o host da API não está na lista dos hosts mortos do desenvolvimento** (`HOSTS_MORTOS` do
  `scripts/conferir-provedores-no-loopback.php`): a quarta trava possível. Hoje as três do desenho bastam (sem
  `url` no `local.php`, nenhum pedido sai, e o portão novo prende o endereço fora do código); pôr o host na lista
  muda os arquivos do compose do desenvolvimento e do laboratório, e fica para quando a 87 abrir a tela no
  navegador.

**A verificação da 133 (08/11/2026):** a resposta boa grava no log comum o tempo e os tokens de saída
(`respondida em <ms> ms, tokens de saida <n> (<x> tokens/s), ...`), a linha que a conferência da 88 lê, e todo erro
leva o tempo também; o assistente inativo ou que não existe dá `nao_encontrado` (a 87 responde o 404 de sempre), e
não mais "sendo preparado"; a reserva da cota continua devolvida em todo erro antes do primeiro byte, os 4xx
inclusive, e a leitura está escrita na seção 7 do desenho; e os casos que não provavam o que diziam ganharam prova (os
dois envios no último lugar contra um processo à parte que confirma enquanto o serviço espera, a gaveta acima de 256,
as fronteiras do teto do mês, a conversa de outro assistente, a resposta só com raciocínio, a chave fora do log e a
fábrica). **Para a 87:** `nao_encontrado` e `conversa_de_outro` são o 404; `indisponivel` e `ocupado` o 503 (com o
`Retry-After` de `esperar`); `limite` o 429. **As conversas guardadas ficam fora da limpeza da base do teste do
BVIP** -- é o D747, que espera o dono.

**A fatia 134 (08/11/2026, ASSISTENTES-ADMIN-ANTIGA-INSTRUCOES):** a administração antiga
(`/mod-configuracoes/assistentesAdmin/novo` e `editar`) ganhou "Instruções" (até 20.000 caracteres e o que a
coluna `text` guarda em bytes, opcional, com a linha de ajuda do desenho) e "Modelo" (a lista da `RegraDoAssistente`,
com o nome legível, o Haiku 5.5 marcado no novo); o "iFrame" deixou de ser obrigatório (vazio grava `''`), e o
assistente ativo precisa de um dos dois. O `AssistenteAdminService` grava as duas colunas pela conexão, dentro da
transação de hoje, antes dos vínculos; sem as colunas no banco (a base do teste do BVIP), o formulário fica como era.
Ninguém gravou instruções por roteiro: o dono cola o rascunho (`assistentes-instrucoes-rascunho.md`). O botão
"Ativar" da lista também confere a regra dos dois desde a correção da 134 (o D748, CORRIGIDO), e o modelo gravado
fora da lista aparece marcado na edição e é recusado com "Escolha um modelo da lista.", em vez de virar o Haiku calado.

**Como conferir:** `php scripts/conferir-sem-anthropic-nos-testes.php` sai 0; o `ConversaDoAssistenteTest`, o
`ClienteDoClaudeCurlTest` e o `RegraDoAssistenteComAnthropicTest` verdes nos dois bancos; no `local.php` daqui, a
seção `assistentes.anthropic` não existe (o `local.php.dist` a traz vazia).

### D747 — As conversas com os assistentes ficam fora da limpeza da base do teste do BVIP: o texto livre iria cru na próxima exportação — REGISTRADO em 08/11/2026

**Achado na verificação da fatia 133 (ASSISTENTES-PORTA-DO-CLAUDE).** O `scripts/exportar-base-teste.sh` copia a
`franquiabv_dev` inteira com o `mysqldump`, sem lista de tabelas, e passa a cópia pelo `anonimizar.sh` (com o
`anonimizar.sql.modelo`) e pelo `endurecer-base-teste.sql`, que troca por "de exemplo" o texto livre que pode trazer
dado pessoal (`motivo`, `descricao`, `observacoes`, `mensagens.conteudo`) e apaga o `assistentes.iframe`. Nenhum dos
dois conhece as tabelas que a 133 acrescentou: `assistentes_mensagens.conteudo` guarda o que a pessoa digita e a
resposta (também as do Motivational Coach e do RH VIP, que podem ser pessoais), e a resposta 369 (c) as guarda para
sempre. O selo `conferir-base-teste.sql` também não as confere. O `migrar-dados.sh` regerado já leva as quatro
tabelas para a carga do corte.

**Por que é latente:** hoje as quatro tabelas têm zero linha na `franquiabv_dev` (contado por SELECT em 08/11/2026), e
sem a chave nada é gravado aqui. O furo abre quando a cópia local for remontada a partir da produção depois que os
assistentes da Anthropic estiverem em uso (a 87 e a 88): a exportação seguinte mandaria as conversas cruas ao teste
público do BVIP.

**Por que não foi consertado na 133:** o conserto muda os arquivos selados da cópia (`endurecer-base-teste.sql` e
`conferir-base-teste.sql`), que só mudam com resposta do dono, e o que fazer com as conversas na cópia (apagar, trocar
o texto ou levar como está) é escolha dele: a pergunta do lote de 08/11/2026 ("As conversas com os assistentes vão
para a base do teste do BVIP?"). Até a resposta, nada muda, e o furo continua fechado só porque as tabelas estão
vazias.

**Como conferir, quando consertado:** na cópia endurecida, `SELECT COUNT(*) FROM assistentes_mensagens` dá zero (ou
nenhum `conteudo` fora do texto de exemplo, conforme a resposta), e o selo conta essas linhas como conta o `iframe`.

### D748 — O botão "Ativar" da lista dos assistentes não confere as instruções nem o código do Zapier: o ativo sem os dois volta pela lista — REGISTRADO em 08/11/2026 — CORRIGIDO em 08/11/2026: o "Ativar" pede um dos dois, com a frase

**Achado na fatia 134 (ASSISTENTES-ADMIN-ANTIGA-INSTRUCOES).** O bloco 134 do desenho
(`docs/f3-telas-restantes/assistentes-anthropic.md`) recusa o assistente ATIVO sem as instruções e sem o "iFrame", com
"Preencha as instruções (ou o código do Zapier, enquanto ele existir).", e a 134 o fez no formulário (o novo e o editar,
pelo `AssistenteAdminFilter`). Mas o estado muda só pelos botões da lista (o D410), e o
`AssistentesAdminController::ativarRemoverAction` ativa qualquer assistente: o inativo salvo sem os dois (o que a 134
deixa, de propósito) volta ativo pelo "Ativar", sem a frase.

**Por que era pequeno:** o ativo sem os dois não abre conversa nenhuma -- o abrir antigo não faz conversa do HTML
vazio, e a porta da 133 responde "Este assistente ainda está sendo preparado." ao ativo sem instruções. Dos quatro
assistentes da fixture, três são ativos e um inativo (o 90001003), todos com o HTML vazio e sem instruções
(`tests/Caracterizacao/fixtures.sql`); o caso do aviso da lista (`AssistentesPelosControllersTest`) ativava o inativo
da fixture, e o `D199AtivarRemoverPorPostTest` ativava o assistente que cria, também com o HTML vazio. (A primeira
versão deste parágrafo dizia "os quatro são ativos": não são.)

**Consertado na correção da 134 (08/11/2026),** depois que a verificação apontou que o bloco 134 ("Para ativar, basta um
dos dois") e o item 12 da revisão do desenho tratam do ato de ativar, e não só do formulário. O
`ativarRemoverAction` pergunta ao `AssistenteAdminService::podeSerAtivado()`, que aplica a mesma
`RegraDoAssistente::temComoConversar` do filtro às instruções gravadas (só quando o banco tem a coluna, pela
`ColunaNoBanco`) e ao iFrame gravado. Sem nenhum dos dois, nada muda, a lista volta aos inativos
(`index?status=0`) e mostra "Para ativar, preencha as instruções (ou o código do Zapier, enquanto ele existir) na
edição do assistente." (`FRASE_ATIVAR_SEM_INSTRUCOES_NEM_ZAPIER`). Desativar não pede nada. As fixtures não mudaram:
os dois casos que ativam dão ao assistente o código do Zapier dentro da transação. Casos novos:
`testOAtivarDaListaRecusaOInativoSemOsDois` (recusa sem os dois; ativa só com as instruções) e
`testSemAsColunasOAtivarSoOlhaOIframe`, nos dois bancos; os mutantes N01 a N03 da correção.

**Para a 88:** quando o iFrame sair, a regra do ativo passa a exigir as instruções nas duas portas -- o filtro
(`temComoConversar`) e o "Ativar" (`podeSerAtivado`) usam a mesma função, e basta mudá-la; os três assistentes ativos da
fixture vão precisar de instruções (ou os casos, de as darem na transação).

**Como conferir:** na lista dos inativos, "Ativar" num assistente sem instruções e sem iFrame: a frase, e ele
continua inativo; com as instruções coladas na edição, "Ativado com sucesso."
