# DEPLOY — Assistente RH (CT dev `assistente-dev`, 192.168.69.150)

Este documento cobre a instalação e o funcionamento do CT de dev tal como executado até à Fase 4. Não cobre prod (Fase 5).

## 1. Ambiente

- CT 117, hostname `assistente-dev`, IP 192.168.69.150, Debian 13.
- PHP 8.4 (php-fpm), nginx (root `public/`), MariaDB, Composer.
- App em `/var/www/assistente`, utilizador de sistema `claude` (sem sudo) para deploy, `www-data` para o servidor.
- NAS montada read-only em `/mnt/processos` (CIFS, systemd automount) — ver Fase 1.
- Leitor (mac mini): `https://10.0.30.231:8090`, token em `.env` (`LEITOR_TOKEN`).
- Wrapper SSH CT 107: instalado no Sabichão de dev (CT **100**, 192.168.69.20 — não é o CT 107, que é só de produção), chave de consulta em `/opt/assistente/.ssh/assistente_consulta`. Desde a Fase 4, também a chave de **apply** (`/opt/assistente/.ssh/assistente_apply`, gerada no próprio CT 117 e nunca copiada para fora): `apply`/`ficha_pdf`/`email` ligados em dev, atrás da flag `WRAPPER_APPLY_ATIVO=dev` no `.env` do CT assistente e confirmados remotamente por `consulta.alvo` antes de qualquer ação (nunca em prod — não existe rota nenhuma para "Aplicar em PROD").
- **wkhtmltopdf**: necessário no Sabichão de dev (CT 100) para `ficha_pdf`/`email` (render da ficha). Não existe no repositório apt do Debian 13 (trixie) — instalado a partir do `.deb` estático oficial (ver secção 11).

## 2. Sincronizar o código

```bash
export COPYFILE_DISABLE=1
rsync -a -e "ssh -i $HOME/.ssh/id_ed25519_claude" \
  --exclude vendor --exclude node_modules --exclude .env \
  --exclude storage --exclude bootstrap/cache --exclude tests/fixtures \
  /Volumes/www/assistente/app/ claude@192.168.69.150:/var/www/assistente/
```

## 3. Instalar/atualizar no CT

```bash
ssh -i ~/.ssh/id_ed25519_claude claude@192.168.69.150
cd /var/www/assistente
composer install --no-dev --optimize-autoloader   # ou sem --no-dev em dev, para correr os testes
php artisan migrate --force
php artisan config:clear && php artisan route:clear && php artisan view:clear
```

## 4. .env (campos relevantes, valores reais só no CT, nunca no repo)

```
APP_ENV=local
APP_DEBUG=true
DB_CONNECTION=mysql
DB_DATABASE=assistente
SESSION_DRIVER=database
SESSION_LIFETIME=60
QUEUE_CONNECTION=database
LEITOR_URL=https://10.0.30.231:8090
LEITOR_TOKEN=<token>
WRAPPER_HOST=192.168.69.20
WRAPPER_USER=assistente
WRAPPER_CHAVE_CONSULTA=/opt/assistente/.ssh/assistente_consulta
FICHAS_PROCESSOS_PATH=/mnt/processos

# Fase 4
WRAPPER_CHAVE_APPLY=/home/claude/.ssh/assistente_apply
WRAPPER_APPLY_ATIVO=dev
# Segundo caminho, só tentado quando o MAI não existe no principal — reservado
# a casos SINTÉTICOS de teste ponta a ponta, nunca dados reais (docs/PLANO.md
# Fase 4 ponto 6). Remover/comentar fora de uma sessão de teste.
FICHAS_PROCESSOS_PATH_TESTE=/var/www/assistente/storage/app/e2e-fixtures/processos
```

## 5. Criar o utilizador de testes

```bash
php artisan utilizadores:criar rh "Recursos Humanos" --gerar-password
```

A password aparece uma única vez no terminal e fica também em `storage/app/.rh-pass` (0600, fora do repo — `.gitignore` já exclui `/storage/*`).

## 6. Worker da fila

**Instalado como serviço systemd desde a Fase 4** (23/09/2026) — via root do Proxmox (`pct push` + `pct exec 117`, o CT 117 em si não tem sudo, mas o host tem):

```bash
# a partir do host Proxmox (root@192.168.69.2):
pct push 117 deploy/assistente-queue.service /etc/systemd/system/assistente-queue.service
pct exec 117 -- systemctl daemon-reload
pct exec 117 -- systemctl enable --now assistente-queue
```

`User=www-data`/`Group=www-data` no unit file — coerente com quem já corre o php-fpm e lê o `.env` (0640... na prática `rw-rw-r--`, legível por `www-data`). Depois de qualquer `rsync` que mude código do job/fila, `systemctl restart assistente-queue` (como root, via `pct exec`) é obrigatório — o worker carrega as classes uma vez no arranque, tal como o `queue:work` manual já exigia (ver armadilha na secção 9).

Confirmar que está a correr: `pct exec 117 -- systemctl status assistente-queue --no-pager`.

Antes da Fase 4, sem systemd, testava-se com o worker à mão em background (mantido aqui só como alternativa de emergência):

```bash
cd /var/www/assistente
nohup php artisan queue:work --queue=default --tries=3 --sleep=3 > storage/logs/queue-worker.log 2>&1 &
```

Parar: `pkill -f "queue:work"`.

## 7. nginx (root público, PHP-FPM 8.4)

Sem alterações à configuração existente do CT 117 nesta fase — a app já respondia em `http://192.168.69.150/` desde a Fase 1/2 (ver FASE2-RESULTADOS.md). Confirmar `root` a apontar para `/var/www/assistente/public` e `fastcgi_pass` para o socket do php8.4-fpm.

## 8. Permissões

- `storage/` e `bootstrap/cache/` escreváveis por `www-data` (servidor) e pelo utilizador que corre o worker.
- `storage/app/.rh-pass` fica 0600, dono do processo que criou o utilizador.

## 9. Armadilhas encontradas na Fase 3 (guardar para a próxima vez)

- **`storage/` e `bootstrap/cache/` sem escrita para `www-data`**: o rsync corre como o utilizador `claude` (dono `claude:claude`, modo 775); o pool php-fpm corre como `www-data`, que não está no grupo `claude` e não tem sudo disponível para o corrigir de vez. Sem escrita em `storage/framework/views`, qualquer Blade novo dá um 500 mudo (a própria página de erro do Laravel 12 falha a renderizar — `tempnam()` na spotlight ascii — escondendo o erro real) e nada fica em `storage/logs/laravel.log`. Mitigação usada: `chmod -R o+rwX storage bootstrap/cache` a seguir a cada rsync (fica com `o+w`, aceitável em dev; em prod isto precisa de `www-data` no grupo do deploy, ou sudo para chown, decidido na Fase 5). Depois de `www-data` criar ficheiros lá dentro, o `claude` deixa de poder fazer `chmod` a esses ficheiros específicos (não é dono) — normal, ignorar o erro pontual do `chmod -R`.
- **Suite Pest a correr contra a BD de dev partilhada**: `tests/Pest.php` usa `RefreshDatabase` sem SQLite em memória (o CT não tem `pdo_sqlite`), logo cada corrida da suite **apaga todos os dados da BD MySQL de dev**, incluindo utilizadores e casos reais registados manualmente. Ordem que funciona: correr a suite primeiro, só depois criar o utilizador de teste e registar casos reais — nunca ao contrário, e não voltar a correr a suite depois sem os recriar.
- **Worker preso a código antigo**: o `queue:work` carrega as classes uma vez no arranque; depois de um `rsync` que muda `app/Jobs/LerCaso.php` (ou qualquer dependência), é preciso `pkill -f "queue:work"` e arrancar de novo — caso contrário continua a correr a versão anterior em memória.
- **`LocalizadorPasta` sem binding no container**: o comando `fichas:ler` construía-o à mão (`app(LocalizadorPasta::class, ['baseNas' => ...])`), mas o job `LerCaso`, tal como o Laravel o resolve pela fila (injeção automática por tipo), não tinha forma de saber o valor de `$baseNas` — falha só visível a correr pela fila a sério (`Unresolvable dependency`), nunca pelos testes síncronos da Fase 2. Corrigido com um binding em `AppServiceProvider::register()`. Teste de regressão em `tests/Feature/Fichas/LocalizadorPastaBindingTest.php`.
- **Comandos remotos com `&` a prender a sessão SSH**: mesmo com `nohup ... > log 2>&1 < /dev/null &`, o `ssh` por vezes só devolve a shell depois do processo tocar em algo — usar sempre um segundo comando SSH separado para confirmar `ps aux | grep queue:work`, sem depender do primeiro terminar depressa.
- **`--data-urlencode nome@ficheiro` inclui o `\n` final do ficheiro**: gravar a password com `printf '%s'` (sem `echo`) antes de a usar num `curl --data-urlencode password@ficheiro`, senão o login falha silenciosamente (302 de volta a `/login`, sem 419, fácil de confundir com CSRF).

