# F1.8 — A migração dos dados e o corte

Feita em 27/08/2026, no ambiente local, contra a base real anonimizada.

O portão desta frente — e da fase inteira — é objetivo: **os mesmos 67 testes de
caracterização, sem alteração nenhuma no que eles verificam, passando contra
PostgreSQL.** Se o comportamento financeiro mudou, eles acusam.

**Situação: portão fechado.**

| | MySQL | PostgreSQL |
|---|---|---|
| Caracterização (67 testes) | **OK**, 128 asserções | **OK**, 128 asserções |
| Contrato (28 testes) | 4 falhas conhecidas | **as mesmas 4**, nenhuma nova |
| Tempo da suíte | 7 min 38 s | 2 min 31 s |

As 4 falhas de contrato são anteriores à migração e já estavam medidas: elas
aparecem igual com `ONLY_FULL_GROUP_BY` ligado e desligado, nos dois bancos.

---

## A carga

```bash
sh scripts/migrar-dados.sh          # gera-se com scripts/gerar-migracao-dados.php
php scripts/conferir-migracao.php   # sai 1 se qualquer prova falhar
```

**83 tabelas, 533 segundos.** As maiores: `agendas` 4.000.081,
`vendas_produtos` 3.780.224, `vendas` 2.269.209, `clientes` 242.587.

O roteiro é **gerado**, não escrito à mão, porque são 83 tabelas e 77 colunas
booleanas — uma lista manual fica desatualizada no primeiro `ALTER TABLE`, e o
erro apareceria como coluna faltando no meio de uma carga de horas.

É **repetível**: esvazia o destino antes de começar. Um corte não se faz na
primeira tentativa, ensaia-se — e o passo que se esquece às três da manhã é
justamente recarregar o schema.

### As quatro coisas que os dois bancos não combinam sozinhos

| | O que | Como |
|---|---|---|
| 1 | Booleano | o MySQL grava 0/1, o COPY espera `true`/`false` — conversão no SELECT |
| 2 | Inquilino | `inquilino_id` não existe no MySQL; a sessão declara e o padrão preenche |
| 3 | Chaves estrangeiras | `session_replication_role = replica` durante a carga, conferência depois |
| 4 | Sequências | `GENERATED BY DEFAULT AS IDENTITY` não avança com id explícito — `setval` no fim |

---

## A conferência: cinco provas independentes

Cada uma pega uma classe de erro que as outras não pegam.

| Prova | Resultado |
|---|---|
| Contagem por tabela | **83 de 83 idênticas** |
| Soma de toda coluna decimal | **85 de 85 idênticas** |
| Órfãos de chave estrangeira | **127 referências, 0 órfãos** |
| Sequências à frente do maior id | **0 atrasadas** |
| Extremos de cada coluna de data, como texto | **151 de 151 idênticas** |

A segunda é a que importa. Contagem igual não diz nada sobre o que está *dentro*
das linhas: escala errada, separador trocado, valor virado nulo — nada disso
muda a contagem, e tudo muda a soma.

**Correção de 27/08/2026, feita ao revisar.** A primeira versão desta prova
comparava as somas em ponto flutuante, com tolerância de meio centavo, e este
documento dizia que a tolerância era ruído de leitura "porque as duas somas vêm
de tipos decimais exatos". **Não vêm:** as 85 colunas numéricas do sistema são
`double`, nos dois bancos (**D49**). 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.

Agora a soma é feita em **decimal exato dos dois lados** — `SUM(CAST(col AS
DECIMAL(38,6)))` no MySQL, `SUM(round(col::numeric, 6))` no PostgreSQL — e
comparada como **texto**, sem passar pelo float do PHP. Assim a ordem da soma
deixa de importar e a diferença aceita passa a ser zero de verdade. Verificada
somando um centavo a uma linha: a prova acusou.

A quarta foi verificada **quebrando-a de propósito**: um `setval('vendas_id_seq',
1)` deliberado, e a prova acusou. Uma prova que nunca falhou não é prova.

---

## O que a carga quase levou embora, calado

