# Migração accia PHP 7.4 → 8.3 — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** o accia roda em PHP 8.3 (container `php83_apache`), com o mesmo código ainda rodando em 7.4, sem regressões comprovadas por crawl comparativo e fluxos de escrita na cópia `psec_vialgpd_php83`.

**Architecture:** o trabalho acontece na worktree `C:\projetos\apps\accia-php83` (branch `migracao-php83`). A infraestrutura nova vive em `C:\projetos\php83\`. Um harness Python (`tools/migracao-php83/`) roda lint, PHPCompatibility, inventário de rotas, crawl Playwright e comparação 7.4 × 8.3, e grava relatórios em `docs/migracao-php83/validacao/`. As correções entram em commits por camada.

**Tech Stack:** PHP 8.3/7.4 (Apache), Yii 2.0.5x, Composer 2, MySQL 5.7 (`mysql57`), Docker Compose, Mailpit, PHP_CodeSniffer + PHPCompatibility, Rector, Python 3.12 + Playwright (Chromium já instalado) + Pillow + `unittest`.

**Spec:** `docs/superpowers/specs/2026-10-22-migracao-php83-design.md`

## Global Constraints

- Nunca alterar: branch `main` do accia, `C:\projetos\docker-compose.yml`, container `php74_apache`, banco `psec_vialgpd`. O banco real só é lido uma vez, pelo `mysqldump`.
- Toda validação usa o banco `psec_vialgpd_php83`.
- O código migrado precisa rodar em PHP **7.4.33 e 8.3**: nada de sintaxe 8.0+ (match, nullsafe, named args, union types, readonly, enums, `str_contains` sem polyfill).
- `composer.json`: `config.platform.php = "7.4.33"`; `yiisoft/yii2: ~2.0.53`.
- Correção de warning de runtime é feita **na causa**: proibido `@`, proibido reduzir `error_reporting` para esconder.
- Validação com `error_reporting=E_ALL`. O `php.ini` de produção entregue usa `E_ALL & ~E_DEPRECATED & ~E_STRICT`.
- Login de validação: usuário `999.999.999-99`, senha `Gledson@2026`, formulário `/site/login` (`LoginForm[username]`, `LoginForm[password]`). Só em containers locais; a senha não entra em nenhum arquivo commitado (vem da variável `ACCIA_VAL_PASS`).
- Nenhum e-mail sai para a internet nos containers de validação: `smtp.sendgrid.net` resolve para o Mailpit **só** na rede `php83_val`.
- Rotas destrutivas excluídas do crawl GET: regex `(?i)(delete|excluir|remove|logout|limpa|envia|send|backup|restore)`.
- php.ini dos containers novos = valores efetivos do 7.4: `memory_limit=128M`, `upload_max_filesize=2M`, `post_max_size=8M`, `max_execution_time=0`, `max_input_vars=1000`, `date.timezone` vazio (igual ao 7.4).
- Commit por camada, mensagem em português, terminando com `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.

## Review Focus

1. **Parâmetro omitido em action com default.** No 7.4, `/financial/tipo/dados-ordena?nivel=1` (sem `tipoId`) é aceito; no 8.x o `$tipoId = null` antes de `$nivel` vira obrigatório, e o Yii responde 400. O esperado é a mesma resposta do 7.4. Teste no Task 9: requisição sem `tipoId` nos dois ambientes, com o mesmo status.
2. **Tela que só quebra com dado real específico** (índice inexistente num registro antigo). O esperado é a tela abrir como no 7.4. Teste no Task 5/11: o inventário usa o menor, o maior e o ID mediano de cada tabela.
3. **Upload de planilha `.xls` legado** (formato BIFF) no `GppTemp`. O esperado é importar igual ao PHPExcel. Teste no Task 7: importar o mesmo `.xls` e o mesmo `.xlsx` nos dois ambientes e comparar as linhas inseridas.
4. **Sessão de usuário com `auth_key` vazio** (1 registro em `user_ceon`). O esperado é login e navegação sem expulsão, como no 7.4. Teste no Task 10: documentar o comportamento observado nos dois ambientes e reportar sem corrigir o dado.
5. **PDF/QR gerados com caracteres acentuados.** O esperado é texto idêntico ao 7.4. Teste no Task 8/12: extrair o texto do PDF (`pdftotext` na imagem) e decodificar o QR (`zbarimg` na imagem), comparando 7.4 e 8.3.

---

## Mapa de arquivos