## 10. Armadilhas da Fase 4

- **Worker systemd também fica preso a código antigo** — a mesma armadilha do `queue:work` manual (secção 9), mas agora exige `systemctl restart assistente-queue` como root via `pct exec 117` em vez de `pkill`/relançar; fácil de esquecer porque o serviço "parece" sempre ativo (`systemctl status` mostra "active (running)" mesmo a correr a versão errada).
- **`chmod -R o+rwX storage bootstrap/cache` continua obrigatório a cada rsync**, systemd ou não — o worker corre como `www-data` de qualquer forma; sem isto, `SegundaLeituraQwen`/blade novos voltam a dar 500/exceção muda por falta de escrita em `storage/framework/views`.
- **`Array to string conversion` em `SegundaLeituraQwen::camposMapeados`** — o Qwen pode devolver um array em vez de string para um campo (documento ambíguo); `(string) $array` é um `ErrorException` sob o error handler estrito do Laravel, que fazia o job falhar por inteiro (3 tentativas → `erro_tecnico`). Só apareceu com um caso sintético real no teste ponta a ponta da Fase 4 — nenhum dos 40 processos reais da validação da Fase 2 tinha isto. Corrigido: valores não-escalares são ignorados (log de aviso), nunca rebentam o job.
- **`wkhtmltopdf` não existe no apt do Debian 13 (trixie)**: instalado a partir do `.deb` estático oficial (secção 11) — sem isto `ficha_pdf`/`email` falham com "wkhtmltopdf indisponivel neste servidor" (exit 2, mensagem clara, nunca um erro mudo).
- **`FICHAS_PROCESSOS_PATH_TESTE` fica esquecido no `.env`** depois de uma sessão de teste sintético: como é só um segundo caminho tentado quando o MAI não existe no principal, não tem efeito nenhum sobre casos reais — mas convém removê-lo/comentá-lo no fim de cada sessão, e sempre limpar a pasta de fixtures sintéticas (nunca dados reais lá).
- **As chaves SSH do wrapper (`WRAPPER_CHAVE_CONSULTA`/`WRAPPER_CHAVE_APPLY`) têm de ser DONAS de `www-data`, não só legíveis por grupo**: `chown www-data:www-data` + `chmod 600`. Uma chave `claude:www-data` com `640` parece funcionar (e até funciona quando é o próprio `www-data` a usá-la) mas o cliente OpenSSH recusa-a com "Permissions ... are too open" quando é o `claude` a correr o `ssh -i` (ex. a suite Pest, que corre como `claude`) — a verificação estrita do OpenSSH depende de quem está a correr o comando, não só do modo do ficheiro. `claude` deixa de conseguir ler o conteúdo da chave depois disto (dono passa a ser `www-data`) — normal, só a app precisa de acesso; testar manualmente como `claude` exige uma cópia própria da chave.
- **`WRAPPER_CHAVE_APPLY`/`WRAPPER_APPLY_ATIVO` não bastam só na documentação** — têm de estar mesmo no `.env` do CT 117 (`php artisan config:clear` depois), e o worker systemd precisa de `systemctl restart` para os apanhar (lê o `.env` só no arranque). Sem isto, "Aplicar em DEV" fica sempre com o erro "está desativado" mesmo com tudo o resto certo.
- **`php artisan optimize:clear` NÃO chega para um controlador/classe alterada pegar** — o pool PHP-FPM (`www-data`) mantém o seu próprio OPcache com `opcache.validate_timestamps=On`/`revalidate_freq=2`, mas na prática (encontrado no teste E2E de 2026-09-22, cenário C) uma alteração a um ficheiro PHP já carregado continuou a servir o bytecode antigo minutos depois do rsync, enquanto `php artisan tinker` (CLI, sem opcache partilhado com o FPM) já via o código novo — sintoma traiçoeiro porque parece "só às vezes falha". Depois de qualquer rsync que mude `app/**/*.php` (não só Blade/lang, que recompilam sozinhos), reiniciar o pool: `ssh root@192.168.69.2 "pct exec 117 -- systemctl restart php8.4-fpm"` (sem sudo no `claude`, tem de ser por aqui). Confirmar comparando um `tinker --execute=...` contra o comportamento HTTP real se houver dúvida.

## 11. Instalar o wkhtmltopdf (dependência do `ficha_pdf`/`email`)

**No Sabichão de dev (CT 100, não no CT assistente):**

```bash
# a partir do host Proxmox (root@192.168.69.2), o CT 100 tem acesso à internet:
pct exec 100 -- bash -c '
  cd /tmp
  curl -sL -o wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb
  apt-get update -qq
  dpkg -i wkhtmltox.deb || true   # falha por dependências em falta, corrigido a seguir
  apt-get install -y xfonts-75dpi xfonts-base
  dpkg -i wkhtmltox.deb           # agora conclui a configuração
  rm -f wkhtmltox.deb
'
```

Instala em `/usr/local/bin/wkhtmltopdf` (o 1º candidato de `wkhtml_bin()` em `funcoes/pdf_wkhtml.php` — o wrapper encontra-o sem configuração extra). Confirmar: `pct exec 100 -- sudo -u www-data /usr/local/bin/wkhtmltopdf --version`.

O pacote `bookworm` (Debian 12) funciona no CT 100, que é Debian 13 (trixie) — é um binário Qt estaticamente ligado, sem dependência da versão exata da distro; não há pacote `trixie` publicado ainda.

## 12. Rollback

- Código: `git log` no CT (repo espelhado por rsync, não é um clone) — manter sempre uma cópia da release anterior antes de sincronizar uma mudança grande (fora do âmbito desta fase, sem automação ainda).
- BD: `php artisan migrate:rollback` reverte as migrações desta fase (users: username/ativo; ver lista em `database/migrations/2026_09_22_*`).

## 11. Permissões de storage (armadilha real de 23/09/2026)

O php-fpm e o worker correm como `www-data`. A sincronização a partir do Mac (`rsync -a`) copia também as permissões das pastas e deixava `storage/framework/{views,cache,sessions}` e `bootstrap/cache` como `claude:claude 775`, sem escrita para o `www-data`. Resultado: `tempnam(): file created in the system's temporary directory` na primeira página que precisou de ser compilada (Novo caso).

- Sincronizar SEMPRE com `--exclude storage --exclude bootstrap/cache`.
- Permissões corretas (como root no CT): `chown -R www-data:www-data storage bootstrap/cache && chmod -R ug+rwX storage bootstrap/cache && find storage bootstrap/cache -type d -exec chmod g+s {} +` (o utilizador `claude` está no grupo `www-data`).
- Depois de alterar PHP: reiniciar `php8.4-fpm` (ver §10).

## 12. Permissões de ficheiros vindas do Mac (armadilha real de 23/09/2026)

Um `config/services.php` com modo 700 no Mac foi copiado assim pelo `rsync -a` e o `www-data` deixou de o ler (erro "Permission denied" ao arrancar o Laravel, worker em ciclo de reinício). A sincronização passa a forçar permissões legíveis com `--chmod=Dug=rwx,Do=rx,Fug=rw,Fo=r`.

## 13. Módulo Contratos (Fase 3, 23/09/2026)

**Pacotes novos no CT 117 (dev)**: `qpdf`, `libreoffice-writer-nogui`
(`apt-get install -y qpdf libreoffice-writer-nogui`, Debian 13/trixie — já
instalados nesta entrega). Confirmar com `soffice --version` e `qpdf
--version`.

