# Ambiente de desenvolvimento

## Subir do zero

Requisito: Docker Desktop em execução.

```bash
sh scripts/setup-dev.sh
```

O script cria o `.env`, cria o `config/autoload/local.php` apontando para os
containers, compila a imagem, sobe os serviços, instala as dependências do
Composer e ajusta os diretórios de escrita.

| Serviço | Endereço | Observação |
|---|---|---|
| Sistema | http://localhost:8080 | |
| Mailpit | http://localhost:8025 | Captura todo e-mail; nada sai para caixas reais |
| Adminer | http://localhost:8081 | Servidor `mysql`, usuário `franquiabv`, senha `dev` |
| MySQL | 127.0.0.1:3307 | Para clientes externos (DBeaver, Workbench) |

Portas ocupadas na sua máquina podem ser trocadas no `.env`.

### Os provedores de produção ficam fora de alcance

Desde 05/10/2026 (D360), Asaas, WhatsApp (Evolution e WAHA), OneSignal, SMS, Yungas, Zoomtec e o SMTP de
produção resolvem para o loopback **dentro do contêiner do PHP**, no IPv4 e no IPv6: a chamada morre com
"Could not connect to server" e não sai da máquina. O ViaCEP continua alcançável. Quem faz isso é o
`docker-compose.desenvolvimento.yml`, que o compose só lê pelas duas linhas `COMPOSE_` do `.env.example`
-- um `.env` de antes dessa data não as tem: copie-as, e recrie o app:

```bash
docker compose up -d app
docker compose restart nginx    # o nginx guarda o endereço do app da subida
docker compose exec -T app php scripts/conferir-provedores-no-loopback.php --conteiner
```