Três defeitos no roteiro que **não davam erro** — a diferença entre uma migração
que falha e uma que corrompe.

**A crase virava execução de comando.** O SELECT sai dentro de aspas duplas do
shell, e ali a crase não delimita identificador: é substituição de comando.
`` `id` `` **executa o comando `id`**. Como ele existe e devolve zero, a carga
teria colocado `uid=0(root)...` no lugar do nome da coluna, sem reclamar.

**O `-q` do psql escondia a confirmação.** O modo silencioso suprime a linha
`COPY <n>` — exatamente o que a conferência procura. Uma carga de 4 milhões de
linhas que deu **certo** era relatada como falha, e o roteiro parava.

**O `tail -1` engolia o erro.** Num COPY que falha, o psql escreve o ERRO e
depois um CONTEXT; a última linha é o CONTEXT, que diz onde parou e não o que
houve. Pior: como o CONTEXT contém a palavra `COPY`, a primeira versão da
conferência aceitava a **mensagem de erro** como prova de sucesso.

E um quarto, de leitura — com uma **correção de 27/08/2026**, feita ao começar
a F1.9. Escrevi aqui que "o RLS tem FORCE, então toda consulta sem
`app.inquilino_id` devolve zero linhas". **As duas metades estavam erradas.** A
medição direta mostra outra coisa:

| Quem consulta | Sem declarar o inquilino |
|---|---|
| `franquiabv`, o usuário da aplicação | devolve **tudo** — é superusuário, e superusuário ignora RLS mesmo com `FORCE` |
| um papel comum | **`ERROR: unrecognized configuration parameter "app.inquilino_id"`** — falha alto |

O isolamento por inquilino **não está em vigor hoje**, porque quem conecta é
superusuário; e quando estiver, a falta do parâmetro dá **erro**, não silêncio.
Os zeros que me confundiram durante a carga vinham de tabela genuinamente vazia,
não do RLS. Ver **D48**.

### O retorno de carro

O achado com maior alcance. Medido, não suposto:

```
SELECT CONCAT('A',CHAR(10),'B'), CONCAT('C',CHAR(13),'D'),
       CONCAT('E',CHAR(9),'F'),  CONCAT('G',CHAR(92),'H')

A \n B <TAB> C <CR> D <TAB> E \t F <TAB> G \\ H
```

O cliente MySQL em modo lote escapa **avanço de linha**, **tabulação** e **barra
invertida** — e entrega o **retorno de carro cru**. O COPY do PostgreSQL lê CR
cru como fim de linha: corta o registro no meio e morre com `missing data for
column`. São ~800 valores, quase todos modelos de mensagem digitados em
textarea, onde o CR vem do CRLF do navegador.

A troca é feita **no cano**, por um `sed` dentro do contêiner: ali o dado já vem
escapado, toda barra invertida é escape e nenhuma é dado, então trocar CR pelo
escape `\r` é sem ambiguidade e **sem perda** — o COPY decodifica de volta.
Fazer no SELECT não funcionaria: a barra que o SQL produzisse seria escapada de
novo pelo próprio cliente.

O filtro vai como **arquivo**, não como argumento. Entre o shell do host, o
docker e o `sh` do contêiner há três camadas de aspas, e cada uma come um nível
de barra invertida — o `sed` acabava recebendo "troque CR por CR", um no-op
invisível. Um heredoc literal tem uma camada só, e dá para conferir com `od`.

### O fuso — achado na revisão, e o pior de todos

Encontrado em 27/08/2026, **depois** de a carga inteira já ter passado nas
quatro provas que existiam.

O contêiner PostgreSQL nasce em **UTC**. O dump do MySQL traz **hora local, sem
fuso** — `2017-05-13 18:34:51`. As **133 colunas de data** são `timestamp with
time zone`. Numa sessão UTC, aquele texto entra como 18:34 **UTC**, que é 15:34
em São Paulo:

```
venda 100000

  MySQL                              2017-05-13 18:34:51
  PostgreSQL, sessão em UTC          2017-05-13 18:34:51+00
  PostgreSQL, sessão em São Paulo    2017-05-13 15:34:51-03
```