| Caminho | Responsabilidade |
|---|---|
| `C:\projetos\php83\Dockerfile` | imagem `php83-apache` |
| `C:\projetos\php83\docker-compose.yml` | `php83_apache`, `mailpit_php83`, `php74_val` (perfil `validacao`) |
| `C:\projetos\php83\php\validacao.ini`, `php\producao.ini` | php.ini |
| `C:\projetos\php83\vhosts\accia.conf` | vhost alvo |
| `C:\projetos\php83\vhosts-74val\accia.conf` | vhost base |
| `C:\projetos\php83\mailpit\` | `cert.pem`, `key.pem` (CN/SAN `smtp.sendgrid.net`) |
| `C:\projetos\php83\tools\composer.json` | phpcs + PHPCompatibility + rector, instalados em `/opt/tools` na imagem |
| `accia-php83/tools/migracao-php83/config.py` | URLs base, conexão do banco, regex de exclusão, caminhos |
| `.../lint.py` | `php -l` em todos os arquivos próprios, nos dois containers |
| `.../inventario.py` | extrai rotas + parâmetros + IDs reais → `inventario.json` |
| `.../normaliza.py` | normalização de HTML para diff |
| `.../crawl.py` | Playwright: login, visita, coleta (status, HTML, console, logs) → `crawl-<amb>.json` |
| `.../compara.py` | compara dois `crawl-*.json` + capturas → `comparacao.md/json` |
| `.../fluxos.py` | fluxos de escrita (POST) + consultas antes/depois |
| `.../tests/test_*.py` | unittest do harness |
| `docs/migracao-php83/validacao/` | relatórios |

---

### Task 1: Infraestrutura Docker (php83_apache, mailpit, php74_val)

**Files:**
- Create: todos os arquivos `C:\projetos\php83\*` do mapa

**Interfaces:**
- Produces: `http://localhost:8083` (alvo, Host `accia.localhost`), `http://localhost:8074` (base, perfil `validacao`), Mailpit UI `http://localhost:8025` + API `/api/v1/messages`, binários `/opt/tools/vendor/bin/{phpcs,rector}` no `php83_apache`, `pdftotext` e `zbarimg` instalados (pacotes `poppler-utils`, `zbar-tools`) no `php83_apache`.

- [ ] **Step 1:** gerar o cert do Mailpit com `openssl req -x509 -newkey rsa:2048 -nodes -days 825 -subj "/CN=smtp.sendgrid.net" -addext "subjectAltName=DNS:smtp.sendgrid.net"`, rodando dentro de um container `php:8.3-cli` com saída em `C:\projetos\php83\mailpit\`.
- [ ] **Step 2:** escrever o Dockerfile:
  - `FROM php:8.3-apache`;
  - apt: `libpng-dev libjpeg-dev libfreetype6-dev libzip-dev libicu-dev unzip poppler-utils zbar-tools`;
  - `docker-php-ext-configure gd --with-jpeg --with-freetype`;
  - `docker-php-ext-install pdo_mysql mysqli gd zip intl`;
  - `a2enmod rewrite`;
  - `COPY --from=composer:2 /usr/bin/composer`;
  - instalar `/opt/tools` a partir de `tools/composer.json` (`squizlabs/php_codesniffer:^3.10`, `phpcompatibility/php-compatibility:^10.0@dev` ou a versão estável que suporte testVersion 8.3, `rector/rector:^1.2`);
  - copiar `mailpit/cert.pem` para `/usr/local/share/ca-certificates/mailpit.crt` e rodar `update-ca-certificates`;
  - copiar `php/validacao.ini` para `$PHP_INI_DIR/conf.d/zz-validacao.ini`.
- [ ] **Step 3:** `validacao.ini`: os valores dos Global Constraints, mais `error_reporting=E_ALL`, `display_errors=Off`, `log_errors=On`, `error_log=/var/log/php/php_errors.log`. `producao.ini`: igual, com `error_reporting=E_ALL & ~E_DEPRECATED & ~E_STRICT`.
- [ ] **Step 4:** docker-compose:
  - redes: `projetos_default` (external) e `php83_val` (bridge do compose);
  - `mailpit_php83`: apenas na `php83_val` com `aliases: [smtp.sendgrid.net]`, `MP_SMTP_TLS_CERT`/`MP_SMTP_TLS_KEY` montados, `MP_SMTP_AUTH_ACCEPT_ANY=1`, `MP_SMTP_AUTH_ALLOW_INSECURE=1`, porta `8025:8025`;
  - `php83_apache`: `8083:80`, volumes de apps, `vhosts`, `./logs/php83:/var/log/php`, `env_file: C:\projetos\apps\accia\.env` e override `APP_DATABASE_DB=psec_vialgpd_php83`;
  - `php74_val`: `image: projetos-webserver`, `profiles: [validacao]`, `8074:80`, `vhosts-74val`, `./logs/php74:/var/log/php`, mesmo env e override; montar um ini com `log_errors=On`, `error_log=/var/log/php/php_errors.log` **sem mudar** `error_reporting` (a base precisa refletir o 7.4 real);
  - vhosts: copiar o modelo de `C:\projetos\apache\vhosts\accia.conf`, trocando o DocumentRoot.
- [ ] **Step 5:** `docker compose -f C:\projetos\php83\docker-compose.yml --profile validacao up -d --build`.
- [ ] **Step 6: verificação.** Rodar:
  - `docker exec php83_apache php -v` → `PHP 8.3.x`;
  - `diff <(docker exec php74_val php -m) <(docker exec php83_apache php -m)` → só diferenças esperadas (`json` embutido no 8.x), registradas;
  - `docker exec php83_apache sh -c 'echo QUIT | openssl s_client -starttls smtp -connect smtp.sendgrid.net:587 -verify_return_error'` → `Verify return code: 0` e banner do Mailpit;
  - `docker exec php74_apache getent hosts smtp.sendgrid.net` → IP público (não o do Mailpit);
  - `curl -s -o /dev/null -w "%{http_code}" -H "Host: accia.localhost" http://localhost/` → `302` (o `php74_apache` real segue intacto).
