# FASE4-RESULTADOS — Dry-run e apply em dev (docs/PLANO.md, Fase 4)

## O que foi feito

- **`App\Services\Fichas\Aplicador`** — cliente SSH da chave de *apply* (separado de `Cruzamentos`, que só usa a chave de consulta), ações `apply`/`ficha_pdf`/`email`. `ativoEmDev()` gate por `WRAPPER_APPLY_ATIVO=dev`.
- **`App\Services\Fichas\Conferencia`** — comparação campo a campo do manifesto aprovado contra `consulta.ficha` depois do apply: literal normalizado primeiro; morada/nome/naturalidade recorrem a `LeitorClient::comparar` (Qwen) quando a literal falha; especialidades comparadas como conjunto (ordem não é significativa); IBAN comparado sem espaços (inserir.php reformata sempre em blocos de 4). `mai`/`admissao`(→`processo_admissao`)/`especialidades`/`zona1`/`zona2`/`local_servico`/`nhoras` entram na comparação, não só os dados pessoais — alargado numa revisão do Codex.
- **`FichasController`**: `aplicarDev`, `reconciliar`, `pdf`, `email`, `emailLote` + máquina de estados (`docs/02-MAQUINA-ESTADOS.md`): `dry_run_ok → a_aplicar → inserido_verificado | erro_tecnico`; `a_aplicar` também aceita reentrar em `aplicarDev` (apply idempotente) para destravar um caso preso depois de um timeout sem reconciliação possível.
- **Aprovação verificada antes do apply**: `aprovacaoInvalida()` recompõe o manifesto e o hash de cada documento; qualquer divergência invalida a aprovação e devolve o caso a `por_rever` em vez de aplicar sobre dados desatualizados.
- **wrapper.php** (`wrapper-ct107/`): `ficha_pdf` implementado de facto (`ficha_pdf.php` novo, `funcoes/ficha_funcionario_render.php` + wkhtmltopdf, base64, limite 10 MB); `email` corrigido para só usar `--ignora-modo-teste` em prod (em dev confirma `configuracoes_globais.email.modo_teste=1` antes de sequer tentar); nova ação `consulta.alvo` (confirma remotamente `dev`/`prod`, correspondência exata, nunca um default otimista); idempotência do `apply` revista de fundo depois de 4 rondas de revisão do Codex — ver secção 7 de `docs/03-CONTRATO-WRAPPER-CT107.md` (lock por PID, marca `INCERTO` quando o registo não pode ser persistido, reconfirmação do registo depois de adquirir o lock).
- **Email não idempotente, tratado como tal**: mutex `fichas_casos.email_enviando_em` (nunca `email_enviado_em` sozinho — um valor "já enviado" reaproveitável como observado por um pedido concorrente não chega); qualquer exceção depois de invocar o wrapper (não só timeout) fica marcada como resultado incerto, nunca revertida às cegas; reenvio exige `confirmar_reenvio` explícito (botão muda de texto e pede confirmação no browser).
- **`GeradorManifesto`**: `zona1`/`local_servico` passam a vir de `$caso->zona`/`$caso->local_servico` — bug pré-existente (Fase 2/3) encontrado só pelo teste ponta a ponta desta fase: o manifesto nunca tinha escrito a zona real no Sabichão (`inserir.php` lê `campo($f,'zona1')` do manifesto), ia sempre vazio.
- **`SegundaLeituraQwen::camposMapeados`**: campo não-escalar do Qwen (documento ambíguo) ignorado em vez de rebentar o job (`Array to string conversion`) — bug pré-existente, só apareceu com um caso sintético real no teste ponta a ponta.
- **Ownership dos ficheiros de chave SSH no CT 117**: `/home/claude/.ssh/assistente_consulta`/`assistente_apply` eram só legíveis pelo dono (`claude`, 0600) — nunca tinha sido um problema porque os testes das Fases 2/3 corriam o worker manualmente como `claude`; com o worker (e o php-fpm) a correr como `www-data` (systemd, Fase 4), os pedidos SSH ao wrapper falhavam com "Identity file ... Permission denied". Primeira tentativa (`chgrp www-data` + `chmod 640`, mantendo o dono `claude`) pareceu resolver, mas o OpenSSH cliente recusa um ficheiro assim quando é o PRÓPRIO DONO (`claude`) a usá-lo ("Permissions 0640 ... are too open" — a verificação estrita do cliente é sensível a quem está a correr o `ssh`, não só ao modo do ficheiro), o que só apareceu ao correr a suite Pest (que corre como `claude`, não `www-data`). Corrigido de vez: `chown www-data:www-data` + `chmod 600` — a app (php-fpm e o worker) passa a ser dona de facto das próprias chaves, sem herança de `claude`. **Nenhum teste automatizado exercita isto** — `Cruzamentos`/`Aplicador` são sempre mockados na suite; só um teste real contra a infraestrutura o apanha.
- **`wkhtmltopdf`** instalado no Sabichão de dev (CT 100) — não existe no apt do Debian 13; instalado a partir do `.deb` estático oficial (`wkhtmltox_..._bookworm_amd64.deb`, funciona em trixie) + `xfonts-75dpi`/`xfonts-base`.
- **Worker como serviço systemd** — `deploy/assistente-queue.service` instalado e ativo no CT 117 (`User=www-data`, `Restart=always`), substituindo o `queue:work` manual das fases anteriores.