**Toda data do sistema, três horas atrasada.** Em ~13 milhões de linhas.

O que torna isso pior que os outros achados é a forma como ele se esconde:
**enquanto a aplicação também estiver em UTC, ida e volta ficam consistentes e
os dígitos exibidos batem.** Os 67 testes de caracterização passaram. As quatro
provas da carga passaram. O erro só aparece quando alguém ajusta o fuso para o
correto — que é precisamente o que se faz ao levar para produção.

O conserto vai no mesmo lugar onde o `search_path` já ia, e pelo mesmo motivo —
toda sessão precisa, nenhuma deve ter de lembrar:

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

**Ajustar depois não conserta**: o dado já entrou errado, e é preciso recarregar.

### A quinta prova, que existe por causa disso

As quatro provas originais — contagem, soma, órfãos, sequências — não olhavam
para data. Contagem não muda; soma só cobre coluna numérica; órfão é chave;
sequência é contador. **Um conjunto de provas descobre que tem buraco pelo
defeito que atravessa**, e este atravessou os quatro.

A quinta compara **mínimo e máximo de cada coluna de data, como texto**, nos
dois bancos. Como texto de propósito: é assim que a data chega à tela e ao
relatório, e comparar como instante absoluto esconderia justamente este erro.

Uma consulta **por tabela**, não por coluna: mínimo e máximo de todas as colunas
de data saem numa varredura só. São 151 colunas, várias em tabelas de milhões de
linhas.

Verificada acusando: com o fuso ajustado e o dado antigo ainda carregado, ela
apontou as 151 colunas, todas com exatamente três horas de diferença.

### A conexão vinha em latin1

As tabelas são `utf8mb3`, mas o cliente negociava `latin1` e o MySQL
transcodificava na saída. "Florianópolis" chegava com `0xf3`, e o PostgreSQL
recusava: `invalid byte sequence for encoding "UTF8"`. Não era dado corrompido —
era a conexão. `--default-character-set=utf8mb4` resolve.

---

## O que o portão encontrou na aplicação

Nove classes de incompatibilidade. Todas foram **encontradas executando**, não
lendo código.

### 1. Agregação implícita (2 consultas)

```sql
SELECT SUM(his.total) FROM PontuacaoHistorico his
 WHERE ... ORDER BY his.dataCriacao DESC
```

Não há `GROUP BY`, então a auditoria da F1.4 pulava o bloco inteiro. Mas um
agregado no SELECT sem GROUP BY agrega a consulta toda: o conjunto de
agrupamento é **vazio** e nenhuma coluna nua é permitida — nem no SELECT, nem no
ORDER BY.

Nos dois casos o `ORDER BY` era peso morto (uma agregação sem GROUP BY devolve
exatamente uma linha), então removê-lo preserva o comportamento nos dois bancos.

**`scripts/auditar-group-by.php` foi estendido** para enxergar essa forma. A
extensão distingue agregado do nível de fora de agregado dentro de subconsulta —
sem isso, toda projeção com um `(SELECT COUNT(*) ...)` viraria falso positivo.
O portão da F1.4 continua em **0 quebras**, agora sobre 102 consultas em vez de
99.

### 2. `SET NAMES utf8mb4` (18 pontos)

Sintaxe do MySQL, espalhada por 8 arquivos. Vira
`ISBase\Doctrine\Charset::garantirUtf8mb4()`, que só emite a instrução quando a
plataforma é MySQL.

**Por que um guarda e não a remoção**, agora que `charset` é declarado na
conexão: o `local.php` que vale em produção **não é versionado**. Se ele subir
sem `charset`, a conexão volta a nascer em utf8mb3 e os 18 pontos deixam de
proteger justamente o texto do cliente.

### 3. Funções DQL (6 de 8)