- [ ] **Step 7:** registrar `docker inspect php74_apache --format '{{json .Config}}{{json .HostConfig.Binds}}'` em `C:\projetos\php83\baseline-php74_apache.json` (para o critério 10). Sem commit no accia: infra fora do repositório.

### Task 2: Cópia do banco e linha de base do banco real

**Files:**
- Create: `accia-php83/docs/migracao-php83/validacao/01-copia-banco.md`

**Interfaces:**
- Produces: banco `psec_vialgpd_php83`; arquivo `C:\projetos\php83\checksum-psec_vialgpd-antes.txt`.

- [ ] **Step 1:** `CHECKSUM TABLE` de todas as tabelas de `psec_vialgpd` → `checksum-psec_vialgpd-antes.txt` (prova futura de que o banco real não mudou por ação nossa; o 7.4 real pode gravar sozinho, então a comparação final cobre só as tabelas sem escrita por tráfego externo e registra as demais).
- [ ] **Step 2:** `CREATE DATABASE psec_vialgpd_php83` com o mesmo charset/collation de `psec_vialgpd` (ler de `information_schema.SCHEMATA`), depois `mysqldump --single-transaction --routines --triggers --events psec_vialgpd | mysql psec_vialgpd_php83`, tudo dentro do `mysql57`, rodando em background (cerca de 7,5 GB).
- [ ] **Step 3: verificação.** `SELECT COUNT(*)` por tabela nos dois bancos (225 tabelas) → zero diferenças, exceto tabelas que o 7.4 real escreveu durante o dump (log/sessão), registradas com a diferença.
- [ ] **Step 4:** escrever `01-copia-banco.md` (comando, duração, tamanho, tabela de contagens) e fazer o commit `docs(validacao): copia do banco psec_vialgpd_php83`.

### Task 3: Harness — lint, normalização e comparação (com testes)

**Files:**
- Create: `tools/migracao-php83/{config.py,lint.py,normaliza.py,compara.py}`, `tools/migracao-php83/tests/{test_normaliza.py,test_compara.py}`

**Interfaces:**
- Produces:
  - `normaliza.normalizar_html(html: str) -> str`
  - `compara.classificar(base: dict, alvo: dict, limiar_pct: float = 2.0) -> dict` com chaves `veredito` (`"igual"|"divergente"`) e `motivos: list[str]`
  - `compara.gerar_relatorio(crawl_base: Path, crawl_alvo: Path, saida: Path) -> None`
  - `lint.rodar(container: str) -> list[dict]` com `arquivo`, `erro`
  - formato de cada registro de crawl: `{"url", "status", "ms", "erro_yii": bool, "html_norm_sha", "html_norm_len", "console_erros": [], "log_novos": [], "captura": str|None}`

- [ ] **Step 1:** escrever os testes que falham:
  - `test_remove_csrf`: `normalizar_html('<input name="_csrf" value="abc">') == normalizar_html('<input name="_csrf" value="xyz">')`;
  - `test_remove_meta_csrf`: o mesmo para `<meta name="csrf-token" content="...">`;
  - `test_remove_datahora`: `"22/10/2026 10:11:12"` e `"23/10/2026 09:00:00"` normalizam igual;
  - `test_mantem_conteudo`: `"<td>Valor 10</td>"` ≠ `"<td>Valor 11</td>"`;
  - `test_status_diferente`: `classificar({"status":200,...},{"status":500,...})["veredito"]=="divergente"` e `"status" in motivos[0]`;
  - `test_log_novo`: alvo com `log_novos=["Undefined array key"]` → divergente;
  - `test_ruido_tamanho`: diferença de tamanho de 1% com sha diferente e sem outros sinais → `igual` com motivo `"ruido"`;
  - `test_erro_yii_so_alvo` → divergente.