**Pastas novas no CT 117**: `/srv/assistente-dev/modelos/` (cópia dos 4
`.docx` de `/Volumes/Assistente/Modelos contratos`, dono `www-data`) e
`/srv/assistente-dev/contratos-feitos/` (pasta de teste, dono `www-data`).
Nunca a partilha `Assistente` real — essa fica para quando o utilizador criar
a conta de escrita dedicada no DSM (ver "O que fica para o utilizador"
abaixo).

**`.env` novo (CT 117, não sincronizado por rsync — acrescentar à mão ou
copiar de `.env.example`)**:
```
CONTRATOS_MODELOS_PATH=/srv/assistente-dev/modelos
CONTRATOS_SAIDA_PATH=/srv/assistente-dev/contratos-feitos
CONTRATOS_SOFFICE_BIN=soffice
CONTRATOS_QPDF_BIN=qpdf
```

**Migrações novas**: `contratos_casos`, `contratos_eventos`,
`contratos_versoes` — `php artisan migrate --force` no CT depois de
sincronizar.

**Wrapper (CT 100, DEV — não CT 107)**: 3 ações novas
(`consulta.fila_contratos`, `consulta.dados_contrato`,
`contrato.marcar_redigido`) na mesma release do wrapper (ver
`wrapper-ct107/README.md` para o procedimento de reinstalação em DEV com
`ALVO=dev`). Instalado e testado nesta entrega (release `20260923181556`).
**Nada foi tocado em prod (CT 107)** — a próxima instalação lá segue o
mesmo procedimento do README, com `ALVO=prod` e sem a chave de apply
(decisão já documentada: chave de apply só na Fase 5).

**Rollback**: `php artisan migrate:rollback` reverte as 3 migrações novas
(não têm dados de produção — `contratos_casos`/`eventos`/`versoes` são só do
CT assistente, nunca do Sabichão). O wrapper reverte trocando o symlink
`/opt/assistente/bin/current` para a release anterior
(`/opt/assistente/releases/<timestamp anterior>`), sem reinstalar nada.

**O que fica para o utilizador**:
- Criar no DSM (Synology, `10.0.30.79`) uma conta de escrita dedicada para a
  partilha `Assistente` (`Contratos Feitos/<zona>/`), para trocar
  `CONTRATOS_SAIDA_PATH` da pasta de teste para a pasta real quando a app
  for para produção. Sem esta conta, o módulo continua a publicar só na
  pasta de teste.
- Confirmar quando o Codex tiver quota de novo, para fechar a revisão
  pendente (`docs/CONTRATOS-FASE3-RESULTADOS.md`, ponto 5, achados 9-10).
- Autorizar (ou não) instalar `qpdf`/`libreoffice-writer-nogui` e a release
  nova do wrapper no CT 107 — nada disto foi feito nesta entrega.

## 14. Fontes Aptos/Aptos Display (paginação verificada, 24/09/2026)

**Instaladas no CT 117 (dev)** em `/usr/local/share/fonts/aptos/` (ficheiros
`.ttf`, `fc-cache -f` a seguir) — sem elas o LibreOffice substitui a fonte do
modelo (Aptos, a fonte por omissão do Office 2024/365) por outra com métricas
diferentes e pagina de forma bem diferente do Word (o contrato do processo 15
passou de 17 para 14 páginas só com esta instalação, antes de qualquer código
novo). Confirmar com `fc-list | grep -i aptos` (grupo `www-data` tem de
conseguir ler, é o `soffice --headless` da fila que as usa).

**Licença**: a Aptos vem com o Microsoft Office (2024/365), não é uma fonte
livre — **decisão de instalar em prod (CT 107) por fazer pelo utilizador**
antes de replicar; nesta entrega ficou só em dev (CT 117), com o `.env` já a
apontar para lá (`services.contratos.pdftotext_bin` não precisa de fonte,
só `soffice`).

**Módulo `PaginacaoVerificada`** (`App\Services\Contratos\PaginacaoVerificada`,
ver `docs/CONTRATOS-FASE3-RESULTADOS.md` secção "Paginação verificada com o
LibreOffice") usa também `pdftotext` (poppler-utils, já instalado com
`libreoffice-writer-nogui`/`poppler-utils` — confirmar com `pdftotext -v`).
`.env` novo:
```
CONTRATOS_PDFTOTEXT_BIN=pdftotext
```

## 15. Word real no mini como conversor principal (24/09/2026)

**`.env` novo (CT 117)**:
```
CONTRATOS_CONVERSOR=word
```
`word` (omissão) — o `Gerador` tenta primeiro `LeitorClient::paginarPdfContrato`
(`POST /v1/contrato/paginar-pdf` no leitor-mini, `https://10.0.30.231:8090`,
`docs/06-CONTRATO-LEITOR-MINI.md` §8) e só cai para o caminho antigo
(LibreOffice + `PaginacaoVerificada`) se `/v1/saude` não confirmar
`"word":"disponivel"` ou se o próprio pedido falhar (503, timeout, erro de
rede, resposta inválida). `libreoffice` desliga a tentativa do Word por
completo — útil para isolar um problema do mini sem mexer no resto.

**Requisitos no mac mini (10.0.30.231) para o Word responder**:
- **Microsoft Word aberto e com sessão iniciada** no mini — o serviço tenta
  abri-lo (`open -a "Microsoft Word"`) se não estiver a responder e espera
  até 30s antes de devolver `503`.
- **Acesso Total ao Disco** concedido ao binário Python real do venv do
  leitor (`readlink -f ~/leitor/.venv/bin/python3.13` — não o symlink),
  em Definições > Privacidade e Segurança > Acesso Total ao Disco — sem
  isto, o serviço corrido pelo `launchd` (diferente de uma sessão SSH
  interativa) falha com `PermissionError` a escrever dentro do contentor do
  Word. Ver `leitor-mini/README.md` secção "PASSO MANUAL — Acesso Total ao
  Disco (TCC)" para o procedimento completo (muda de caminho sempre que o
  Homebrew atualiza `python@3.13` — reconferir depois de qualquer
  `brew upgrade`).
- **Autorização de Automação** (AppleScript) do Word para o processo do
  leitor — concedida uma vez, confirmada por `/v1/saude` a devolver
  `"word":"disponivel"`.
- Confirmado em produção real do mini a 24/09/2026 (`GET /v1/saude` pelo
  serviço `launchd` real, porta 8090): `{"word":"disponivel","word_versao":"16.113.2"}`
  — os dois passos acima já estavam concedidos nesta entrega.

**Timeouts alinhados** (o Word demora ~95s num modelo de 14 páginas, medido a
24/09/2026): `LeitorClient::paginarPdfContrato` usa 200s (acima do limite
documentado de 180s do lado do mini, margem para a rede/parsing);
`RedigirContrato::$timeout` subiu de 180s para 300s; o unit systemd
(`deploy/assistente-queue.service`) passa a levar `--timeout=300` no
`queue:work` — sem isto o supervisor do worker mata o processo antes do
timeout do próprio job disparar. Reinstalar o unit depois de mudar (como
root, via `pct exec 117`):
```bash
# a partir do host Proxmox (root@192.168.69.2):
pct push 117 deploy/assistente-queue.service /etc/systemd/system/assistente-queue.service
pct exec 117 -- systemctl daemon-reload
pct exec 117 -- systemctl restart assistente-queue
```

**`.env` também (CT 117)**: `DB_QUEUE_RETRY_AFTER=360`. O omissão do Laravel
é 90s: com o Word a demorar 65-95s, a fila podia dar o job como perdido e
relançá-lo a meio. Tem de ficar acima dos 300s do job. Depois:
`runuser -u www-data -- php artisan config:clear` e reiniciar `assistente-queue`.

**qpdf e o PDF do Word**: o PDF do Word para Mac traz objetos com "offset 0";
o `qpdf --encrypt` repara-os e sai com código 3 ("succeeded with warnings").
O `Protetor` aceita 0 e 3 e confirma o resultado com `--show-encryption`.
Validado no processo 15 (24/09/2026): Word, 15 páginas, 65s, PDF protegido.