| Função | MySQL | PostgreSQL |
|---|---|---|
| `RAND()` | `RAND()` | `RANDOM()` |
| `DATE(x)` | `DATE(x)` | `CAST(x AS date)` |
| `DAY/MONTH/YEAR(x)` | idem | `CAST(EXTRACT(... FROM x) AS integer)` |
| `DAYOFWEEK(x)` | 1 = domingo | `EXTRACT(DOW)` **+ 1** |
| `TIMESTAMPDIFF(u,a,b)` | idem | por unidade, via `age()` ou `EPOCH` |

**`DAYOFWEEK` é o perigoso.** Ali a diferença não é de nome, é de
**numeração**: as duas linguagens têm a função, com origens diferentes (1 = domingo
no MySQL, 0 = domingo no PostgreSQL). Uma tradução ingênua **não falharia** —
rodaria devolvendo o dia errado, em silêncio. O `+ 1` preserva a numeração que os
chamadores usam.

`TIMESTAMPDIFF` usa `age()` para YEAR e MONTH porque `age()` conta como pessoa
conta idade, respeitando dia e mês. `EXTRACT(YEAR FROM b) - EXTRACT(YEAR FROM a)`
daria 1 para 31/12 → 01/01 e envelheceria meio cadastro da noite para o dia.
Unidade sem tradução **lança exceção de propósito**: aproximar produziria um
número plausível e errado, e ninguém confere idade plausível.

### 4. `true` do PHP amarrado a coluna inteira (30 pontos)

`'status' => true` contra `vendas.status`, que é **inteiro de verdade** — guarda
0, 1 e **3**, com 43 linhas no 3. No MySQL `true` vira 1 e ninguém percebe; no
PostgreSQL o PDO manda `'t'` e a consulta morre.

A suíte encontrou uma parte. As outras vieram de uma varredura que resolve o
alias pelo `FROM` **e pelo `JOIN`** (seguindo a associação declarada na
entidade) e consulta o tipo real no catálogo — 30 ocorrências, **0 aliases não
resolvidos**. A troca só acontece quando o parâmetro não encosta em nenhuma
coluna booleana no mesmo método; casos mistos seriam relatados, e não houve
nenhum.

### 5. A ordenação de nulos — o achado mais silencioso

```
ORDER BY col ASC     MySQL: nulos PRIMEIRO    PostgreSQL: nulos POR ÚLTIMO
ORDER BY col DESC    MySQL: nulos POR ÚLTIMO  PostgreSQL: nulos PRIMEIRO
```

O MySQL trata nulo como o **menor** valor; o PostgreSQL, como o **maior**. Não há
erro, não há aviso: a consulta roda nos dois e devolve as **mesmas linhas em
ordem diferente**.

Foi assim que apareceu — uma falha de contrato em `unidades-selecionar`, onde a
única unidade sem `estado` (a de teste) trocava de ponta e mudava os tipos
inferidos do instantâneo.

Medindo o alcance: **28 ordenações por coluna anulável, em 17 colunas**, de 157
métodos com `ORDER BY`. E o que importa é quantos nulos existem de fato:

| Coluna | Nulos | Linhas |
|---|---:|---:|
| `clientes.nps` | **228.924** | 242.587 |
| `clientes.ultima_visita` | 12.211 | 242.587 |
| `clientes.consumo` | 4.628 | 242.587 |
| `convenios.data_ultima_venda` | 188 | 638 |
| as outras 13 | 0 a 7 | — |

**94% dos clientes não têm NPS.** Ordenar a listagem por NPS decrescente no
PostgreSQL traria essas 228.924 linhas primeiro. A tela não quebra: fica inútil,
e quem olha conclui que o relatório está errado, não o banco.

`ISBase\Doctrine\Ordenacao::comoMysql()` reproduz a regra do MySQL com uma chave
antes da real, em SQL padrão aceito pelos dois:

```sql
ORDER BY CASE WHEN col IS NULL THEN 0 ELSE 1 END <dir>, col <dir>
```

`NULLS FIRST`/`NULLS LAST` seria mais direto, mas o DQL do Doctrine 2 não conhece
essa sintaxe — e o objetivo é trocar o banco, não reescrever as consultas.

Aplicado nas 8 ordenações com nulos reais ou expostas por teste. Prova:

```
ORDER BY nps DESC puro      MySQL: 36, 58, 59   PostgreSQL: 2, 3, 1  (todos NULOS)
com a chave                 MySQL: 36, 58, 59   PostgreSQL: 58, 36, 59
```

### 6. O andaime dos testes

Não é a aplicação, mas conta: **os testes escreviam 0/1 em coluna booleana** e
**afirmavam sobre booleano com `(int)`**.

O segundo é uma armadilha de verdade: o PDO do PostgreSQL devolve `'t'`/`'f'`, e
**`(int) 't'` vale ZERO**. `assertSame(0, (int) $x)` passa com o valor
**verdadeiro** — o teste fica verde justamente quando deveria acusar. Quatro
asserções estavam nessa situação; agora passam por `self::booleano()`, que falha
alto nos dois bancos.

As fixtures viraram um arquivo só para os dois dialetos: `TRUE`/`FALSE` (aceitos
pelos dois) e `INSERT ... SELECT ... WHERE NOT EXISTS` no lugar do `INSERT
IGNORE`, que é exclusivo do MySQL — o `ON CONFLICT` equivalente é exclusivo do
PostgreSQL.

---

## A aplicação apontada para o PostgreSQL

```bash
cp config/autoload/local.postgres.php.dist config/autoload/local.php
docker compose exec -T app sh -c 'rm -rf data/DoctrineORMModule/Proxy/*'
```

Guarde o `local.php` anterior: é ele que aponta para o MySQL.

Três peças tornam isso possível:

**O `driverClass` deixou de ser fixo.** Fica em `doctrine.global.php` como
padrão, e o `local.php` — que não é versionado, e por isso pode diferir entre
máquina, homologação e produção — escolhe.

**O `charset` substituiu o `PDO::MYSQL_ATTR_INIT_COMMAND`.** Aquela constante só
existe com a extensão MySQL carregada: com o driver PostgreSQL, o arquivo
quebraria **antes** de qualquer conexão.

**`ISBase\Doctrine\DeclararInquilino`** declara `app.inquilino_id` logo depois de
cada conexão. Hoje ele é inócuo, porque a aplicação conecta como superusuário e
superusuário ignora RLS; passa a ser indispensável no dia em que ela deixar de
conectar assim — e sem ele a consulta **falha com erro**, não devolve vazio.
Ver **D48**.

---

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

**Não prova que o corte de produção vai correr bem.** Prova que a carga preserva
os dados e que o comportamento financeiro medido não mudou. O corte real tem
janela, tráfego e volta atrás, e nada disso foi ensaiado aqui.

**A cobertura é a dos 67 testes.** Eles cobrem o núcleo financeiro — venda,
pagamento, comissão, royalty, fidelidade, caixa. Não cobrem relatórios, telas de
cadastro, integrações, nem as 29 rotinas de cron. As nove classes acima foram
encontradas nesse recorte; **é razoável esperar mais fora dele**, e as varreduras
por catálogo existem justamente para reduzir esse resto.

**A base é a anonimizada.** A anonimização reescreve exatamente as colunas de
texto e data onde moram os casos difíceis — foi assim que 2.397 datas inválidas
passaram despercebidas até a F1.3. O que roda aqui não é prova sobre o dado real.

**O tempo não é comparável.** 533 segundos para 16 milhões de linhas numa
máquina local, com origem e destino no mesmo host. E a suíte rodar 3× mais rápido
no PostgreSQL não diz nada sobre produção: são bases recém-carregadas, sem
concorrência e sem o cache aquecido de um servidor em uso.

---

## O que falta

| | O que | Por quê |
|---|---|---|
| 1 | **F1.9** — repontar o BI | usuário só-leitura numa réplica |
| 2 | Ensaio de corte cronometrado | com janela, volta atrás e tráfego real |
| 3 | As 21 ordenações restantes | sem nulos hoje; ver **D46** |
| 4 | Inquilino vindo de quem autentica | hoje é fixo na configuração; ver **D45** |
| 5 | Ordem entre empates | indefinida nos dois bancos; ver **D47** |