- [ ] **Step 2:** `python -m unittest discover -s tools/migracao-php83/tests -v` → FAIL (módulos inexistentes).
- [ ] **Step 3:** implementar `normalizar_html` (regex para CSRF em input/meta, datas `dd/mm/aaaa[ hh:mm[:ss]]`, ISO 8601, `nonce="..."`, `?v=\d+`/`?t=\d+` em assets e hashes de `/assets/<hash>/`, que mudam entre containers) e `classificar` (status ≠ → divergente; `erro_yii` só no alvo → divergente; `log_novos` ou `console_erros` só no alvo → divergente; sha igual → igual; tamanho com diferença ≤ `limiar_pct` → igual/ruído; acima disso → divergente com o motivo `"conteudo"`).
- [ ] **Step 4:** `python -m unittest ...` → PASS.
- [ ] **Step 5:** `lint.py`: `find` dos `.php` próprios (exclui `vendor`, `runtime`, `web/assets`, `.git`) e `php -l` em cada um via `docker exec <container>`. Saída em `02-lint-<container>.json`. Verificação: `python tools/migracao-php83/lint.py php74_val` sobre o código atual → 0 erros (o 7.4 roda o sistema hoje).
- [ ] **Step 6:** commit `test(migracao): harness de lint, normalizacao e comparacao`.

### Task 4: Harness — inventário de rotas com IDs reais

**Files:**
- Create: `tools/migracao-php83/inventario.py`, `tools/migracao-php83/tests/test_inventario.py`

**Interfaces:**
- Consumes: `config.DB` (host `127.0.0.1:3306`, banco `psec_vialgpd_php83`, credenciais via env `ACCIA_DB_USER`/`ACCIA_DB_PASS`).
- Produces: `inventario.extrair_actions(raiz: Path) -> list[dict]` (`modulo`, `controller_id`, `action_id`, `params: [{nome, default: bool}]`, `arquivo`, `linha`) e `inventario.gerar(raiz, saida_json) -> None`; o arquivo `docs/migracao-php83/validacao/inventario.json` com `urls: list[str]` já com IDs reais.

- [ ] **Step 1:** testes que falham, com fixtures inline:
  - `actionDadosOrdena($tipoId = null, $nivel)` em `modules/financial/controllers/TipoController.php` → `{"modulo":"financial","controller_id":"tipo","action_id":"dados-ordena","params":[{"nome":"tipoId","default":True},{"nome":"nivel","default":False}]}`;
  - `actionView($id)` em `controllers/LicencaController.php` → `controller_id "licenca"`, `modulo None`;
  - CamelCase `BpTipoController` → `bp-tipo`; `actionFindCliente` → `find-cliente`.
- [ ] **Step 2:** rodar → FAIL.
- [ ] **Step 3:** implementar o parser por regex (sem executar PHP) e a resolução de IDs:
  - para `$id`, tabela do model achada pelo `findModel()` do controller, via `tableName()` do model referenciado;
  - para `*_id`/`*Id`, a tabela de mesmo prefixo;
  - valores = `MIN`, `MAX` e mediana de `id`;
  - sem como inferir → URL sem o parâmetro e marca `"param_nao_inferido"`.
  - Ler `pymysql` se existir; se não, usar `docker exec mysql57 mysql -N -e` (sem instalar pacote).
- [ ] **Step 4:** testes → PASS; `python inventario.py` gera `inventario.json` com ≥ 1.000 actions (são 1.084 no código). Verificação: `jq '.actions|length'`.
- [ ] **Step 5:** commit `test(migracao): inventario de rotas com IDs reais`.

### Task 5: Harness — crawler Playwright + rodada BASE (7.4, código original)

**Files:**
- Create: `tools/migracao-php83/crawl.py`, `tools/migracao-php83/tests/test_crawl.py`

**Interfaces:**
- Consumes: `inventario.json`, `normaliza.normalizar_html`, `config.EXCLUIR`.
- Produces: `crawl.rodar(base_url: str, rotulo: str, inventario: Path, saida: Path, capturas: bool) -> None`; o arquivo `crawl-<rotulo>.json`; capturas em `docs/migracao-php83/validacao/capturas/<rotulo>/`.