## Testes automatizados

- **`tests/Feature/Fichas/AplicarDevTest.php` + `tests/Unit/ConferenciaTest.php`: 33 testes** (crescido ao longo das 12 rondas de revisão do Codex): aprovação invalidada bloqueia o apply; `WRAPPER_APPLY_ATIVO` desligado bloqueia; alvo remoto != dev bloqueia (`consulta.alvo`); chave de idempotência determinística (hash do manifesto + id da aprovação); timeout deixa o caso em `a_aplicar` (nunca `erro_tecnico`); reconciliação confirma via `consulta.ficha` e completa a conferência; reconciliação ignora um processo antigo do mesmo NIF (re-admissão) que não corresponde à admissão deste caso; resultado incerto do wrapper (lock/registo por gravar) mantém `a_aplicar`; um caso preso em `a_aplicar` pode ser reaplicado (idempotente); conferência com tudo ok/equivalente → `inserido_verificado`; conferência com divergência → `erro_tecnico` + motivo; PDF servido com `no-store`; email em lote regista execuções e marca `email_enviado_em`; email em lote com falha não marca; pedidos concorrentes (duplo clique, 1º envio e reenvio confirmado) só deixam um reivindicar; qualquer exceção no envio (não só timeout) fica incerta; recusa/permite reenvio conforme `confirmar_reenvio`; comparação literal normalizada, campo semântico com/sem equivalência via Qwen, especialidades como conjunto, mai/admissão mapeados, IBAN sem espaços.
- **1 teste de regressão em `tests/Feature/Fichas/SegundaLeituraQwenTest.php`** para o bug do campo não-escalar.
- **179 testes Pest a passar no CT** (145 da Fase 3 + 34 novos da Fase 4), suite completa, sem regressões — todos os testes desta fase mockam `Cruzamentos`/`Aplicador` (nunca ligam a sério ao wrapper), depois de um ajuste: dois testes que inicialmente faziam SSH real contra `consulta.alvo` deixaram de funcionar quando as chaves passaram a ser propriedade de `www-data` (ver "Ownership das chaves SSH" abaixo) — corrigido mockando `Cruzamentos::alvo()` como o resto da suite já fazia.

## Revisão do Codex (`codex review --uncommitted`)

13 rondas ao longo do bloco (12 completas + a 13ª interrompida por limite de uso da conta, retomável depois de 2026-09-23 00:30). Achados reais corrigidos, por ronda:

1. Conferência não comparava `mai`/`admissao`/`especialidades`/zona/local/horas — só dados pessoais.
2. `<form>` do envio em lote cruzava a fronteira de uma `<div>` a meio — o botão ficava fora do formulário na prática.
3. Timeout antes de o apply chegar a correr deixava o caso preso em `a_aplicar` sem forma de o repetir nem de o marcar como falha técnica.
4. Import errado (`Symfony\...\ProcessTimedOutException` em vez de `Illuminate\Process\Exceptions\ProcessTimedOutException`) — o catch de timeout nunca disparava a sério.
5. Corrida de idempotência: o lock só era gravado DEPOIS de `correr_inserir()`; um SSH local expirado podia deixar o processo remoto continuar e uma 2ª tentativa reaplicar.
6. `ativoEmDev()` só validava a flag local — uma configuração trocada podia mostrar "Aplicar em DEV" e agir em prod (→ `consulta.alvo`).
7. Reenvio de email sem barreira nenhuma contra duplo clique/pedidos concorrentes (→ mutex `email_enviando_em`).
8. `reconciliar()` sem a mesma barreira de alvo das outras ações Fase 4.
9. `consulta.ficha` tratada como confirmação sem verificar que era o processo NOVO (re-admissão podia confundir o processo antigo).
10. Reclamar o lock por idade (`mtime`) mesmo com o dono ainda vivo anulava a própria garantia de idempotência num apply lento.
11. `ler_alvo()` (usado por `resolver_ambiente()`) tem um default otimista para 'dev' quando o ficheiro falta — inadequado como prova de "isto é dev" (→ `acao_consulta_alvo` nunca usa esse default).
12. `enviarEmailProcessos` só tratava `ProcessTimedOutException` como incerta; qualquer outra exceção revertia a marca de enviado — arriscando reenvio às cegas.
13. IBAN comparado sem normalizar separadores — `inserir.php` reformata sempre em blocos de 4, um apply correto ficava "diferente".
14. Reenvio confirmado sem proteção de concorrência nenhuma (a comparação otimista só olhava para o valor observado, reutilizável por um pedido concorrente).
15. Falha ao gravar o registo de idempotência libertava o lock na mesma (o PID já tinha terminado, reclamável por qualquer tentativa seguinte) → marca `INCERTO`.

Nenhum destes era hipotético — todos alteravam o comportamento real do fluxo de apply/email. A suite de testes cresceu a par de cada correção (ver secção anterior).

## Teste ponta a ponta (caso sintético)

**Nunca dados reais.** MAI `900010`, NIF `290005124` (checksum válido, fictício, série `2900051xx` nunca usada em prod), admissão `2026-10-15`, zona `Açores` (zona real de dev, sem significado no teste).

### 1. Fixtures sintéticas

Geradas com Pillow no mac mini (`ssh ruipedro@10.0.10.231 '~/leitor/.venv/bin/python /tmp/gerar_fixtures.py'`): `cc frente.jpg`, `cc verso.jpg` (NISS + MRZ), `vig.jpg` (cartão PSP Vigilante, nº `900010021`, validade 26/04/2029), `Certificado Registo Criminal.jpg` (finalidade "exercício da atividade de segurança privada", "CÓDIGO VIGENTE ATÉ (AAAA-MM-DD): 2028-12-31"), `iban.jpg` (IBAN de exemplo com checksum válido, `PT50 0002 0123 1234 5678 9015 4`). Copiadas para `/var/www/assistente/storage/app/e2e-fixtures/processos/900010 Caso Sintetico Fase4/Proc 1/` no CT 117, servidas por um **segundo caminho** (`FICHAS_PROCESSOS_PATH_TESTE`, só tentado quando o MAI não existe na NAS real — `LocalizadorPasta` alargado para o suportar, nunca interferindo com a leitura de casos reais).

### 2. Reserva no Sabichão de dev

`INSERT INTO processo (id_processo, nif, admissao, estado, nhoras) VALUES (279, '290005124', '2026-10-15', 'Reservado', 0)` — anotado para reversão no fim (secção "Limpeza").

### 3. Fluxo pela webapp a sério (sessão HTTP real: login por username+password, CSRF, cookies)