**Rode o compose da raiz do repositório**, onde está o `.env`. De `frontend/`, de `scripts/` ou de um
worktree (nenhum deles tem `.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: o app sai sem nenhum `extra_hosts` (medido com
`docker compose config` em 05/10/2026), e um `docker compose up` dali recria o `franquiabv-app-1` sem o
corte. Por isso, **antes de abrir no navegador as unidades de fixture 90001150 e 90001200**, rode o
terceiro comando acima e só siga com ele verde: o modo de arquivos do portão fica verde mesmo com o
contêiner sem o corte.

O corte não está no `docker-compose.yml` de propósito: é o arquivo que a produção talvez use depois do
corte, e o dono pediu para não tocá-lo (06/10/2026). A homologação e o laboratório de migração do BVIP
têm o corte escrito desde essa data, a homologação pelo seu arquivo e o laboratório pelo `COMPOSE_FILE`
do `.env.laboratorio.example`, num clone próprio desde 08/10/2026 (D431), e ele vale no próximo `up` de
cada um (`docs/ambiente-homologacao.md`). Nem no
`docker-compose.override.yml`, que resolveria a subpasta:
o compose o lê sozinho sempre que roda sem `COMPOSE_FILE` nem `-f`, e de uma subpasta ele não vê o
`COMPOSE_FILE` do `.env` -- num servidor, o override entraria assim na homologação (medido; o D360 traz a
troca). O `scripts/conferir-provedores-no-loopback.php` (no
`conferir-tudo.sh`) compara esta lista e a da homologação com a do deploy de teste e procura no código um
provedor que elas não tenham.

## nginx, e o que isso significa

A produção usa **nginx**, então o ambiente usa nginx + PHP-FPM em containers
separados.

Consequência que vale registrar: **o `public/.htaccess` é um arquivo morto**.
Ele veio do esqueleto do Laminas e só teria efeito sob Apache; o nginx não o lê.
Todo o roteamento e toda a proteção de caminho vivem em
`docker/nginx/default.conf` — e, em produção, no arquivo equivalente do
servidor.

O `default.conf` daqui faz três coisas além de rotear:

1. Impede execução de PHP dentro dos diretórios de upload. O sistema grava
   arquivos enviados pelo usuário dentro do webroot; sem essa regra, um `.php`
   enviado como "contrato" seria executado pelo servidor.
2. Bloqueia arquivos de dado e log soltos na raiz do webroot (`.txt`, `.log`,
   `.sql`, `.bak`, ...), por categoria e não por nome. É a rede de proteção
   para o problema do Dia 0.
3. Libera `robots.txt` como exceção explícita.

**Confirmar se a configuração de produção tem equivalentes.** Se não tiver, os
itens 1 e 2 são lacunas reais no servidor, não só no ambiente local.

## Versões

Espelham a produção: **MySQL 8.4** (o servidor roda 8.4.7), **PHP 8.1** e
**Composer 2.2**.

O PHP 8.1 é o da produção, deduzido do lock de lá (`operacao-atual.md`): o
`laminas/laminas-cache 3.6.0` aceitava no máximo 8.1. **Desde 08/11/2026 (a
fatia 130, o D11) o lock daqui não prende mais:** `laminas-cache` 3.13.0 e o
adaptador de filesystem 2.5.0 aceitam do 8.1 ao 8.4, e o lock inteiro aceita do
8.1 ao 8.3 (`composer prohibits php 8.3.0` não acusa ninguém; no 8.4, o
`composer.json` e uma dúzia de pacotes ainda recusam). A suíte inteira passou no
8.2, nos dois bancos, numa imagem feita à parte:

    docker build --build-arg PHP_VERSION=8.2 -t franquiabv-app-php82:ensaio docker/php

O contêiner do dia a dia segue no 8.1. Trocar o PHP do servidor é do corte.

O Composer segue o 2.2: o `laminas-dependency-plugin` (2.7.0) ainda exige
`plugin-api < 2.3`, e com Composer 2.3+ o `install` falha. Ver o débito D11 em
`debitos-tecnicos.md`.

O charset do servidor MySQL fica no padrão de propósito. O dump declara
`utf8mb3` por tabela, então as tabelas nascem idênticas às de produção. Forçar
`utf8mb4` no container mascararia problemas de acentuação que só apareceriam no
servidor. Ver o débito D6 em `debitos-tecnicos.md`.

## Desempenho no Windows: por que o `vendor/` fica em volume nomeado

Na primeira execução, cada requisição levava **cerca de 11 segundos**. A causa
não era o código: era o sistema de arquivos compartilhado do Docker Desktop no
Windows.

A medição que isolou o problema — o mesmo `Application::init`, com o mesmo PHP,
mudando apenas de onde os arquivos são lidos:

| Origem dos arquivos | Tempo do bootstrap |
|---|---|
| Sistema de arquivos do container | **0,06 s** |
| Bind mount do Windows | **8,56 s** |

Cerca de 140× de diferença. O projeto tem ~4.900 arquivos PHP e o bootstrap do
Laminas percorre quase todos; cada leitura atravessando a camada de
compartilhamento custa caro.

Como o `vendor/` são ~4.200 desses arquivos, ele foi movido para um **volume
nomeado**, fora do bind mount. Resultado medido: **11 s → 0,6 s por requisição**,
e a suíte de contrato caiu de 6min49s para 11 s.

Duas consequências práticas:

- O `vendor/` do container **não é mais o do host**. Se a IDE precisar dele para
  autocompletar, rode `composer install` também no Windows; as duas cópias
  convivem sem interferir.
- Depois de `docker compose down -v`, o volume some e o `setup-dev.sh` precisa
  rodar de novo para repopulá-lo.

Se ainda assim o ambiente parecer lento, a solução definitiva no Windows é mover
o projeto para dentro do sistema de arquivos do WSL2 — mas com esta mudança o
ambiente já ficou utilizável sem isso.

Ajustar o OPcache **não** resolveu (foi tentado antes de medir: o ganho foi
nulo). Fica registrado para ninguém repetir a tentativa.

## Dados

`mysql.sql` contém **apenas o schema**, sem nenhum registro. Ao subir pela
primeira vez o banco fica com as tabelas do `mysql.sql` vazias — suficiente para
navegar, mas não para trabalhar em relatórios. São **85** em 07/10/2026
(`grep -c 'CREATE TABLE' mysql.sql`); eram 82 quando este guia foi escrito, em
15/08, e o número cresce com o esquema (correção de 07/10/2026, D443).

### Carregar dados para trabalhar

**Nunca use um dump cru de produção.** O fluxo é sempre: dump → anonimizar →
usar.

```bash
# 1. Carregue o dump no banco de desenvolvimento
docker compose exec -T mysql mysql -uroot -proot franquiabv_dev < dump-producao.sql

# 2. Anonimize IMEDIATAMENTE, antes de qualquer outra coisa
sh scripts/anonimizar.sh

# 3. Gere o dump anonimizado, que é o artefato que circula entre a equipe
docker compose exec -T mysql mysqldump -uroot -proot franquiabv_dev > dump-anonimizado.sql
```

O `anonimizar.sh` recusa rodar contra bancos cujo nome contenha "produc" e pede
confirmação digitada antes de reescrever qualquer coisa.

### O que a anonimização faz

Substitui nome, CPF, RG, telefone, e-mail, endereço de rua, data de nascimento e
anotações de clientes, colaboradores e sócios. Remove as chaves da API de
WhatsApp guardadas em `unidades` e `push_app`, tokens de sessão e ids do
OneSignal.

Até 09/09/2026 ela também zerava `unidades.iframe_bi`, que carregava o token do
painel de BI. A coluna saiu com o Power BI.

Duas garantias importantes: telefones recebem o prefixo `9 0000`, que **não é um
celular válido no Brasil**, e e-mails usam o domínio `.invalid`, reservado pela
RFC 2606 e que **não resolve**. Se um cron de marketing rodar por acidente no
ambiente de desenvolvimento, não há como alcançar uma pessoa real.

Estado e cidade são preservados para que os relatórios regionais continuem
realistas; rua, número, complemento, CEP e bairro são substituídos.

### Entrar no sistema depois de anonimizar

Todos os colaboradores passam a ter:

```
login: user<id>     (ex.: user1)
senha: dev123456
```

Trocar com `SENHA_DEV=outrasenha sh scripts/anonimizar.sh`.

## Trabalhando com a base real anonimizada

Exercitado pela primeira vez em 16/08/2026, com o dump de produção das 19h:
242.586 clientes, 2,27 milhões de vendas, 4 milhões de agendamentos, 32 unidades
e 1,26 GB no MySQL. A carga levou cerca de 10 minutos.

**Duas armadilhas descobertas nessa primeira vez**, ambas já corrigidas:

O dump traz `SET @@GLOBAL.GTID_PURGED`, que aborta a carga com erro 3546 num
banco que já tem GTIDs. Filtre na entrada:

```bash
gunzip -c dump.sql.gz | grep -v GTID_PURGED | docker compose exec -T mysql mysql -uroot -proot franquiabv_dev
```

E os ids do cenário de teste **colidiam com os de produção** — havia cliente,
usuário, produto e caixa de id 1000 no banco real, e a limpeza das fixtures
(`DELETE ... WHERE id >= 1000`) teria apagado dados. A faixa foi movida para
**90.001.000+**, acima do maior id em produção (2,29 milhões em `vendas`).

### Os índices que a cópia local tem e a produção ainda não

A `franquiabv_dev` não é só o dump: o MySQL dela tem índices e tabelas que a
produção ainda não tem. Dois são os ampliados que só a cópia tem, criados com a
autorização do dono e registrados no seu débito; os outros a produção recebe no
passo 0 do corte (o laço de migrações do `procedimento-de-corte.md`), e a cópia já
os tem. O PostgreSQL dela tem os que o `schema.sql` gera. Quem compara a cópia com
o `mysql.sql`, ou mede um plano nela, precisa saber destes: é a lista inteira do
que o catálogo do MySQL da cópia tem diferente do dump de produção
(`git show 3b1f05c:mysql.sql`), conferida nas duas cópias em 09/10/2026; e as
tabelas de novo em 11/10/2026, depois da `clientes_aparelhos` (as 88 da cópia contra
as 83 do dump: as cinco de baixo, e nenhuma do dump faltando); e a `cupons_clientes` em
12/10/2026, depois das colunas do cupom do dia (igual à dos bancos da suíte, nos dois);
e a `vendas_produtos` no mesmo dia, depois da coluna `pacote_recusado`, o "Não" do PDV
antigo (o **D453**, pela `migracao-vendas-produtos-pacote-recusado.sql`; sem índice
nenhum, e igual à dos bancos da suíte, nos dois); e as três de Contas a Pagar (`saidas`,
`saidas_parcelas` e `saidas_parcelas_pagamentos`) em 13/10/2026, depois das oito colunas do motivo e do autor (o
**D205**, o **D207** e o **D199**, pela `migracao-saidas-motivos-e-autores.sql`; sem índice nenhum, e iguais às dos
bancos da suíte, nos dois); e a `nps` em 07/11/2026, depois da coluna `envio`, o número do envio da pesquisa de
satisfação (o **D648**, a fatia 114, pela `migracao-nps-envio.sql` e o irmão `postgres/migracao-nps-envio.sql`; nula nas
37.957 de antes, sem índice, e igual à dos bancos da suíte, nos dois).

**E uma diferença ao contrário, só no PostgreSQL da cópia** (07/11/2026, a fatia 114, o **D622**): a chave primária de
`colunas_gravadas` é `(nome)` na cópia e `(inquilino_id, nome)` no `schema.sql` (e nos bancos da suíte, que nascem dele).
Trocar a chave não é só acrescentar, e por isso a cópia ficou como está; com um inquilino só, as duas se comportam igual.
A produção nasce do `schema.sql` no corte, já com a chave nova.

| Índice | MySQL (`franquiabv_dev`) | PostgreSQL (`franquiabv_dev`, `bvip`) | Nos DDLs do repositório |
|---|---|---|---|
| `agendas`, a grade da agenda (**D189**) | `FK_AGENDAS_colaborador (colaborador, data)` | `agendas_fk_agendas_colaborador (inquilino_id, colaborador, data)` | os dois o têm desde 07/11/2026: o `KEY` de duas colunas no `mysql.sql` (o passo 0 do corte, `migracao-agendas-indice-colaborador-data.sql`, que nas cópias diz "indice ja ampliado") e o ampliado pelo gerador |
| `produtos_estoque`, as saídas do mês (**D630**, 07/11/2026, a fatia 114) | `IDX_PRODUTOS_ESTOQUE_data_criacao (data_criacao)`, pela `migracao-produtos-estoque-indice-data.sql` | `produtos_estoque_idx_produtos_estoque_data_criacao (inquilino_id, data_criacao)`, por `postgres/migracao-produtos-estoque-indice-data.sql` (`CREATE INDEX CONCURRENTLY`) | os dois o têm (o passo 0 do corte) |
| `vendas`, o cliente novo (**D308**, 29/09/2026) | `FK_VENDAS_cliente (cliente, status, data_criacao)` | `vendas_fk_vendas_cliente (inquilino_id, cliente, status, data_criacao)` | o `mysql.sql` ainda tem `(cliente)`; o `schema.sql`, o ampliado |
| `agendas`, a família do Desmarcar (**D220**, 09/10/2026, pergunta 62 do lote) | `IDX_AGENDAS_id_pai (id_pai)`, pela própria `migracao-agendas-indice-id-pai.sql` | `agendas_idx_agendas_id_pai (inquilino_id, id_pai)`, por `CREATE INDEX CONCURRENTLY` | os dois o têm: o `KEY` no `mysql.sql` (o passo 0 do corte) e o herdado pelo gerador |
| `vendas`, quem autorizou o fiado | `FK_VENDAS_autorizado_por (autorizado_por)`, pela `migracao-venda-autorizacao-fiado.sql` | `vendas_FK_VENDAS_autorizado_por (inquilino_id, autorizado_por)` | os dois o têm (o passo 0 do corte); o `schema.sql` o gera sem aspas, com o nome em minúsculas, e na cópia ele está em maiúsculas |
| `pontuacoes_historicos`, quem autorizou o resgate | `FK_PONTUACOES_HISTORICOS_autorizado_por (autorizado_por)`, pela `migracao-pontuacao-resgate-autorizado.sql` | `pontuacoes_historicos_FK_PONTUACOES_HISTORICOS_autorizado_por (inquilino_id, autorizado_por)` | os dois o têm (o passo 0 do corte); o nome, como o de cima |
| `cupons_clientes`, o produto do cupom do dia (**D271**, 12/10/2026, a resposta 83 do lote) | `FK_CUPONS_CLIENTES_produto_oferecido (produto_oferecido)`, pela `migracao-cupons-clientes-desconto-produto.sql`, com as colunas `desconto` e `produto_oferecido` (nulas nos cupons de antes) e a chave estrangeira para `produtos` | `cupons_clientes_fk_cupons_clientes_produto_oferecido (inquilino_id, produto_oferecido)`, com as duas colunas e a chave, o que o gerador emite | os dois o têm (o passo 0 do corte); a entidade não mapeia as colunas (o **D473**) |

E as tabelas que o dump não tem, com os índices delas: `usuarios_horarios` e
`clientes_sessoes`, do passo 0 (`migracao-usuarios-horarios.sql` e
`migracao-clientes-sessoes.sql`, com `FK_USUARIOS_HORARIOS_usuario (usuario,
vigente_desde)`, `uq_clientes_sessoes_token (token_hash)` e
`idx_clientes_sessoes_telefone (telefone)`, os do `mysql.sql`); a
`clientes_aparelhos`, também do passo 0 (`migracao-clientes-aparelhos.sql`), criada
vazia na cópia em 11/10/2026 às 06:33, com a autorização do dono (a resposta 72; o
**D461**), com `PRIMARY (id)`, `uq_clientes_aparelhos_identificador (identificador)`,
`idx_clientes_aparelhos_cliente (cliente)` e a chave estrangeira
`FK_CLIENTES_APARELHOS_cliente`, em cascata, os do `mysql.sql` — e, no PostgreSQL da cópia,
a do `schema.sql`, com o inquilino na frente dos dois índices e o RLS; a `sessoes`, de
`sql/sessoes.mysql.sql`, que o corte **não** aplica (`procedimento-de-corte.md`); e a
`_fixtures_versao`, das fixtures da suíte (abaixo).

A base do teste do BVIP veio desta cópia antes do índice do D220, da
`clientes_aparelhos`, das colunas do cupom do dia (o D271) e da coluna do "Não" do PDV
antigo (o D453), e só os recebe com a próxima cópia exportada daqui. Até lá, as
colunas faltam lá -- o cupom segue a campanha, e o "Não" vale só para o lançamento --,
sem erro: o código pergunta se elas existem (o D473). A tabela, até lá,
chega pelo passo manual da `docs/ambiente-teste.md` ("A tabela dos aparelhos do
push"). E a próxima exportação leva a tabela: esvaziá-la e contá-la no endurecimento e na
régua é o passo antes dela (o **D327**).

### O que a base anonimizada NÃO consegue responder

Regra que já custou duas conclusões erradas em 17/08/2026, e que vale para
qualquer pergunta futura.

A anonimização **deriva os valores do `id` e grava sem `WHERE`**. Isso preserva
estrutura e volume, mas destrói três propriedades dos dados:

| Pergunta | A base anonimizada responde? |
|---|---|
| Quantas linhas, que datas, que valores | **Sim** |
| Estrutura, tipos, chaves | **Sim** |
| Há CPF/e-mail/login duplicado? | **Não** — são derivados do `id`, únicos por construção |
| Quantos clientes têm CPF preenchido? | **Não** — o `UPDATE` preenche até onde era vazio |
| Como se distribui um dado pessoal | **Não** |

Os dois erros concretos: uma checagem de duplicatas em `login`, `email`, `cpf` e
`hash` deu zero e parecia liberar a criação de restrições de unicidade (D29); e
uma medição de cobertura de CPF deu 100%, o que decidiria a chave de identidade
do cliente. **Os dois números eram artefato da anonimização.**

Qualquer pergunta sobre **unicidade, completude ou distribuição de dado pessoal**
tem que rodar na réplica de produção.

### A suíte de contrato diverge com a base real

Os snapshots em `tests/Contrato/snapshots/` são gerados a partir do cenário de
fixtures, que é o que o CI carrega — e é o CI que manda. Com a base real
carregada, vários endpoints devolvem listas populadas onde as fixtures devolvem
vazio, e a suíte de contrato acusa diferença.

Isso é esperado, não é defeito. Rode só a caracterização quando estiver com a
base real:

```bash
docker compose exec app phpunit --testsuite caracterizacao
```

**Não regenere os snapshots com a base real carregada** — eles passariam a
descrever formas que o CI não consegue reproduzir, e a suíte quebraria lá.

### E a caracterização também diverge, quando o cenário envelhece

O banco de desenvolvimento é carregado **uma vez** e nunca remontado. As
fixtures, ao contrário, crescem: um cliente acentuado aqui, um caixa a mais ali.
Em 07/09/2026 essa distância chegou a **45 falhas, nenhuma real** — os mesmos
312 testes passavam no CI, que remonta o banco a cada execução.

Desde então o `fixtures.sql` **declara o sha1 do próprio conteúdo** e o grava em
`_fixtures_versao` ao ser carregado. `CaracterizacaoTestCase` compara as duas
marcas e recusa rodar quando diferem, com uma frase em vez de 45 vermelhos.

Quando isso acontecer, a suíte de cenário vai para os bancos que se remontam:

```bash
sh scripts/rodar-caracterizacao-como-ci.sh    # MySQL, banco recriado
sh scripts/rodar-caracterizacao-postgres.sh   # PostgreSQL, banco recriado
```

**Não dá para reaplicar as fixtures por cima das que já estão lá** — elas gravam
id explícito e colidem em chave primária, e nem toda tabela distingue a linha de
fixture da real (`vendas_produtos` se identifica pela venda, não por um id na
faixa alta). Ou se recria o banco, ou se usa um dos dois roteiros acima.

Isso não tira nada de valor do banco real: ele continua sendo o único lugar
onde se mede sobre 2,3 milhões de vendas. Só deixou de ser o lugar onde se roda
o cenário. Ver D122.

### Se você mexer no `fixtures.sql`

Regrave a marca, ou o portão acusa:

```bash
php scripts/marcar-fixtures.php              # regrava
php scripts/marcar-fixtures.php --conferir   # o portão, que roda no CI
```

Marca que mente é pior que marca nenhuma: todo banco carregado do arquivo
juraria estar em dia.

### Desempenho

Com dados reais a suíte completa leva cerca de 6 minutos, contra 22 segundos no
cenário de fixtures. A diferença é a própria API respondendo sobre 2,3 milhões
de vendas, e serve como primeira medida de desempenho real do sistema.

## Comandos do dia a dia

```bash
docker compose up -d              # subir
docker compose down               # parar (mantém os dados)
docker compose down -v            # parar e APAGAR o banco
docker compose logs -f app        # acompanhar erros do PHP
docker compose exec app bash      # shell no container da aplicação
docker compose exec app composer install
```

Rodar um cron manualmente, já que em produção eles são disparados por URL:

```bash
curl http://localhost:8080/api/cronjobs/npsContabilizar
```

### Os portões de conferência

```bash
sh scripts/conferir-tudo.sh
```

Os que só leem arquivo terminam em segundos. Os que consultam o banco rodam **do
host**, e não de dentro do container — a diferença é grande e vale saber por quê:
os dois varrem `module/` inteiro antes de consultar, e **o scan através do bind
mount do Windows leva minutos**.

| | No container | Do host |
|---|---:|---:|
| unicidade das relações | minutos | **1,3 s** |
| ordenação por coluna anulável | **~14 min** | **16 s** |

O que impedia rodar do host era só o endereço: `local.php` diz `host => mysql`,
nome que existe apenas na rede do Docker. Os dois roteiros passaram a aceitar
`BANCO_HOST` e `BANCO_PORTA`, que o `conferir-tudo.sh` já preenche com
`127.0.0.1:3307` — as portas publicadas no `docker-compose.yml`.

Rodá-los dentro do container continua funcionando, e continua sendo o caminho
quando não há PHP na máquina.

**O conjunto inteiro leva 3 minutos**, e quase tudo isso é o do `schema-tool` —
ele sobe o CLI do Doctrine dentro do container e introspecta as 85 tabelas. Os
outros nove somam menos de meio minuto.

São dez roteiros que varrem coisas diferentes — referências quebradas na
documentação, rotas apontando para ação inexistente, débitos escritos duas
vezes, listagens paginadas sem ordem definida, o que o `schema-tool` apagaria.
Cada um sai com código 1 quando acha algo, e **cada um recusa aprovar o que não
leu**: contagem de itens examinados igual a zero é falha, não sucesso.

**Um deles é vermelho conhecido.** `conferir-agendamento` acusa duas rotinas sem
entrada no crontab, e está certo em acusar: uma espera decisão de negócio
(**D43**), a outra espera a produção ser descongelada (**D50**). O roteiro mostra
o motivo e não conta como falha — sem essa categoria o conjunto ficaria vermelho
para sempre, e conjunto sempre vermelho é conjunto que ninguém roda.

Se um vermelho conhecido **passar**, isso também é reportado como falha: quer
dizer que a lista envelheceu e o item precisa sair dela.

Dois ficam de fora, por precisarem de coisas que o dia a dia não tem:
`conferir-migracao.php` (dez minutos, e é portão de corte) e `conferir-papeis.sh`
(exige a senha). Eram três: `conferir-bi-replica.sh` saiu em 09/09/2026 junto com
o Power BI.

## PostgreSQL (Fase 1)

O serviço já está declarado, mas não sobe por padrão. A imagem PHP tem
`pdo_pgsql` instalado, então dá para apontar o Doctrine para o Postgres e rodar
os dois bancos em paralelo sem recompilar nada:

```bash
docker compose --profile fase1 up -d
```

## Estado da verificação

O ambiente foi executado de ponta a ponta em 15/08/2026: os cinco containers
sobem, o `composer install` completa, as 82 tabelas são criadas e a tela de
login renderiza em http://localhost:8080/autenticacao/entrar.

As travas do nginx foram testadas uma a uma:

| Requisição | Esperado | Obtido |
|---|---|---|
| `/teste_generico.txt` | bloqueado | 403 |
| `/clientes.txt` | bloqueado | 403 |
| `/log_scripts.txt` | bloqueado | 403 |
| `/phpinfofile.php` | bloqueado | 403 |
| `/arquivos/teste.php` | bloqueado e **não executado** | 403, sem execução |
| `/robots.txt` | liberado | 200 |
| `/autenticacao/entrar` | liberado | 200 |

O que ainda **não** foi exercitado: carga de um dump real seguido de
anonimização (o `mysql.sql` só tem schema, então `anonimizar.sh` roda contra
tabelas vazias), e o envio de e-mail chegando ao Mailpit.

## Limitações conhecidas

O ambiente não fala com OneSignal, com a API de SMS nem com o servidor de e-mail
real: as chaves ficam vazias de propósito, para que essas integrações falhem de
forma visível em vez de disparar mensagens de verdade. Para testar uma
integração específica, use uma credencial de sandbox do próprio fornecedor,
nunca a de produção.