- [ ] **Step 1:** testes que falham (sem rede):
  - `test_exclui_destrutivas`: `filtrar(["/x/delete?id=1","/site/logout","/x/view?id=1"]) == ["/x/view?id=1"]`;
  - `test_mesmo_host`: links externos e `#` descartados;
  - `test_detecta_erro_yii`: HTML com `<h1>Internal Server Error</h1>` ou `class="exception"` → `erro_yii=True`.
- [ ] **Step 2:** rodar → FAIL; implementar; → PASS.
- [ ] **Step 3:** implementar `rodar`:
  - login em `/site/login` com `999.999.999-99` e `ACCIA_VAL_PASS`, Host `accia.localhost` (header `Host` via contexto Playwright, ou entrada no hosts do contexto: `--host-resolver-rules="MAP accia.localhost 127.0.0.1:<porta>"`);
  - passada A (inventário) e passada B (BFS dos links, profundidade ≤ 3, mesmo host);
  - antes e depois de cada URL, ler o tamanho de `C:\projetos\php83\logs\<php>\php_errors.log` e de `accia*/runtime/logs/app.log`, guardando as linhas novas em `log_novos`;
  - captura de tela de página inteira só na primeira URL de cada controller;
  - concorrência 1, para que os logs sejam atribuíveis por URL.
- [ ] **Step 4: rodada BASE.** `python crawl.py --base http://localhost:8074 --rotulo base74`. Verificação: `crawl-base74.json` existe, o login funcionou (primeira URL autenticada sem redirect para `/site/login`) e há ≥ 1 registro por controller. URLs que já dão 500 no 7.4 ficam listadas como **erro preexistente** e não contam como regressão.
- [ ] **Step 5:** commit `test(migracao): crawler e rodada base 7.4` (inclui `crawl-base74.json`, `03-base74.md` com o resumo; as capturas vão para `docs/.../capturas/`, comprimidas em JPEG 70%).

### Task 6: Composer — atualização de dependências

**Files:**
- Modify: `composer.json`, `composer.lock`

**Interfaces:**
- Produces: `vendor/` em `accia-php83` com Yii ≥ 2.0.53, PhpSpreadsheet, qrcode-library 3.x e mpdf 8.2+ (usados pelos Tasks 7 e 8). PagSeguro e htmltoopenxml ausentes.

- [ ] **Step 1:** registrar as versões atuais (`composer show --locked` no 7.4) em `docs/migracao-php83/validacao/04-dependencias-antes.txt`.
- [ ] **Step 2:** editar o `composer.json` conforme a spec, tabela 2.2:
  - `yiisoft/yii2: ~2.0.53`;
  - remover `phpoffice/phpexcel`, `rkorebrits/htmltoopenxml`, `pagseguro/pagseguro-php-sdk`;
  - adicionar `phpoffice/phpspreadsheet: ^1.29`;
  - `2amigos/qrcode-library: ~3.1`, `mpdf/mpdf: ^8.2`;
  - `require-dev`: remover `codeception/*`, `symfony/browser-kit`, `mockery/mockery`;
  - `config.platform.php: 7.4.33`;
  - cada `dev-master`/`@dev`/`*`: rodar `composer show -a <pacote>` e fixar a maior tag estável que aceite PHP 7.4 e 8.3; sem tag, `dev-master#<hash do lock atual>`.
- [ ] **Step 3:** `docker exec -w /var/www/html/accia-php83 php83_apache composer update --no-interaction -W` → exit 0. Conflito → ajustar a restrição do pacote citado (nunca usar `--ignore-platform-reqs`).
- [ ] **Step 4: verificação.**
  - `composer validate` → ok;
  - `composer check-platform-reqs` em `php83_apache` e em `php74_val` (que precisa do composer: `docker cp` do binário ou `php composer.phar` do accia) → todos `success`;
  - `composer audit` → salvo em `04-audit.txt`, só registro.
- [ ] **Step 5:** `composer show --locked` → `04-dependencias-depois.txt`; diff no relatório `04-dependencias.md`.
- [ ] **Step 6:** commit `build(composer): atualiza dependencias para PHP 7.4+8.3`. Nesse ponto o código ainda referencia PHPExcel, QR antigo e PagSeguro; os Tasks 7–9 consertam. Não rodar o crawl antes do Task 9.

### Task 7: PHPExcel → PhpSpreadsheet (GppTemp)

**Files:**
- Modify: `modules/performance/models/GppTemp.php:95-96`
- Create: `tools/migracao-php83/fixtures/gpp.xlsx`, `tools/migracao-php83/fixtures/gpp.xls` (gerados a partir do formato que o `upload()` espera; colunas lidas no próprio `upload()`)