| Passo | Como | Resultado |
|---|---|---|
| Registar | `POST /fichas/novo` (MAI/NIF/admissão/zona/local/horas/alínea/modalidade) | Caso 28 criado, `LerCaso` despachado |
| Ler | Worker systemd apanha o job | Ver "bugs encontrados" abaixo — 2 problemas reais impediram a 1ª e 2ª tentativa; corrigidos; a 3ª leitura terminou com sucesso |
| Rever | `nascimento`/`naturalidade`/`val_rc` em confiança alta (confirmados por 2ª fonte, Qwen); `ncc`/`niss`/`validade_cc`/`filho_de` em "rever" (só uma fonte, layout sintético sem redundância suficiente); `concelho`/`distrito`/`nacionalidade`/`morada`/`iban`/`email`/`contacto`/`estado_civil`/`ndependentes`/`habilitacoes`/campos de emergência em "rever" vazios (sem `consulta.portal_lookup` para um NIF fictício — nunca há dados do portal para inventar) | Editados manualmente via `POST /fichas/28/guardar` (mesma rota que a RH usa) — todos os campos em rever preenchidos |
| Aprovar | `POST /fichas/28/aprovar` | `aprovado`, manifesto+hashes fixados, `fichas_fiabilidade` alimentada |
| Dry-run | `POST /fichas/28/dry-run` | `dry_run_ok` — `inserir.php` sem `--apply` confirma reserva, schema OK |
| Aplicar em DEV | `POST /fichas/28/aplicar-dev` | `inserido_verificado` — apply com sucesso, conferência sem divergências |
| Conferência | Automática dentro do apply | Tabela "Conferência pós-inserção" no caso: **25/25 campos `ok`**, incluindo `zona1`='Açores', `especialidades`='VIG', `val_vig`, `admissao`, `local_servico`, `nhoras` — confirma em produção real o fix ao `GeradorManifesto` (bug 3 abaixo, encontrado e corrigido durante as rondas de revisão do Codex, antes deste teste; o teste ponta a ponta é que prova que ficou mesmo resolvido) |
| PDF | `GET /fichas/28/pdf` | PDF válido (`%PDF-1.4`, 1 página, 39.5 KB), `Content-Disposition: attachment`, `Cache-Control: no-store, private`, `X-Content-Type-Options: nosniff`, sem cópia guardada no CT |
| Email | `POST /fichas/28/email` | Email enviado — `modo_teste` ativo em dev confirmado antes (`email.modo_teste=1`), "Email ENVIADO mas REDIRECIONADO pelo modo_teste: foi para ruipedrox13@gmail.com, NAO para recursoshumanos@segunor.pt" (texto real do wrapper) |

### 4. Bugs reais encontrados só por este teste (nenhum nos 179 testes automatizados)

1. **`Array to string conversion` em `SegundaLeituraQwen::camposMapeados`** — o Qwen devolveu um array (não string) para `filho_de` num dos documentos sintéticos; `(string) $array` é um `ErrorException` fatal sob o error handler do Laravel, o job falhava por inteiro (3 tentativas → `erro_tecnico`, confirmado no log: `tentativas=3`). Corrigido: valores não-escalares são ignorados com aviso no log, nunca rebentam o job. Teste de regressão adicionado.
2. **Ownership das chaves SSH do wrapper no CT 117** — `/home/claude/.ssh/assistente_consulta`/`assistente_apply` só legíveis pelo dono (`claude`); com o worker/php-fpm a correr como `www-data` (systemd, Fase 4), qualquer chamada ao wrapper (dry-run, apply, cruzamentos durante a leitura) falhava com "Identity file ... Permission denied", sempre engolida como falha técnica recuperável (o caso voltava para `registado` e era reapanhado, mascarando o problema como "lento" até esgotar as 3 tentativas). Nunca tinha aparecido porque as Fases 2/3 testaram sempre com o worker corrido manualmente como `claude`. Corrigido de vez: `chown www-data:www-data` + `chmod 600` (uma tentativa intermédia com `chgrp`+`640`, mantendo `claude` como dono, funcionava para `www-data` mas era recusada pelo próprio OpenSSH cliente quando corrido como `claude` — "Permissions ... too open" — só apanhado depois, ao correr a suite Pest, que usa esse utilizador). **Nenhum teste automatizado exercita isto** — `Cruzamentos`/`Aplicador` são sempre mockados na suite; só um teste real contra a infraestrutura o apanha.
3. **`WRAPPER_CHAVE_APPLY`/`WRAPPER_APPLY_ATIVO` documentados mas nunca escritos no `.env` real do CT 117** — o primeiro "Aplicar em DEV" do teste falhou com "está desativado (WRAPPER_APPLY_ATIVO)" apesar de o código e a `install.sh` do wrapper estarem corretos; a variável só existia em `docs/DEPLOY.md`, esquecida no `.env` de facto. Corrigido (`cat >> .env`, `config:clear`, reiniciar o worker systemd).
4. (Não descoberto por este teste, mas confirmado por ele) **`GeradorManifesto` nunca escrevia a zona real** — `zona1`/`local_servico` estavam na lista de campos "opcionais" com default `""`, nunca ligados a `$caso->zona`/`$caso->local_servico`; encontrado numa ronda de revisão do Codex à `Conferencia` (que passou a comparar estes campos) antes deste teste, e corrigido então. O teste ponta a ponta confirma o fix: `zona1`/`local_servico` aparecem "ok" na conferência real.