**Testes**: `services.contratos.conversor` fica forçado a `libreoffice` em
`phpunit.xml` (env `CONTRATOS_CONVERSOR`) — sem isto, qualquer teste de
Contratos que não mocke o `LeitorClient` dispararia uma chamada HTTP real ao
mini só para `Gerador::deveTentarWord` decidir. Os testes do caminho Word
(`tests/Feature/Contratos/GeradorConversorWordTest.php`,
`tests/Feature/Leitor/LeitorClientContratoTest.php`) mudam a config caso a
caso, sempre com `Http::fake`, nunca contra o mini real.

**Armadilha real de 24/09/2026 — permissões do perfil do soffice**: o
perfil dedicado do LibreOffice usado por `Conversor`
(`storage/app/private/contratos/.soffice-home`) tem de ser gravável tanto
pelo `www-data` da fila/PHP-FPM como por quem testar a conversão à mão por
SSH (utilizador `claude`, membro do grupo `www-data`) — encontrado a testar
`PaginacaoVerificada` a sério: o diretório tinha ficado `drwx--S---`
(sem acesso de grupo), o que fazia o `soffice --headless` chamado como
`claude` esgotar o timeout de 90s (permissão negada sem erro claro, não uma
falha rápida). Corrigido com `chmod -R 2770` nesse diretório (setgid,
leitura/escrita para o grupo `www-data`); confirmar sempre que uma pasta nova
do módulo Contratos precisa do mesmo tratamento (`chmod` recursivo com
setgid) para não repetir o mesmo problema. **Nota**: o `soffice` cria
subpastas de cache novas (`.config/libreoffice/4/...`) sempre a `0700`, sem
respeitar o setgid do pai — a fila (`www-data`) volta a "fechar" o perfil a
cada geração real. Isto nunca é um problema em produção (só `www-data` toca
no perfil, sempre o mesmo dono), mas em dev, se `php artisan test` correr
pela SSH como utilizador `claude` a seguir a uma geração real da fila, o
teste de integração do LibreOffice (`PaginacaoVerificadaTest`) pode falhar
por permissões — repetir o `chmod -R 2770` antes de testar, ou testar antes
de gerar algo real pela app.

## 16. Campos [FALTA] editáveis + realce de revisão + bloqueio de publicação (24/09/2026)

**Migração nova**: `2026_09_24_000001_add_valores_manuais_to_contratos_casos_table.php`
— coluna `valores_manuais` (JSON nullable) em `contratos_casos`. `php artisan
migrate --force` como sempre (passo 3 deste documento).

**`.env` — `APP_TIMEZONE`**: horas da app passam a `Europe/Lisbon` por
omissão (`config/app.php`, `env('APP_TIMEZONE', 'Europe/Lisbon')`) — não
precisa de nada no `.env` do CT para ficar correto; só definir
`APP_TIMEZONE` se algum dia for preciso um fuso diferente. Adicionado a
`.env.example` como referência. A BD MySQL guarda o que o Laravel lhe dá,
sem conversão própria — nada mais a fazer.

**Campos [FALTA] editáveis** (`ContratosController::preencher`, rota `POST
contratos/{caso}/preencher`): o utilizador escreve um valor para uma chave
que o motor não conseguiu preencher a partir do Sabichão (cartão "Campos por
preencher" na página do caso); fica gravado em `contratos_casos.valores_manuais`
(nunca escrito no Sabichão) e `Gerador::gerar` sobrepõe esses valores em cima
dos campos calculados na próxima regeneração. Evento `valores_manuais`
regista só as chaves alteradas, nunca os valores (podem ser NIF/morada).

**Realce das alterações (pré-visualização de revisão)**: `Gerador::gerar`
passa a gerar sempre o `.docx` COM realce por origem do campo
(`DocxEngine::preencherDocx($revisao = ['manuais' => [...]])`): verde =
direto do Sabichão, azul (ciano) = calculado, cinzento = escrito pelo
utilizador, amarelo = `[FALTA]` (like sempre). Esse `.docx` é o que é
paginado (Word ou LibreOffice) e convertido num "PDF de revisão" (nunca
protegido, nunca publicado — só mostrado na página do caso). As cores são
retiradas do `.docx` antes deste ser gravado/publicado
(`DocxEngine::retirarRealceRevisao`) — o ficheiro final e o publicado nunca
levam cores. Caminho do PDF de revisão fica em
`contratos_casos.manifesto->meta->pdf_revisao` (sem coluna dedicada — decisão
desta entrega, documentada aqui); rota `GET contratos/{caso}/ficheiro/revisao`
serve-o com a mesma autorização que `pdf`/`docx`.

No caminho Word (mini), o pedido a `/v1/contrato/paginar-pdf` passa a levar o
campo de formulário `revisao=1`; a resposta esperada do mini ganha
`pdf_revisao_base64` (o `docx_base64`/`pdf_base64` continuam sempre sem
cores). Um mini antigo sem este contrato simplesmente não devolve
`pdf_revisao_base64` — a app segue sem PDF de revisão, sem bloquear a
geração (o trabalho no leitor-mini está a decorrer em paralelo, fora desta
entrega).

**Publicação bloqueada quando incompleta**: `Publicador::publicar` (defesa em
profundidade) e `ContratosController::publicar`/`publicarLote` passam a
recusar publicar um caso que não esteja em `gerado_completo`, com `n_falta >
0`, ou sem PDF em disco — antes disto `gerado_com_falta` também podia ser
publicado (o utilizador completava o `[FALTA]` no Word depois, fora da app);
deixou de fazer sentido agora que a app tem uma forma de preencher esses
campos. O botão "Aprovar e publicar" na página do caso fica desativado com o
motivo (`ContratoCaso::motivoBloqueioPublicacao()`); a lista "prontos para
publicar" do índice só mostra `gerado_completo`; `publicarLote` ignora os
casos incompletos e diz quantos saltou.

**Testado** (CT 117, `./vendor/bin/pest`): 199 testes em `tests/Unit` +
`tests/Feature/Contratos` + `tests/Feature/Leitor` (0 falhas); 20 testes em
`tests/Feature/Auth` + `tests/Feature/Fiabilidade` + `tests/Feature/FilaProcessamento`
+ `tests/Feature/*.php` (0 falhas); ver WORKLOG.md para o resto da suite
(`tests/Feature/Fichas`, exceto os dois testes de integração com o Qwen
real). Rotas novas confirmadas com `php artisan route:list | grep contratos`.
Nada disto regera casos reais — o Word do mini está a ser alterado em
paralelo (fora desta entrega).

**Mini (leitor) — tem de subir ANTES ou junto com a app**: copiar
`leitor-mini/app.py` para `~/leitor/app.py` no mini (cópia de segurança
primeiro) e `launchctl kickstart -k gui/$(id -u)/pt.segunor.leitor`. Sem
isto a app funciona na mesma, só sem PDF de revisão no caminho Word. Em dev
já está instalado (24/09/2026; backup em `~/leitor/app.py.bak-20260924`).

**Com [FALTA]** (acrescento de 24/09/2026, depois da revisão): gera-se na
mesma o PDF de revisão, pelo LibreOffice (sem Word, poucos segundos), e o
`.docx` descarregável fica sempre sem as cores de revisão (só o amarelo).

**Validado a sério em dev (24/09/2026)**: processo 15 pelo Word do mini:
15 páginas, 68 s, PDF de revisão com as cores (não protegido) e PDF final
sem cores (protegido, 15 páginas). A página `/contratos/5`, a rota
`ficheiro/revisao` e a rota `ficheiro/pdf` respondem 200 com um utilizador
autenticado. Os testes de integração com o Qwen real (FichasLerCommandTest e
RetryExtracaoTest) passam, 3/3, em 142 s.

**Revisão do Codex (24/09/2026, commits b21a90a e b09b062)**: o `leitor-mini/app.py`
mudou outra vez (trinco do Word). Em prod, instalar a versão do repositório no
mini como descrito acima. O `Publicador` cria `storage/app/contratos-publicar.lock`
(dono `www-data`) na primeira publicação; nada a configurar.

## 17. Ambiente de escrita (dev/prod)

Decisão do utilizador (24/09/2026): vai existir um segundo CT com a mesma
app, configurado para o Sabichão e as pastas de **produção**. O CT de dev
(117, este documento) continua a escrever só em dev. **Cada instância
escreve APENAS no ambiente configurado no seu próprio `.env`** — nunca as
duas a partir do mesmo CT.

`WRAPPER_APPLY_ATIVO` passa a aceitar três valores:

- `off` (omissão) — apply/ficha_pdf/email/contrato.marcar_redigido
  desligados; a app só lê (dry-run continua sempre disponível, é só
  consulta).
- `dev` — liga essas ações NESTE CT, cada uma confirmada contra o wrapper
  remoto (`consulta.alvo`) antes de escrever — é o valor do CT 117.
- `prod` — o mesmo, mas para o CT de produção. Além da confirmação remota,
  o apply de fichas exige uma segunda confirmação explícita na UI (caixa
  "Confirmo que revi a ficha contra os documentos", validada também no
  servidor, mais um `confirm()` em JavaScript com o MAI e o nome).

`Aplicador::ambiente()` devolve `'dev'`/`'prod'`/`null` a partir desta
config; `Aplicador::confirmarAlvoRemoto()` é chamado antes de qualquer apply,
reconciliação, PDF, email ou `contrato.marcar_redigido` e recusa a ação se o
wrapper remoto não confirmar exatamente o mesmo ambiente — nunca compara só
contra `'dev'` fixo como nas fases anteriores.

**No `.env` do CT de produção (a criar), tudo isto muda em relação ao de
dev**:

```
WRAPPER_HOST=10.0.30.253
WRAPPER_USER=<a preencher na instalação>
WRAPPER_CHAVE_CONSULTA=<a preencher na instalação — chave própria do CT de prod>
WRAPPER_CHAVE_APPLY=<a preencher na instalação — chave própria do CT de prod, nunca copiada do CT de dev>
WRAPPER_APPLY_ATIVO=prod
CONTRATOS_MODELOS_PATH=<a preencher na instalação — pasta real da partilha Assistente em produção>
CONTRATOS_SAIDA_PATH=<a preencher na instalação — "Contratos Feitos/<zona>" real, nunca a pasta de teste do CT 117>
FICHAS_PROCESSOS_PATH=<a preencher na instalação — NAS de produção, nunca /mnt/processos de dev>
```

O wrapper do lado do Sabichão tem de estar instalado no **CT 107** (nunca no
CT 100, que é só o wrapper de dev) com `ALVO=prod` — ver
`wrapper-ct107/README.md`. Nada disto foi instalado ainda; o CT de produção
da app em si também está por criar — este documento só regista o que muda
quando existir, para não ser preciso reconstruir a lista do zero.

O CT 117 (dev) mantém `WRAPPER_APPLY_ATIVO=dev` e continua a mostrar "Aplicar
em DEV" — confirmar isto depois de qualquer alteração a esta área (ver
WORKLOG.md).