- [ ] **Step 1: teste.** Função `fluxo_import_gpp(base_url)` em `fluxos.py`:
  - faz login e envia `gpp.xlsx` na tela que chama `GppTemp::upload()` (achar a action pelo `grep "->upload()"` em `modules/performance/controllers`);
  - consulta `SELECT COUNT(*), MD5(GROUP_CONCAT(...ORDER BY id))` das tabelas `gpp_temp`, `gpp_causa_provavel`, `gpp_acao_proposta`, `gpp_tarefa`;
  - asserção: o resultado do alvo == o da base (7.4, código original);
  - repetir com `gpp.xls`.
- [ ] **Step 2:** rodar no alvo → FAIL (classe `PHPExcel_IOFactory` inexistente).
- [ ] **Step 3:** trocar por `\PhpOffice\PhpSpreadsheet\IOFactory::load(...)` mantendo `->getActiveSheet()->toArray(null, true, true, true)` (mesma assinatura e semântica de chaves por letra de coluna).
- [ ] **Step 4:** rodar → PASS nos dois arquivos; rodar também no `php74_val` apontado para `accia-php83` → PASS.
- [ ] **Step 5:** commit `fix(performance): GppTemp usa PhpSpreadsheet no lugar do PHPExcel`.

### Task 8: QR code 3.x e mpdf 8.2

**Files:**
- Modify: `modules/performance/controllers/ArvoreController.php:238-241`, `modules/performance/controllers/DefaultController.php:505-508`, e as views que consomem `$qrCode` (achar com `grep -rn "qrCode->" modules/performance/views`)

- [ ] **Step 1: teste.** Em `fluxos.py`, `fluxo_qr(base_url)`:
  - abre `/performance/arvore/view?id=<id real>` e extrai a imagem do QR;
  - abre o PDF de protocolo (a action do `DefaultController:505`, com `hash` real de `formulario_resposta`);
  - `zbarimg` decodifica o QR → texto igual à URL esperada (`create-arvore?arvore_id=<id>` / `protocolo-seguranca-validacao?hash=...`);
  - `pdftotext` do PDF da base == o do alvo (normalizando datas).
- [ ] **Step 2:** rodar no alvo → FAIL (API da 1.x).
- [ ] **Step 3:** adaptar à API da 3.x conferindo no vendor: `setSize`/`setMargin` e a troca de `useForegroundColor(0,0,0)` pelo método equivalente da 3.x (ler `vendor/2amigos/qrcode-library/src/QrCode.php`). Ajustar nas views o método de saída (`writeDataUri()` ou equivalente) conforme a classe instalada.
- [ ] **Step 4:** rodar → PASS no alvo e no `php74_val` com `accia-php83`.
- [ ] **Step 5:** commit `fix(performance): QR code na API da qrcode-library 3.x`.

### Task 9: Remoção do PagSeguro + lint + PHPCompatibility + assinaturas

**Files:**
- Modify: `models/Licenca.php:322-415`, `controllers/LicencaController.php:554-677,853-857`, `views/layouts/main_1.php:42`
- Delete: `views/licenca/pagseguro.php`
- Modify: `modules/financial/controllers/BpTipoController.php:456`, `DreTipoController.php:421`, `TipoController.php:427`, `modules/performance/models/Coleta.php:6613`, `components/PHPlot.php:5379`, os arquivos com `utf8_decode`/`utf8_encode` (13 ocorrências) e os demais apontados pelo phpcs

- [ ] **Step 1: PagSeguro.** Remover exatamente o que a spec 4.3 lista. Verificação:
  - `grep -rn 'PagSeguro\\' --include=*.php . --exclude-dir=vendor` → vazio;
  - `php -l` nos 3 arquivos → ok;
  - `/licenca/index` e `/licenca/view?id=<real>` com o mesmo status da base;
  - `/licenca/compra` → mesma resposta da base (vazia, `return false`).
  - Commit `refactor(licenca): remove integracao PagSeguro inativa`.
- [ ] **Step 2: lint.** `python lint.py php83_apache` e `python lint.py php74_val` (com `accia-php83`) → listar os erros, corrigir e repetir até 0. Commit `fix: erros de sintaxe PHP 8.3` (pular se já estiver em 0).
- [ ] **Step 3: phpcs.** `docker exec php83_apache /opt/tools/vendor/bin/phpcs --standard=PHPCompatibility --runtime-set testVersion 7.4-8.3 --extensions=php --ignore=*/vendor/*,*/runtime/*,*/web/assets/* --report=json /var/www/html/accia-php83 > 05-phpcompat-antes.json`, mais um resumo por sniff em `05-phpcompat.md`.
- [ ] **Step 4: assinaturas — teste primeiro.** Em `fluxos.py`, `fluxo_assinaturas(base_url)`:
  - `GET /financial/{tipo,bp-tipo,dre-tipo}/dados-ordena?tipoId=<real>&nivel=1`;
  - o mesmo sem `tipoId`;
  - asserção: status e corpo iguais aos da base 7.4.
  - Rodar no alvo e registrar (esperado FAIL no caso sem `tipoId`).