**Nota operacional** (não um bug, mas consumiu tempo real durante o teste): submeter o formulário "Guardar edições" por `curl --data-urlencode` com um array indexado por id (`cartoes[88]=1`) não marcou a checkbox do cartão de forma fiável nas várias tentativas — a causa exata não foi isolada (o próprio fluxo de checkbox já tem teste de regressão da Fase 3, `AprovacaoTest`, que passa); corrigido diretamente por `FichaCartao::update()` para não bloquear o teste. Recomendação: testar esta interação específica sempre pelo browser, não por `curl`.

### 5. Limpeza (Sabichão de dev)

Depois de confirmado `inserido_verificado`, revertido tudo conforme a secção de reversão do SKILL.md (`inserir-funcionario`): `funcionarios` (DELETE pelo NIF sintético), `processo` (reposto a `estado='Reservado'`, id_processo 279, sem o resto dos campos do apply), `status_plataforma_rh`, `log_funcionarios`, `processo_contrato` (se aplicável), `funcionarios_condicoes` — todos pelo NIF `290005124`/id_processo `279`. Confirmado por `SELECT` que ficou limpo (nenhuma linha nas tabelas com este NIF/id_processo fora do estado `Reservado` original, que também foi removido no fim — a reserva era só para este teste). `FICHAS_PROCESSOS_PATH_TESTE` e a pasta `storage/app/e2e-fixtures/` também removidas do CT 117.

## Contrato, condições e App RH — teste de ponta a ponta (23/09)

Repetição do teste de ponta a ponta (mesma metodologia da secção anterior: sessão HTTP
real com login+CSRF+cookies, fixtures sintéticas via `FICHAS_PROCESSOS_PATH_TESTE`,
reserva por SQL, reversão completa no fim), desta vez para confirmar que o bloco
Contrato/Condições/App RH acrescentado ao "Novo caso" (commits `15e3759`, `57c9aba`)
fica gravado a sério no Sabichão de dev — a "BUG GRAVE" registada na memória do projeto
a 23/09 01:50 (o `GeradorManifesto` nunca incluía `contrato_termo` nem `condicoes`) já
tinha sido corrigida e commitada antes deste teste; este teste é a prova independente de
que ficou resolvida.

**Nunca dados reais.** MAI `999950`, NIF `290999120` (checksum válido, fictício),
admissão `01/10/2026`, zona `Porto` (zona real de dev). Caso 4 na webapp.

### Fixtures e reserva

Fixtures Pillow geradas no mac mini (variante do script da Fase 4, cartão PSP nº
`999950031`, RC "vigente até 2029-06-30", IBAN `PT61 0035 0000 0012 3456 7890 5`
válido), copiadas por tar via ssh para `/tmp/fichas-teste-pw` no CT 117
(`FICHAS_PROCESSOS_PATH_TESTE`). Reserva `INSERT INTO processo (id_processo, nif, mai,
admissao, estado) VALUES (279, 290999120, '999950', '2026-10-01', 'Reservado')`.