## 18. Portal dos dados ligado a LerCaso + correções de Fichas (24/09/2026)

Tudo feito e testado só em dev (CT 117); nada disto foi instalado em
produção (CT 121). Para levar a prod quando o utilizador pedir:

1. **Migração**: `2026_09_24_000003_add_portal_status_to_fichas_casos_table`
   (fichas_casos ganha `portal_status`/`portal_consultado_em`) — `php artisan
   migrate --force` como sempre, sem passo manual.
2. **Sem novas variáveis de ambiente** — `PortalDadosAplicador` usa
   `Cruzamentos::portalLookup()`, que já lê `services.wrapper_ct107.*` (as
   mesmas credenciais do resto do módulo). A ação `consulta.portal_lookup`
   do wrapper tem de já estar instalada no wrapper de prod (CT 107) —
   confirmar com `consulta.alvo` + um dry-run de `consulta.portal_lookup`
   antes de confiar no botão "Consultar portal outra vez" em prod.
3. **Sem dados a migrar**: casos já lidos em prod antes desta alteração
   ficam com `portal_status=null` (checklist "Portal dados" mostra
   "pendente") até serem relidos ou até o utilizador clicar "Consultar
   portal outra vez" na página do caso.
4. **Sem passo de rollback especial** — a migração tem `down()`; reverter
   o código e correr `migrate:rollback` chega.