- [ ] **Step 5: assinaturas — correção.** As três `actionDadosOrdena` passam a `actionDadosOrdena($nivel, $tipoId = null)`. O Yii associa parâmetros de action **por nome** (`yii\web\Controller::bindActionParams`), e os únicos chamadores são URLs com query string (`modules/financial/views/{tipo,bp-tipo,dre-tipo}/ordena.php:54-55`). A ordem invertida reproduz o 7.4: sem `tipoId` → `null`; sem `nivel` → 400. Para `Coleta::geraColetaApi($dados = [], $user_id)`: `grep -rn geraColetaApi` não acha chamadores; mudar para `geraColetaApi($dados, $user_id)` e registrar "sem chamadores". Observação: `operational/controllers/DashboardController.php:504`, citado na spec, **não** tem o problema (todos os parâmetros têm default) e fica de fora. Rodar o teste → PASS.
- [ ] **Step 6: demais itens do phpcs:**
  - `strftime($fmt, $ts)` → `date()` com o formato traduzido, só se `$format['time_format']` vier de chamadas internas com formato fixo; verificar os chamadores em `PHPlot.php` antes;
  - `utf8_decode($s)` → `mb_convert_encoding((string) $s, 'ISO-8859-1', 'UTF-8')` e `utf8_encode` → o inverso (o cast mantém o comportamento com `null`);
  - `"${x}"` → `"{$x}"` (só em strings PHP, não em templates JS dentro de HTML).
  - Repetir o phpcs → 0 `ERROR`; `WARNING` residual com justificativa em `05-phpcompat.md`.
- [ ] **Step 7:** commit `fix: compatibilidade PHP 8.x apontada pelo PHPCompatibility`.

### Task 10: Rector (só compat) + notas de upgrade do Yii

**Files:**
- Create: `rector.php` em `tools/migracao-php83/`
- Modify: os arquivos que o Rector alterar; os que a auditoria do Yii indicar

- [ ] **Step 1:** `tools/migracao-php83/rector.php` com `->withPhpVersion(PhpVersion::PHP_74)` e **uma única regra**: `Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector`. Ela envolve argumentos possivelmente nulos de funções nativas em `(string)`, válido em 7.4, e elimina a deprecation 8.1 "Passing null to parameter". Nenhum set de nível. Nenhuma regra que gere sintaxe 8.0+.
- [ ] **Step 2:** `rector process --dry-run` → salvar o diff em `06-rector-dryrun.diff`, revisar e remover regras que mudem semântica. Depois `rector process`. Verificação: `lint.py` nos dois containers → 0.
- [ ] **Step 3:** commit `refactor: regras Rector de compatibilidade PHP 8.x`.
- [ ] **Step 4: auditoria do Yii.** Para cada nota da spec 2.3, `grep` + leitura, com veredito "afeta / não afeta" em `07-yii-upgrade.md`:
  - `DbSession` (há `writeCallback`/`readCallback` em `config/web.php`?);
  - controllers com `init()` sem `parent::init()` (`grep -rn "function init" --include=*Controller.php` e conferir);
  - validadores `static function` (contagem atual: 0);
  - `getCode()` comparado a inteiro;
  - typecast `UNSIGNED`;
  - `Query::select` lido diretamente;
  - `UploadedFile::deleteTempFile`/`isUploadedFile`;
  - `Inflector::slug`;
  - `NumberValidator` com espaços.
  - Corrigir o que afeta; commit `fix: ajustes de upgrade Yii 2.0.16 -> 2.0.5x`.
- [ ] **Step 5 (Review Focus 4):** login no alvo e na base com o usuário de `user_ceon` sem `auth_key`, se a senha for conhecida; senão, documentar em `07-yii-upgrade.md` o comportamento previsto pelo código (`validateAuthKey` / `IdentityInterface`) como **não verificado**. Não alterar o dado sem autorização.

### Task 11: Crawl comparativo e correções de runtime (laço até zerar)

**Files:**
- Modify: os arquivos apontados pelas divergências

- [ ] **Step 1:** `python crawl.py --base http://localhost:8083 --rotulo alvo83` e `python compara.py --base crawl-base74.json --alvo crawl-alvo83.json --saida 08-comparacao.md`.
- [ ] **Step 2:** para cada divergência, agrupar pela causa (mesma linha de `log_novos`) e corrigir na causa:
  - acesso a índice/propriedade possivelmente ausente → `isset()`/`??` com o mesmo valor que o 7.4 produzia (`null`);
  - `count()` em não-array → `is_countable`, ou o tipo real garantido.
  - Repetir só as URLs afetadas até `veredito=igual`.