**Achado próprio deste teste (fixture, não bug da app):** o número de cartão PSP usado
inicialmente (`999950031`) termina em `031` — `CartaoPsp::SUFIXO` só reconhece os
prefixos `02`/`04`/`05`/`06` (VIG/SPR/ARD/ARE) nos dois primeiros dígitos do sufixo de 3;
`03` não está mapeado, por isso `categoria_sufixo` ficou vazio e o cartão não entrou em
`especialidades`. Não afeta a comparação pedida (contrato/condições/App RH) e não é um
bug do motor — só uma fixture mal escolhida; documentado aqui para a próxima ronda usar
sempre um sufixo `02x`.

### Escolhido na webapp vs lido por SELECT (Sabichão dev)

| Campo | Escolhido no Novo caso | `processo_contrato`/`funcionarios_condicoes`/`status_plataforma_rh` (SELECT direto) |
|---|---|---|
| Modalidade | termo_certo | termo_certo |
| Data início | (vazio → = admissão) | 2026-10-01 |
| Duração | 6 meses | 6, meses |
| Data fim (calculada) | — | 2027-03-31 |
| Renovável | sim | 1 |
| Renovação | 6 meses | 6, meses |
| Renovações previstas | 2 | 2 |
| Motivo | alínea f) | "n.º2 f) Acréscimo excecional de atividade da empresa" |
| Horas/período | 40 semanais | nhoras=40, periodo_horas=semanais |
| Horas noturnas | sim | recebe_horas_noturnas=1 |
| Horas extra | não | recebe_horas_extra=0 |
| Feriados | não | recebe_feriados=0 |
| Fundo desemprego parcial | não | fundo_desemprego_parcial=0 |
| Duodécimos férias | sim | subsidio_ferias_duodecimos=1 |
| Duodécimos Natal | sim | subsidio_natal_duodecimos=1 |
| Quota sindical | não | quota_sindical=0 |
| Usar dias de trabalho | sim | usar_dias_trabalho=1 |
| Dias de trabalho | Seg-Sex | dias_trabalho="1,2,3,4,5" |
| App RH | Adicionar | status_plataforma_rh.status="Adicionar" |

Todos os valores batem certo — confirmado por SELECT direto às tabelas
`processo_contrato`, `funcionarios_condicoes` e `status_plataforma_rh` (não só pela
conferência da própria app). A conferência pós-inserção da página do caso mostrou
**33/33 campos "ok"**, incluindo todas as linhas `contrato.*`, `condicoes.*` e
`rh_status`. Estado final do caso: `inserido_verificado`.

### Limpeza

Depois de confirmado, revertido por SQL (`funcionarios`, `processo`, `processo_contrato`,
`funcionarios_condicoes`, `status_plataforma_rh`, `log_funcionarios`,
`funcionarios_dados_pessoais`, todos por `nif=290999120`/`id_processo=279`) e confirmado
por `SELECT` que ficou tudo a zero. `FICHAS_PROCESSOS_PATH_TESTE` removida do `.env` do
CT 117 e a pasta `/tmp/fichas-teste-pw` apagada; `config:clear` + `php8.4-fpm` +
`assistente-queue` reiniciados. Suite Pest: **267 testes a passar**, sem alterações de
código (nenhum bug encontrado nesta ronda — o código já estava correto).

## O que fica por fazer em prod (Fase 5)

- Chave de apply de prod: nunca gerada nem instalada — só na Fase 5, com aprovação explícita do utilizador.
- `wkhtmltopdf` não está instalado no CT 107 (nunca tocado, por instrução explícita desta sessão) — necessário antes de `ficha_pdf`/`email` poderem correr em prod.
- Permissões das chaves SSH: em prod, decidir se o worker/php-fpm corre como `www-data` desde o início (evita o problema descoberto aqui) ou se as chaves ficam num caminho dedicado, não em `/home/<user>/.ssh`.
- "Aplicar em PROD" continua sem nenhuma rota ativa — decisão consciente, não um esquecimento.