5. Todas as outras alterações desta entrada (Classificador, concelho/
   distrito do RC, data de início = admissão, máscara de data, "Confirmar
   em branco") são só código de aplicação — seguem o deploy normal da app
   (sync + `view:clear` + restart php-fpm/queue), sem passo extra.
6. **Ação nova do wrapper `consulta.portal_por_nome` — NÃO instalada.**
   Só existe em `wrapper-ct107/wrapper.php` neste repo (nem no wrapper do
   CT 100/dev nem no de prod). Enquanto não for instalada, o fallback por
   nome (`PortalDadosAplicador`) simplesmente não encontra nada por essa
   via e o caso segue como `not_found` normal — não é um bloqueio, mas o
   fallback só funciona de facto depois de instalar esta ação onde o
   utilizador decidir (dev e/ou prod, ver `wrapper-ct107/README.md` para o
   processo de release versionada).

Validação de campo pendente (fora do âmbito desta sessão, âmbito só-dev):
correr `php artisan fichas:validar` contra os 40 processos reais de
`docs/validacao-fase2.md` exigiria `WRAPPER_HOST=10.0.30.253` (CT 107,
produção) e a NAS de produção montada — não foi corrido nesta sessão porque
o âmbito era estritamente dev (nunca tocar no CT 107/121/Proxmox prod). Pode
ser repetido a partir do CT de produção da app quando existir, ou por quem
tiver acesso de leitura autorizado ao CT 107 a partir de dev.

## 2026-09-24 — Porte de ocr_docs.php (camada de classificação)

Ver `app/docs/PORTE-OCR-DOCS.md` para o mapa completo das 73 funções e o
estado do porte (o que passou nesta entrega e o que fica pendente).

1. **Só código de aplicação** (`app/Support/OcrDocs.php` +
   `app/Services/Fichas/Classificador.php`) — sem migração, sem alteração
   de `.env`, sem ação nova do wrapper. Segue o deploy normal (sync + este
   ficheiro §2/§3 + `view:clear` + restart php-fpm/queue).
2. **Sem dados a migrar** — a mudança é só na classificação de documentos
   feita no momento da leitura; não há coluna nem valor histórico afetado.
3. **Risco de regressão**: `Classificador::classificar` passou a chamar
   `OcrDocs::classificarPorConteudo` (porte literal do Sabichão) como
   primeira leitura, com as regras próprias da app como fallback — os
   testes de paridade (`tests/Unit/Fichas/OcrDocsParidadeTest.php`) e os
   3 grupos de teste do projeto (Unit+Contratos+Leitor,
   Auth+Fiabilidade+FilaProcessamento+Feature soltos, Fichas/*) correram
   todos verdes no CT 117 em 24/09/2026 antes desta entrada. Ainda assim,
   como o classificador entra em TODOS os documentos lidos, vale a pena
   observar os primeiros casos reais em dev com atenção redobrada antes de
   considerar isto estável — sobretudo os cartões profissionais
   (vig/spr/ard/are/coordenador/diretor, que a app mapeia para
   `cartao_psp`).
4. **Sem passo de rollback especial** — reverter o commit chega (não há
   migração nem estado persistido pela mudança).
5. **NÃO leva a prod** — só dev, como o resto do módulo; não foi tocado o
   CT 107 nem o Sabichão de produção.
6. **Pendente para a próxima entrega** (ver PORTE-OCR-DOCS.md, "Estado
   desta entrega"): `ocr_parse_cc_verso` (NISS/filiação vazios),
   `ocr_parse_cc_frente` + integração de `OcrDocs::recuperarAcentos` nos
   extratores (nomes sem acentos), `ocr_parse_cartao_especialidade`,
   `ocr_parse_morada`/`ocr_parse_iban`/`ocr_parse_passaporte`/
   `ocr_parse_habilitacoes`, e a comparação de `ocr_merge_campos` com a
   política de confiança atual da app.

## 2026-09-24 (2ª entrega) — Porte de ocr_docs.php: verso e frente do CC

Continuação da entrada anterior — ver `app/docs/PORTE-OCR-DOCS.md` "Estado
desta entrega" para o detalhe completo.

1. **Só código de aplicação** (`app/Support/OcrDocs.php` +
   `app/Services/Fichas/Extratores/CartaoCidadao.php`) — sem migração, sem
   alteração de `.env`, sem ação nova do wrapper. Segue o deploy normal
   (sync + §2/§3 + `view:clear` + restart php-fpm/queue).
2. **Portado e com teste de paridade** (fixtures geradas no CT 100,
   acrescentadas a `tests/Unit/Fichas/fixtures/ocr_docs_paridade.json`,
   sha256 do `funcoes/ocr_docs.php` de origem inalterado —
   `e15bfb55...4bab8d9db`, o mesmo da 1ª entrega): toda a família do verso
   do CC (`ocr_niss_bloco_fiscal`, `ocr_niss_por_ancora`,
   `ocr_niss_candidatos`, `ocr_niss_prova_documento`,
   `ocr_niss_consenso_documento`, `ocr_filiacao_qualidade`,
   `ocr_filiacao_melhor`, `ocr_mrz_datas`, `ocr_mrz_nome`,
   `ocr_data_apos_label`, `ocr_ncc_de_mrz`, `ocr_mrz_ncc_candidatos`,
   `ocr_ncc_consenso`, `ocr_ncc_tr_de_mrz`, `ocr_morada_apos_nome` e
   auxiliares de morada, `ocr_limpar_parte_nome`, `ocr_parse_cc_verso`) e a
   família da frente (`ocr_parse_cc_frente`, `ocr_pontuar_nome_fonte`,
   `ocr_escolher_nome`, `ocr_tr_num_plausivel`, `ocr_num_titulo_residencia`),
   como métodos estáticos de `App\Support\OcrDocs`.
3. **Integrado**: `CartaoCidadao::extrairVerso` passa a chamar
   `OcrDocs::parseCcVerso` como 1ª leitura para `nif` (campo novo), `niss`,
   `ncc` (campo novo, pela MRZ) e `filho_de` (com `tituloComParticulas` por
   cima, para bater com a mesma regra de maiúsculas/partículas da 2ª
   leitura); a 2ª leitura — o método próprio da app (âncora no NIF do
   utilizador para o NISS, `filhoDeDoVerso` para a filiação, que também
   reconhece os separadores `•`/`·`/`●` que o porte não cobre) — mantém-se
   inalterada como *fallback*, usada só quando o porte não encontra nada. A
   política de confiança (checksums, `04-POLITICA-CONFIANCA.md`) não mudou:
   um campo só sai `alta` quando o porte o marca como fiável (`_fiaveis`).
   `CartaoCidadao::extrairFrente` **não foi alterado** nesta entrega — ver
   "Pendente" abaixo.
4. **Risco de regressão**: os 25 testes de `CartaoCidadaoTest.php` e os 15
   de `OcrDocsParidadeTest.php` correram verdes no CT 117, tal como os 3
   grupos completos do projeto (Unit+Contratos+Leitor = 242, Auth+
   Fiabilidade+FilaProcessamento+Feature soltos = 20, Fichas/* exceto os 2
   testes de integração com o Qwen real = 163) — 425 testes, 0 falhas,
   24/09/2026. O verso do CC entra em todos os processos com esse
   documento; observar os primeiros casos reais em dev.
5. **Sem passo de rollback especial** — reverter o commit chega.
6. **NÃO leva a prod** — só dev, como o resto do módulo.
7. **Pendente para a próxima entrega** (ver PORTE-OCR-DOCS.md): integrar
   `OcrDocs::parseCcFrente` em `CartaoCidadao::extrairFrente` (a extração
   atual da frente não foi tocada — continua só com a lógica própria da
   app, já validada com 40 processos reais); ligar `OcrDocs::recuperarAcentos`
   ao nome/nome_proprio/apelido do CC usando o texto do RC/portal como fonte
   de acentos — isto exige localizar, na camada que combina as várias
   leituras de um caso (fora do âmbito de `Extratores\CartaoCidadao`, que só
   vê o texto do próprio CC), onde entra essa segunda fonte de texto;
   `ocr_parse_cartao_especialidade`; `ocr_merge_campos` vs. a política de
   confiança atual.

## 2026-09-24 (3ª entrega) — Porte de ocr_docs.php: frente do CC + acentos do nome

Continuação das duas entradas anteriores — ver `app/docs/PORTE-OCR-DOCS.md`
"Estado da 3ª entrega" para o detalhe completo.

1. **Só código de aplicação** (`app/Services/Fichas/Extratores/CartaoCidadao.php`
   + `app/Jobs/LerCaso.php`) — sem migração, sem alteração de `.env`, sem
   ação nova do wrapper. Segue o deploy normal (sync + §2/§3 + `view:clear`
   + restart php-fpm/queue).
2. **Integrado**: `CartaoCidadao::extrairFrente` passa a chamar
   `OcrDocs::parseCcFrente` como 1ª leitura, mas com um cuidado extra
   (achado ao testar o porte contra os fixtures reais de
   `CartaoCidadaoTest.php`): a leitura própria continua a DECIDIR
   ncc/nascimento/validade_cc sempre que encontra alguma coisa (é a única
   com o Luhn do ncc e tem as correções dos processos reais
   773/788/850/854, `docs/validacao-fase2.md`) — o porte só preenche
   quando a própria não encontra nada. O nome nunca é substituído pelo
   porte (só confirmado, sobe a confiança quando as letras batem
   ignorando acentos) — o teste com o fixture do CC digital (`app.gov.pt`)
   mostrou o porte a ler "M M M" e a marcar isso como fiável, o que teria
   corrompido o nome digital se fosse tratado como substituição.
3. **Acentos do nome**: `LerCaso::confirmarNomePorSegundaFonte` reescrito
   para usar `OcrDocs::escolherNome`/`pontuarNomeFonte` sobre os candidatos
   já recolhidos em `$nomesPorOrigem` (cc_frente/cc_digital/rc) — nunca
   troca as letras, só melhora a grafia quando uma fonte acentuada (o RC,
   PDF com texto limpo, é a fonte típica) bate com o nome já lido do CC
   físico (maiúsculas sem acentos); `LerCaso::resplitarNomeCompleto`
   (novo) reparte a mesma melhoria por `apelido`/`nome_proprio`.
   Confirmado por >= 2 fontes -> confiança sobe a 'alta'; com 1 fonte só, a
   grafia melhora mas a confiança/motivo ficam como estavam. Confirmado no
   wrapper (`wrapper-ct107/wrapper.php`) que o `mapeado` do portal nunca
   traz `nome` — `PortalDadosAplicador` não foi alterado (nada a corrigir;
   se um dia trouxer, o loop genérico já respeitaria "nunca sobrepor uma
   edição do utilizador").
4. **Risco de regressão**: os 25 testes de `CartaoCidadaoTest.php`
   continuam verdes e inalterados (nenhuma asserção mudou), mais 4 testes
   novos em `tests/Feature/Fichas/AcentosNomeTest.php`. Os 3 grupos
   completos do projeto — (a) Unit+Contratos+Leitor = 242, (b) Auth+
   Fiabilidade+FilaProcessamento+Feature soltos = 20, (c) Fichas/* exceto
   os 2 testes de integração com o Qwen real = 167 — 429 testes, 0 falhas,
   24/09/2026. O nome entra em todos os processos com CC+RC; observar os
   primeiros casos reais em dev.
5. **Sem passo de rollback especial** — reverter o commit chega.
6. **NÃO leva a prod** — só dev, como o resto do módulo.
7. **Pendente para a próxima entrega**: `ocr_parse_cartao_especialidade`
   (330 linhas, cartões PSP continuam com `Extratores\CartaoPsp` próprio);
   `ocr_parse_morada`/`ocr_parse_iban`/`ocr_parse_passaporte`/
   `ocr_parse_habilitacoes`; `ocr_merge_campos` vs. a política de confiança
   atual.

## 2026-09-24 (4ª entrega) — Porte de ocr_docs.php: cartão PSP, IBAN, morada, habilitações, passaporte

Continuação das três entradas anteriores — ver `app/docs/PORTE-OCR-DOCS.md`
"Estado da 4ª entrega" para o detalhe completo.

1. **Só código de aplicação** (`app/Support/OcrDocs.php`,
   `app/Services/Fichas/Extratores/CartaoPsp.php`,
   `app/Services/Fichas/Extratores/Iban.php`, `app/Jobs/LerCaso.php`) —
   sem migração, sem alteração de `.env`, sem ação nova do wrapper. Segue
   o deploy normal (sync + `view:clear` + restart php-fpm/queue).
2. **Portado literalmente** (com testes de paridade contra o PHP original
   no CT 100): `ordenarSiglas`, `cartaoInfosPorLinha`,
   `parseCartaoEspecialidade`, `ibanValidado`, `parseIban`, `parseMorada`,
   `habilitacoesDeNome`, `parseHabilitacoes`, `paisIso3`,
   `parsePassaporte`. `extrairDatas` foi ESTENDIDA (literal do original)
   com o ramo de meses por nome (JAN/FEV/.../DEZ) e o `$reparadas` por
   referência — necessário para `parseCartaoEspecialidade` assinalar a
   amarelo as validades cuja leitura foi reparada; a extensão é aditiva,
   não muda o comportamento dos dois ramos numéricos já usados pelo RC/CC.
3. **Integrado — cartão PSP** (`CartaoPsp::extrairDaPagina`):
   `OcrDocs::parseCartaoEspecialidade` corre por BLOCO (o mesmo bloco que
   a lógica própria já isola por número/cabeçalho) como 1ª leitura só da
   VALIDADE; a categoria impressa (`categoriaPorTexto`, usada também para
   detetar conflito com o sufixo do número) não foi tocada — decisão
   deliberada para não desarmar a deteção de conflito. Cobre
   explicitamente o caso 773 (validades ARD/ARE trocadas): 2 testes novos
   em `CartaoPspTest.php` com dois cartões no mesmo documento (formato
   dd/mm/aaaa e formato "DDMESAAAA" dos cartões reais) confirmam que cada
   validade fica ligada ao SEU sufixo, não ao outro cartão nem à ordem no
   texto.
4. **Integrado — IBAN** (`Iban::extrair`): `OcrDocs::parseIban` corre só
   como COBERTURA ADICIONAL quando a lógica própria (guarda de
   mascaramento + `candidatoPt()` ancorado em "PT50" literal + estrangeiro)
   não encontra nada — nunca substitui um resultado próprio, para não
   reabrir os bugs reais documentados no ficheiro (IBAN mascarado, layout
   em colunas que colava um rótulo a meio dos dígitos). Cobre o caso do
   NIB puro sem prefixo "PT" (doc "nib.jpg"), que a lógica própria não
   tentava.
5. **Integrado — morada** (`LerCaso::processarMorada`):
   `OcrDocs::parseMorada` passa a ser a 1ª leitura (reconhece domicílio
   fiscal, rótulos "MORADA"/"TITULAR DO CONTRATO" e as guardas
   institucionais Seg. Social/Finanças/Saúde/seguro — "vazio > errado",
   nunca extrai a morada do REMETENTE); o regex ingénuo antigo (bloco à
   volta do 1º código postal, sem guardas nenhumas) fica como fallback só
   quando o porte não devolve morada. `Morada::normalizar` (padrão da
   casa) continua a aplicar-se sempre por cima do resultado de qualquer um
   dos dois. O comparador semântico do Qwen (`SegundaLeituraQwen`,
   2ª leitura independente para rc/morada/iban) não foi tocado.
6. **Integrado (capacidade NOVA) — habilitações**
   (`LerCaso::processarHabilitacoes`): a app nunca teve extração própria
   para este tipo de documento — o `default => null` do `match` deixava-o
   só para o Qwen/Portal. Porte de `ocr_habilitacoes_de_nome` (nome do
   ficheiro, fonte mais fiável — o utilizador nomeia-o) como 1ª leitura,
   com `ocr_parse_habilitacoes` (texto, CONSERVADOR, só âncoras fortes)
   como fallback. Risco baixo por ser puramente aditivo: não havia
   comportamento anterior para regredir.
7. **Portado e testado, NÃO integrado — passaporte**:
   `OcrDocs::parsePassaporte`/`paisIso3` têm paridade testada mas
   `Classificador::classificar` ainda não distingue 'passaporte' como tipo
   próprio (cai no fallback junto com 'tr_ambos' e outros) — integrar sem
   essa distinção significaria correr o parser sobre documentos que podem
   não ser passaportes. Fica documentado como pendente (ver
   `app/docs/PORTE-OCR-DOCS.md`).
8. **Risco de regressão**: os 3 grupos completos do projeto —
   (a) Unit+Contratos+Leitor = 260, (b) Auth+Fiabilidade+
   FilaProcessamento+Feature soltos = 20, (c) Fichas/* exceto os 2 testes
   de integração com o Qwen real = 174 — 454 testes, 0 falhas, 24/09/2026.
   9 testes novos: 2 em `CartaoPspTest.php` (caso 773), 2 em
   `IbanTest.php` (NIB puro via porte + confirmação de que o porte nunca
   substitui um resultado mascarado), 7 em
   `tests/Feature/Fichas/HabilitacoesMoradaPorteTest.php` (habilitações +
   morada) — mais 26 testes de paridade em `OcrDocsParidadeTest.php`
   (fixtures em `tests/Unit/Fichas/fixtures/ocr_docs_paridade.json`,
   chaves `ordenarSiglas`, `cartaoInfosPorLinha`,
   `parseCartaoEspecialidade`, `ibanValidado`, `parseIban`, `parseMorada`,
   `habilitacoesDeNome`, `parseHabilitacoes`, `paisIso3`,
   `parsePassaporte`).
9. **Sem passo de rollback especial** — reverter o commit chega.
10. **NÃO leva a prod** — só dev, como o resto do módulo.
11. **Pendente para a próxima entrega**: integrar `parsePassaporte`
    quando o Classificador distinguir 'passaporte'; `ocr_merge_campos`
    vs. a política de confiança atual (ainda por comparar campo a campo);
    `ocr_campos_uteis`/`ocr_classificar_por_nome`/`ocr_tipo_por_tokens`
    (já portadas, ainda sem integração — decidir onde entram no fluxo
    atual, que não classifica por nome de ficheiro hoje).

## 2026-09-25 — Correções do módulo Fichas depois do uso em prod

Seis pontos pedidos pelo utilizador depois da primeira volta em produção. Só dev (CT 117) — nada instalado em CT 121/107/Proxmox prod.

1. **Classificador — verso do CC sem MRZ**: quando o Vision não lê a MRZ
   (imagem cortada), o porte (`ocr_classificar_por_conteudo`) aceitava o
   cabeçalho "Cartão de Cidadão" como `cc_frente` sem ter como distinguir.
   Nova regra em `Classificador::classificar`, testada antes de aceitar o
   `cc_frente` do porte: marcadores do VERSO (filiação, NISS, nº de
   utente, identificação fiscal/NIF) sem nenhum marcador da FRENTE
   (apelido/surname, data de nascimento/birth, nome(s)/given names) força
   `cc_verso`. Um CC digital continua `cc_digital` (marcador próprio,
   testado antes) mesmo trazendo os dois tipos de marcador juntos.
2. **Morada::normalizar — 3 bugs reais de prod** (diagnóstico de 40
   processos, 25/09/2026):
   - As abreviaturas (`R.`/`Av.`/`Tv.`/`Trav.`/`Estr.`/`Lg.`/`Pç.`)
     deixavam o ponto solto ("Rua. das Flores") — o `\b` final da regex
     não fecha entre dois caracteres não-palavra (o ponto e o espaço a
     seguir), por isso o `\.?` nunca consumia o ponto. Trocado por
     lookahead de espaço/vírgula/fim de string.
   - Os designadores de andar/porta (Dto/Dta/Direito/Esq/Esquerdo/Frente/
     Fte/Tras/Trás) só reconheciam o texto exatamente nessa grafia — um
     documento em maiúsculas ("2 DTO") não ganhava o "º". Passa a
     reconhecer insensível a maiúsculas e a normalizar sempre a grafia via
     `Morada::DESIGNADORES_ANDAR` ("2 DTO" -> "2º Dto").
   - Quando o "nº" só nasce depois (documento sem rótulo, "Rua X, 12"), a
     vírgula que já vinha do documento não era limpa (a limpeza corria
     antes de inserir o "nº") — duplicava para "Rua X, nº12, CP". A
     limpeza da vírgula solta antes de "nº" passa a repetir-se depois de
     `inserirNumeroSemLabel`.
   Todos os testes antigos de `MoradaTest` continuam verdes; 8 testes
   novos cobrem os 3 casos (mais o designador "Fte").
3. **Leituras alternativas de morada normalizadas**: os botões "[usar]"
   de Vision/modelo local na página do caso, para um campo `morada` em
   revisão, mostravam o valor em maiúsculas tal como veio do documento.
   `mostrar.blade.php` passa a normalizar (`Morada::normalizar`) também
   aí, mostrando sempre "original -> normalizada"; o valor que "usar"
   grava na caixa já é o normalizado.
4. **UI deixa de dizer "Qwen" fixo**: o modelo em produção mudou (Gemma 4
   26B A4B QAT), a UI continuava a dizer "Qwen". `SegundaLeituraQwen::
   nomeModelo()` (novo, memoiza `saude()` — nunca mais de 1 pedido a
   `/v1/saude` por execução do job) devolve o modelo real de `/v1/saude`
   ou "Modelo local" sem resposta. Usado nos botões "[usar]" (rótulo
   `{{ $modeloLabel }}:`, calculado em `FichasController::mostrar` a
   partir de `fichas_extracoes.modelo_qwen`) e nos motivos gravados por
   `LerCaso`: "Só lido pelo `<modelo>`: confirmar", "Vision e `<modelo>`
   divergem", "`<modelo>` indisponível". Nomes de classes/colunas
   (`SegundaLeituraQwen`, `valor_qwen`, `modelo_qwen`, `leitor` =
   `"qwen:<modelo>"` em `fichas_fiabilidade`) não mudaram — só o texto
   mostrado ao utilizador.
5. **Zona 1 visível e editável na página do caso**: só a Zona 2
   aparecia; a Zona 1 (coluna `zona`, obrigatória desde o registo) só se
   via/editava no "Novo caso". `mostrar.blade.php` ganha um campo "Zona 1"
   ao lado de "Zona 2", mesmo padrão (select com a lista de zonas ativas
   quando disponível, texto livre como reserva). No guardar
   (`EditarCamposRequest`/`FichasController::guardar`), uma zona 1 em
   branco mantém o valor já gravado (ao contrário da zona 2, que é
   opcional e pode ficar vazia).
6. **Visualizador de documentos**: `.panel` passa de `position:absolute`
   para `position:fixed` em `public/css/app.css` — antes ficava preso ao
   topo de `.app` e desaparecia ao descer a página (achado real). Cabeçalho
   do painel (com o botão Fechar) em `position:sticky`. `public/js/app.js`
   ganha o fecho por Esc (só quando não há ecrã inteiro aberto — esse já
   trata o seu próprio Esc) e por clique fora do painel. PDFs passam de
   200 DPI/qualidade 85 para 300 DPI/qualidade 90 em
   `DocumentoController::paginaPdfComoJpeg` — texto pequeno dos
   cartões/CC ilegível no zoom. O zoom com passos, "Ajustar à largura" e
   o ecrã inteiro já existiam em `public/js/viewer.js`/`viewer.css` e
   ficam sem alteração de comportamento.

**Testado**: (a) `tests/Unit` + `tests/Feature/Contratos` +
`tests/Feature/Leitor`: 314/314; (b) `tests/Feature/Fichas/*.php` exceto
`FichasLerCommandTest`/`RetryExtracaoTest`: 176/176 — inclui
`SegundaLeituraQwenTest` com o nome real do modelo do mini
("google/gemma-4-26b-a4b-qat") confirmado num teste sem fake de
`/v1/saude`, a correr contra o leitor real a partir do CT 117.

**Alterado**: `app/Services/Fichas/Classificador.php`,
`app/Services/Fichas/Extratores/Morada.php`,
`app/Services/Fichas/SegundaLeituraQwen.php`, `app/Jobs/LerCaso.php`,
`app/Http/Controllers/FichasController.php`,
`app/Http/Requests/Fichas/EditarCamposRequest.php`,
`app/Http/Controllers/DocumentoController.php`,
`resources/views/fichas/mostrar.blade.php`, `public/css/app.css`,
`public/js/app.js`; testes em `tests/Unit/Fichas/{ClassificadorTest,
MoradaTest}.php` e `tests/Feature/Fichas/SegundaLeituraQwenTest.php`.

**Rollback**: sem migração — reverter o commit chega. Sincronizado por
rsync + `view:clear` + restart `php8.4-fpm`/`assistente-queue` no CT 117.

**Pendente**: o ponto 6 (painel fixo, Esc/clique fora, resolução do PDF)
só foi verificado por leitura do código e pela suite automatizada — sem
teste manual num browser real (fora do âmbito pedido, que limitava os
testes aos grupos Pest). Confirmar visualmente no CT 117 antes de dar
por fechado. Nada tocado em prod (CT 121/107).

## §19 — 2026-09-25: Botão "Fazer contrato" na ficha + pesquisa na lista de contratos

**Pedido**: (1) da página da ficha já `inserido_verificado`, um botão que
salta direto para o contrato desse processo, sem passar pela fila de
contratos manualmente. (2) na lista de contratos, uma caixa de pesquisa que
filtra as tabelas ao escrever.

1. **"Fazer contrato" (`fichas/mostrar.blade.php`)**: visível só com
   `estado='inserido_verificado'`. `FichasController::fazerContrato`:
   resolve o `id_processo` (`sabichao_id_processo` do caso, gravado pela
   conferência pós-apply; se ausente, `Cruzamentos::ficha($caso->nif)`),
   confirma na fila (`Cruzamentos::filaContratos()`, `contrato='Fazer' AND
   estado='Ativo'`) — sem correspondência, erro "Este processo não está na
   fila de contratos." Cria/reaproveita o `ContratoCaso`
   (`ContratoCaso::paraProcesso()`, a mesma criação que
   `ContratosController::index` já fazia ao abrir a fila — extraída para
   não duplicar), despacha `RedigirContrato` se o caso não estiver
   `publicado` (`ContratoCaso::podeRedigir()`, mesma regra de
   `redigirSelecionados`) e redireciona para `contratos.mostrar`.
2. **Pesquisa na lista de contratos (`contratos/index.blade.php`)**: caixa
   `#pesquisa-contratos` no topo; `public/js/contratos-pesquisa.js` (novo)
   filtra ao escrever todas as `<tr>` com `<td>` de qualquer tabela `.card`
   da página, comparando o texto normalizado (sem acentos, minúsculas) —
   sem chamada ao servidor. O "selecionar todos" (`#chk-todos-fila`) passa
   a marcar só as checkboxes `.chk-caso` das linhas visíveis.

**Testado**: `tests/Unit` + `tests/Feature/Contratos` +
`tests/Feature/Leitor` + `tests/Feature/Fichas/*.php` exceto
`FichasLerCommandTest`/`RetryExtracaoTest`: 499/499 no CT 117 —
`tests/Feature/Fichas/FazerContratoTest.php` (6, `Cruzamentos` mockado) e
`tests/Feature/Contratos/PesquisaListaTest.php` (3, smoke test — sem
runner de JS no projeto, mesma limitação de `DatasMascaraTest`, §anterior).

**Alterado**: `app/Http/Controllers/{FichasController,ContratosController}.php`,
`app/Models/ContratoCaso.php`, `routes/web.php`,
`resources/views/fichas/mostrar.blade.php`,
`resources/views/contratos/index.blade.php`,
`public/js/contratos-pesquisa.js` (novo).

**Rollback**: sem migração — reverter o commit chega.

**Pendente**: teste manual num browser real (fora do âmbito desta sessão,
limitado aos grupos Pest). Nada tocado em prod (CT 121/107).