- [ ] **Step 3:** commit por grupo de causa: `fix(<modulo>): <causa> no PHP 8`.
- [ ] **Step 4:** crawl completo de novo → `08-comparacao.md` com **0 divergências não explicadas**. As explicadas (ex.: erro preexistente no 7.4, diferença de ordenação devida ao MySQL) são listadas com evidência.
- [ ] **Step 5:** comparação de capturas (`compara.py --capturas`: PIL `ImageChops.difference` + `getbbox`, com limiar de 0,5% de pixels) → `09-capturas.md` com as diferenças anotadas.

### Task 12: Fluxos críticos de escrita

**Files:**
- Create/Modify: `tools/migracao-php83/fluxos.py`
- Create: `docs/migracao-php83/validacao/10-fluxos.md`

**Interfaces:**
- Consumes: `fluxo_import_gpp`, `fluxo_qr`, `fluxo_assinaturas` (Tasks 7–9).
- Produces: `fluxos.rodar_todos(base_url, rotulo) -> list[dict]`, cada registro com `{"fluxo", "status_http", "db_antes", "db_depois", "email": dict|None, "ok": bool}`.

- [ ] **Step 1:** definir os fluxos com o `inventario.json` (actions `create`/`update` com formulário):
  - um create + update por módulo: `performance`, `financial`, `operational`, `contract`, `risk`, `task`;
  - login/logout com consulta da tabela `session`;
  - endpoints JSON de `modules/api` (todas as actions GET do `V1Controller` com parâmetros reais), comparando com `json.loads` igual entre base e alvo;
  - PDF e Word do `AnexoController` (texto via `pdftotext`; `.docx` via `zipfile` + `word/document.xml` normalizado);
  - um fluxo que envia e-mail (achar com `grep -rn "mailer->compose" --include=*.php`, escolhendo um que não dependa de serviço externo), consultando a API do Mailpit `GET /api/v1/messages` (destinatário e assunto).
- [ ] **Step 2:** cada fluxo roda na base e no alvo, com os registros criados identificados por um prefixo `MIGR83-<rotulo>-` nos campos de texto. Asserção: o mesmo número de linhas afetadas e os mesmos valores (exceto id, timestamps e prefixo).
- [ ] **Step 3:** falhas → corrigir na causa (como no Task 11) com o commit `fix(<modulo>): ...`; rodar de novo até 100% `ok`.
- [ ] **Step 4:** `10-fluxos.md` com a tabela; commit `test(migracao): fluxos criticos de escrita`.

### Task 13: Compatibilidade dupla, console, produção e relatório final

**Files:**
- Modify: `C:\projetos\php83\vhosts-74val\accia.conf` (DocumentRoot → `accia-php83/web`)
- Create: `docs/migracao-php83/validacao/RELATORIO-FINAL.md`, `C:\projetos\php83\README.md`

- [ ] **Step 1: console.** `./yii` e `./yii help <cmd>` para cada comando listado, em `php83_apache` e `php74_val` (com `accia-php83`) → saídas iguais (diff) → `11-console.md`.
- [ ] **Step 2: dupla.** Trocar o vhost do `php74_val` para `accia-php83`, reiniciar, `crawl.py --rotulo migr74`, `compara.py base74 × migr74` → 0 divergências não explicadas; rodar `fluxos.rodar_todos` em `migr74` → 100% ok. Restaurar o vhost depois.
- [ ] **Step 3: critério 10.**
  - `CHECKSUM TABLE` do `psec_vialgpd` agora vs. `checksum-...-antes.txt`: só mudam tabelas escritas por tráfego do 7.4 real, cuja lista é documentada;
  - `docker inspect php74_apache` igual a `baseline-php74_apache.json`;
  - `git -C accia log -1` = `3205ad5` e `status` limpo.
- [ ] **Step 4:** Mailpit: `GET /api/v1/messages` → listar todas as mensagens capturadas (critério 9).
- [ ] **Step 5:** `C:\projetos\php83\README.md`: como subir e derrubar, e como aplicar o `producao.ini`.
- [ ] **Step 6:** `RELATORIO-FINAL.md`:
  - critérios 1–10 da spec, cada um com ✅/❌ e o link para a evidência;
  - não verificado (CasperJS, `auth_key` se aplicável, integrações externas encontradas);
  - pendências (merge, deploy, `DROP` da cópia, decisão sobre `producao.ini`).
- [ ] **Step 7:** commit `docs(validacao): relatorio final da migracao PHP 8.3`.
