# FASE3-RESULTADOS — Webapp (docs/PLANO.md, Fase 3)

## O que foi feito

Autenticação própria (username+password, Argon2id via `config/hashing.php`), layout com barra lateral e tema claro/escuro por utilizador, fila de fichas com filtros e contadores do leitor, registo de casos individual e em lote, página do caso com tabela de campos editável e auditoria, aprovação imutável (manifesto + hashes, alimenta `fichas_fiabilidade`), dry-run contra o wrapper de consulta, rota de documentos protegida (path traversal/symlink/MIME), gestão de utilizadores, página Fiabilidade.

## Rotas

```
GET|POST  /login, /login (entrar), POST /logout, POST /tema
GET       /fichas                                fichas.index
GET       /fichas/novo   POST /fichas/novo        fichas.novo / fichas.registar
GET|POST  /fichas/lote                            fichas.lote
GET       /fichas/{caso}                          fichas.mostrar
POST      /fichas/{caso}/guardar                  fichas.guardar
POST      /fichas/{caso}/aprovar                  fichas.aprovar
POST      /fichas/{caso}/dry-run                  fichas.dry-run
POST      /fichas/{caso}/reler                    fichas.reler
GET       /fichas/documentos/{documento}/pagina/{n} fichas.documento
GET       /utilizadores, /utilizadores/novo, POST .../desativar, .../ativar, .../password
GET       /fiabilidade
```

## Testes

110 testes Pest a passar (0 falhas), incluindo os 76 já existentes da Fase 2 (motor) — a suite continua a passar sem regressões. Novos nesta fase (34):

- `tests/Feature/Auth/LoginTest.php` — login, password errada, utilizador inativo, rate limit 5/min, logout, redirecionamento sem sessão.
- `tests/Feature/Fichas/NovoCasoTest.php` — caso individual válido, NIF inválido, MAI inválido, data inválida, zona fora da lista, duplicado, lote com linhas boas/más, lote recusado com erros.
- `tests/Feature/Fichas/DocumentoRotaTest.php` — autenticação obrigatória, imagem servida com cabeçalhos de segurança, path traversal, symlink, pasta fora da base, MIME disfarçado, documento inexistente.
- `tests/Feature/Fichas/AprovacaoTest.php` — bloqueio com campos por rever, aprovação e manifesto imutável, alimentação de `fichas_fiabilidade`, edição anula aprovação, auditoria de edição, checkbox de cartão desmarcada, dupla aprovação bloqueada.
- `tests/Feature/Fichas/ChecklistTest.php` — checklist Fase A e aviso de campos por resolver.
- `tests/Feature/Fichas/LocalizadorPastaBindingTest.php`, `RetryExtracaoTest.php`, `RedespachoFalhaRecuperavelTest.php` — regressões dos 3 bugs encontrados só pelo teste manual com casos reais (ver secção abaixo e docs/DEPLOY.md §9).

## Como testar no browser

`http://192.168.69.150/` (CT dev), utilizador `rh`, password no `storage/app/.rh-pass` do CT (0600, gerada uma vez por `utilizadores:criar --gerar-password`).

## Teste manual com casos reais

3 casos reais da NAS registados pela **webapp a sério** (sessão HTTP real: login por username+password, CSRF, POST a `/fichas/novo`, não pelo comando `fichas:ler`), com NIF/admissão de `storage/app/validacao-entrada.csv`: MAI 157459 (NIF 241409888, admissão 19/09/2026), MAI 156614 (NIF 275737276, admissão 19/09/2026), MAI 125457 (NIF 253844177, admissão 08/09/2026). Zona/local/horas/alínea/modalidade de teste (Porto/"Teste Fase 3"/40/b)/termo certo — não usados pelo motor de leitura, só pelos cruzamentos). Worker `queue:work` corrido manualmente em background no CT durante o teste (ver docs/DEPLOY.md §6).

**Resultado final dos 3 casos:**

| MAI | id | Estado final | Documentos lidos | Campos | Campos "rever" |
|---|---|---|---|---|---|
| 157459 | 24 | `bloqueado` (sem_reserva) | 8 (RC, 2 cartões PSP, CC frente/verso, morada, IBAN, vigilante) | 11 | 8 |
| 156614 | 25 | `bloqueado` (sem_reserva) | 8 | 12 | 9 |
| 125457 | 26 | `bloqueado` (sem_reserva) | 10 | 12 | 4 |

Os 3 casos **não chegaram a `por_rever`**: o motor leu os documentos corretamente (Vision + Qwen, 8 a 10 ficheiros por caso, classificados por tipo — RC, cartões PSP, CC frente/verso, morada, IBAN — com campos extraídos e a confiança categórica atribuída conforme a política), e o cruzamento `consulta.reserva` (contra a Sabichão de **dev**, 192.168.69.20, nunca prod) não encontrou nenhuma reserva `Reservado` para estes 3 NIFs — resultado correto e esperado: são casos reais admitidos em **prod** (processos 851/850/773), e a base de dev não tem essas reservas específicas sincronizadas. O motor bloqueou exatamente como a máquina de estados manda (`02-MAQUINA-ESTADOS.md`: "sem reserva `Reservado`" é bloqueio funcional, não técnico) — o teste confirma que o cruzamento distingue corretamente "sem reserva" de falha técnica, e que os 3 casos ficam visíveis na fila com o motivo certo, prontos para a RH decidir (criar a reserva em dev, ou aceitar o bloqueio).

Rota de documentos testada com os 3 tipos reais dos ficheiros deste caso: JPEG (`cc frente.jpg`) e PNG (`ard.png`, cartão PSP) servidos diretamente como `image/jpeg`; PDF (`Certificado...pdf`, o RC) rasterizado com `pdftoppm` para JPEG. Os três devolveram `200 OK`, `Content-Type: image/jpeg`, `Cache-Control: no-store, private`, `X-Content-Type-Options: nosniff`, e o ficheiro descarregado é mesmo uma imagem JPEG válida (confirmado com `file`). Páginas `/fichas`, `/utilizadores`, `/fiabilidade` e `/fichas/{caso}` renderizam todas `200 OK` com os dados reais.

**Bugs reais só encontrados por este teste manual** (nenhum dos três aparecia nos 110 testes automatizados, porque nenhum exercitava o caminho completo pela fila com o wrapper e o worker a sério):
1. `storage/`/`bootstrap/cache` sem escrita para `www-data` → qualquer view nova dava 500 mudo, sem log (ver DEPLOY.md §9).
2. `LocalizadorPasta` sem binding no container → o job falhava sempre que corria pela fila (ao contrário do comando síncrono `fichas:ler`), nunca teria funcionado em produção real. Corrigido + teste de regressão.
3. Um retry automático depois de uma falha técnica recuperável colidia com a extração da tentativa anterior (`Duplicate entry` em `fichas_extracoes`), mascarando a falha original. Corrigido + teste de regressão.
4. Nada no sistema redespachava `LerCaso` para um caso devolvido a `registado` por uma falha técnica — ficava parado para sempre apesar de "2 tentativas automáticas" estar descrito no plano. Corrigido (redespacho com atraso de 10s dentro de `tratarFalha`) + 2 testes de regressão.

Não foi aprovado nem aplicado nada — os 3 casos ficam em `bloqueado`, visíveis na fila, para decisão da RH.

## Divisão de trabalho com o Qwen

Ver `docs/FASE3-QWEN.md`.

## Correções do primeiro teste em browser (22/09/2026)

Feedback do utilizador depois do primeiro teste real na página do caso — 6 pontos, todos aplicados:

1. **Botão `[img]` em cada linha de campo** — `fichas_campos` ganhou `documento_id`/`pagina` (migração `2026_09_22_000012`), preenchidos pelo motor de leitura (`LerCaso`) sempre que um campo vem de um documento. Nova secção "Documentos da pasta" na página do caso lista todos os ficheiros lidos (nome, tipo classificado, nº de páginas) com um `[img]` por página (`LerCaso::contarPaginas()`, via `pdfinfo` para PDF).
2. `apelido`/`nome_proprio` deixaram de aparecer na tabela — continuam gravados em `fichas_campos` como auxiliares (usados na confirmação de nome por 2ª fonte), só filtrados na vista.
3. A tabela "Campos" mostra só os campos do manifesto (`FichasController::CAMPOS_MANIFESTO`), pela ordem de `05-GOLDEN-DATASET.md`/relatório da skill; campos técnicos (`mrz_linha2`, etc.) ficam de fora. Um campo do manifesto sem leitura é criado vazio com confiança "rever" ao abrir a página, para o utilizador preencher — e só estes campos (não os técnicos) bloqueiam a aprovação.
4. Datas em dd/mm/aaaa em toda a tabela de campos (valor, leituras Vision/Qwen alternativas) via `App\Support\Datas`; `FichasController::guardar()` converte para aaaa-mm-dd ao gravar, com erro em português (`campos.<campo>`) se a data for inválida.
5. `ncc` passou a gravar só os 8 dígitos do nº de documento (`13253021`, não `13253021 0 ZW2`) — regra confirmada em `01-MANIFESTO.md`. O Luhn continua a correr sobre os 12 caracteres completos para a confiança; o texto "Luhn OK/falhou (nº completo)" fica em `motivo_revisao` e aparece sempre em letra pequena por baixo do valor (não só quando "rever"). `fichas:ler` (CLI) também corrigido — mostrava sempre "Luhn falhou" porque recalculava sobre os 8 dígitos truncados.
6. `filho_de`, `nome`, `naturalidade`, `concelho` e `nacionalidade` normalizados em Title Case com partículas (de/da/do/dos/das/e) em minúsculas — helper `OcrDocs::tituloComParticulas()` partilhado (antes só existia, privado, em `CartaoCidadao`).

**Testes**: 121 Pest a passar (110 + 11 novos/ajustados: `tests/Feature/Fichas/MostrarTest.php` novo, `CartaoCidadaoTest.php` e `ChecklistTest.php` ajustados ao novo comportamento).

**Reprocessados os 3 casos reais** (MAI 157459/156614/125457, `fichas:ler --sem-cruzamentos`) para a tabela refletir as alterações. Confirmado por HTTP (login real, curl) no caso 157459 (id 24): tabela na ordem do manifesto, datas dd/mm/aaaa, `ncc` com 8 dígitos + "Luhn OK (13253021 0 ZW5)" em letra pequena, botão `[img]` em cada linha com documento de origem (a apontar para `fichas.documento`, confirmado a devolver `200 image/jpeg`), secção "Documentos da pasta" com um `[img]` por documento/página, e `apelido`/`nome_proprio`/`mrz_linha2` ausentes da tabela.

## Correções aos extratores (22/09/2026, 4 problemas reais)

Feedback do utilizador sobre 4 bugs reais vistos nos casos MAI 156614/157459
(processos 850/854). Todos corrigidos e confirmados por releitura real
(`fichas:ler <mai> --sem-cruzamentos`):

1. **Cartão PSP fotografado ao contrário (`CartaoPsp`)** — com um só número de
   cartão na página, a categoria e a validade podem vir ANTES do número na
   ordem de leitura (ex. MAI 156614: "Ass. Recinto Desportivo" e "30MAR2028"
   antes de "CARTÃO N.° 156614051"). O bloco de leitura passou a ser a página
   inteira quando há só um número; com vários números continua a ser do
   número até ao seguinte (não há como saber a que cartão pertence o que vem
   antes do primeiro número). Confirmado: 156614051 agora lê ARD, "Ass.
   Recinto Desportivo", 30/03/2028, alta. Também: na tabela da UI
   (`resources/views/fichas/mostrar.blade.php`), quando `categoria_texto` fica
   nula, mostra-se agora a categoria pelo sufixo (`FichaCartao::getCategoriaTextoExibicaoAttribute()`,
   `CartaoPsp::textoPorSufixo()`).
2. **`filho_de` do verso do CC (`CartaoCidadao::filhoDeDoVerso`)** — o regex
   antigo atravessava linhas (`\s` inclui `\n`) e apanhava etiquetas
   estragadas anteriores ("Portugal Carzen Deridadão..."). Regra nova: o
   bloco dos pais começa na linha que contém o separador (`*`, `•`, `·`,
   `●`) — linhas anteriores nunca entram —, o pai é o texto antes do
   separador NESSA linha, e a mãe estende-se pelas linhas seguintes até à
   1.ª linha com dígitos. Sem separador, `filho_de` fica ausente (nunca
   lixo). Confirmado: MAI 156614 → "Bruno Manuel Pacheco Teixeira e Patrícia
   Alexandra da Rocha Almeida"; MAI 157459 → "Laurentino da Silva Machado e
   Maria Helena de Lima Maia Machado".
3. **Finalidade do RC tolerante ao ruído do OCR (`OcrDocs::finalidadeRcCorreta`)**
   — a comparação exigia a frase exata "atividade de segurança privada"
   contígua; com ruído real ("CATIVIDADE DES SEGURANÇA" em vez de "ATIVIDADE
   DE SEGURANÇA") falhava. Passou a aceitar também: "EXERC" + "ATIVI"/"TIVIDADE"
   + "SEGURANCA" + "PRIVADA" por esta ordem numa janela de 80 caracteres, ou
   distância de Levenshtein ≤ 25% sobre a frase-alvo completa. O bloqueio
   `rc_caducado` continua correto e a bloquear (RegistoCriminal::extrair()) —
   mudou é o texto do motivo: `motivo_revisao` passou a frase em PT legível
   ("Certificado caducado em 19/07/2026", "Finalidade não confirmada
   (esperado: exercício da atividade de segurança privada)") em vez dos
   códigos internos concatenados (`rc_caducado`, mantidos só internamente em
   `_bloqueios`, não persistidos). A tabela de campos já mostrava
   `motivo_revisao` em letra pequena por baixo do valor (ponto 5 da secção
   anterior) — não foi preciso alterar a blade para isto. Confirmado: MAI
   157459 → "Certificado caducado em 05/07/2026"; MAI 156614 → "Certificado
   caducado em 19/07/2026" (finalidade reconhecida nos dois, apesar do ruído).
4. **Nascimento não lido quando a etiqueta fica longe da data (`CartaoCidadao::extrairFrente`)**
   — real: "...NASCIMENTO | SEX | HEIGHT | NATIONALITY | OATE OF BIRTH | F |
   1,61 | PRT | 16 01 1987 | ..." tinha ruído a mais entre a etiqueta e a
   data para a janela antiga de 20 caracteres. Fallback novo: quando a
   etiqueta não está perto o suficiente, usa a 1.ª data `dd mm aaaa` (ou
   `dd-mm-aaaa` no digital) da frente cujo ano é plausível para um
   nascimento (≥ 16 anos, ≥ 1900). Também: `extrairVerso()` passou a ler o
   nascimento da MRZ (linha 2, posições 0-5, AAMMDD, século por
   `AA > ano atual - 2000 → 19AA senão 20AA`) como `nascimento_mrz` —
   `LerCaso::processarCcVerso()` confirma contra o valor da frente e sobe a
   confiança para "alta" quando concordam (nunca troca o valor). Confirmado:
   MAI 157459 → nascimento 16/01/1987, confiança **alta** (confirmado pela
   MRZ do verso, 8701163F...); MAI 156614 → nascimento 30/05/2002, confiança
   **alta** (MRZ 0205300F2706111PRT).

**Reprocessados os 3 casos reais** (`fichas:ler --sem-cruzamentos`, MAI
157459 id 24, 156614 id 25, 125457 id 26) para confirmar — extrato relevante:

| MAI | Cartão (nº) | filho_de | val_rc (motivo) | nascimento (confiança) |
|---|---|---|---|---|
| 157459 | 157459051 ARD, 16/06/2028, alta | Laurentino da Silva Machado e Maria Helena de Lima Maia Machado | 2026-07-05 — "Certificado caducado em 05/07/2026" | 16/01/1987 — alta |
| 156614 | 156614051 ARD, 30/03/2028, alta | Bruno Manuel Pacheco Teixeira e Patrícia Alexandra da Rocha Almeida | 2026-07-19 — "Certificado caducado em 19/07/2026" | 30/05/2002 — alta |
| 125457 | 125457051 ARD, 10/05/2027, alta (caso de controlo, sem os 4 bugs) | — (sem separador no verso deste caso) | 2026-11-18 — sem bloqueio (não caducado) | 29/07/1989 — rever (regra do proc 773 mantida: nunca confunde com a validade) |

**Testes**: 132 Pest Unit+Feature a passar no CT (85 Unit + 47 Feature/outros;
17 novos/ajustados para os 4 problemas: 1 em `CartaoPspTest.php`, 5 em
`CartaoCidadaoTest.php`, 3 em `RegistoCriminalTest.php`). Uma falha isolada
em `tests/Feature/Fichas/DocumentoRotaTest.php` (constraint única em
`fichas_extracoes`) é de WIP de outro agente no painel de imagem, não
relacionada com estas correções — fora do âmbito deste trabalho.

## Qwen como segunda leitura (22/09/2026)

Terminado o WIP de "Qwen como segunda leitura" no motor (`App\Services\Fichas\SegundaLeituraQwen`,
ligado em `App\Jobs\LerCaso`), conforme a política de confiança por campo do
`PLANO.md`: rc/morada/iban vão sempre ao Qwen (`LeitorClient::analisarDocumento`
com o schema do tipo); qualquer campo do manifesto vazio/"rever" com documento
candidato (CC para nome/nascimento/ncc/validade_cc/filho_de/niss, cartão PSP sem
validade/categoria) tem recurso automático, uma chamada por documento+tipo
(cache na execução); Vision=Qwen normalizado → alta; só Qwen → rever "Só lido
pelo Qwen: confirmar"; divergem → rever "Vision e Qwen divergem" (os dois
valores disponíveis para os botões "usar"); em morada/nome/naturalidade uma
divergência literal passa antes pelo comparador semântico (`/v1/comparar`) e,
se equivalente, sobe a alta com o motivo "equivalente segundo o modelo";
falha do Qwen nunca bloqueia (campo "rever"/"Qwen indisponível" + evento no
caso); modelo do Qwen gravado em `fichas_extracoes.modelo_qwen` a partir de
`/v1/saude` (`quantizacao` fica sempre `null` — o `/v1/saude` real do
leitor-mini, `leitor-mini/app.py`, só devolve `modelo`, sem quantização
separada; o modelo carregado é fixo, `qwen/qwen3.8-27b` MLX 4bit, ver
`reference_mac_mini_lmstudio`); tempos por documento no log (`duracao_ms`,
sem conteúdo).

Corrigido no WIP encontrado: `concelho` do RC era lido pelo Vision e existe no
schema do Qwen (`leitor-mini/schemas/rc.json`) mas não estava em
`SegundaLeituraQwen::MAPA_CAMPOS`/`CANDIDATOS_POR_CAMPO` — ficava sempre sem
segunda leitura. Adicionado (confirmado no caso 125457 abaixo: `concelho`
"Vila Nova de Gaia" chega a alta com a 2.ª leitura).

**Testes**: 12 novos em `tests/Feature/Fichas/SegundaLeituraQwenTest.php`
(`Http::fake`, nunca contra o mini real) — o algoritmo de mistura
(`combinarValorQwen`/`marcarQwenIndisponivel`) e `aplicarSegundaLeitura` de
ponta a ponta: recurso automático com cache por documento+tipo (uma só
chamada HTTP para 2 campos do mesmo documento RC), campo já em alta nunca
reaberto ao Qwen, falha do Qwen não bloqueia (evento + motivo), cartão PSP
sem validade completado e marcado rever, cartão completo não vai ao Qwen.
**145 testes Pest a passar no CT (0 falhas)**, suite completa.

### Releitura dos 3 casos reais — antes (só Vision) vs depois (com Qwen)

`fichas:ler <mai> --sem-cruzamentos`, casos 24/25/26 reutilizados (sem
cruzamentos ao Sabichão). "Antes" corrido com o código do motor tal como
estava no commit `5f1c2f8` (sem nenhuma ligação ao Qwen, o mesmo leitor
Vision) — reposto o código atual a seguir, confirmado por `diff` contra a
fonte no Mac e suite completa a passar. Contagem só dos campos do manifesto
(`FichasController::CAMPOS_MANIFESTO`; exclui `apelido`/`nome_proprio`/
`mrz_linha2`, auxiliares técnicos não mostrados na tabela da app).

| MAI | Antes: alta / rever (campos) | Depois: alta / rever (campos) | Tempo (depois) | Campos que mudaram |
|---|---|---|---|---|
| 157459 | 4 alta / 6 rever (10; `morada` ausente) | 8 alta / 3 rever (11) | ~40 s | `val_rc`, `naturalidade`, `nacionalidade`, `validade_cc` sobem a alta (Qwen confirma); `morada` passa a existir (só Qwen, rever) |
| 156614 | 4 alta / 6 rever (10; `morada` ausente) | 9 alta / 2 rever (11) | ~33 s | `val_rc`, `naturalidade`, `nacionalidade`, `validade_cc`, `niss` sobem a alta (Qwen confirma); `morada` passa a existir (só Qwen, rever) |
| 125457 | 8 alta / 3 rever (11; `niss` ausente) | 8 alta / 4 rever (12) | ~40 s (variou 40 s–2m18s entre corridas, carga do mini) | `validade_cc`, `nascimento` sobem a alta (Qwen confirma); `concelho` confirmado alta (fix do mapeamento); `niss` passa a existir (só Qwen, rever); `naturalidade` e `iban` DESCEM para rever — Qwen divergiu de "Eca Sandim" (naturalidade, provável erro de OCR do Vision) e de um dígito do IBAN, ambos corretamente sinalizados para revisão humana em vez de ficarem "alta" errados |

Nenhum caso chegou a `por_rever` → aprovável (todos ficam `por_rever`, como
seria de esperar — bloqueios funcionais como `sem_reserva` foram saltados por
`--sem-cruzamentos`). O ponto relevante desta releitura é o efeito da 2.ª
leitura na confiança por campo: sobe campos confirmados por duas fontes
independentes, recupera campos que o Vision não tinha lido de todo
(`morada`, `niss`), e **desce** dois campos que o Vision tinha marcado "alta"
por engano (`naturalidade` do 125457, `iban` do 125457) — exatamente o
comportamento pretendido pela política de confiança (zero valores errados
com confiança alta).

## Por fazer / decisões da Fase 4 em diante

- "Aplicar em DEV/PROD" e envio de fichas por email ficam desativados na interface — só a Fase 4 liga o wrapper de apply.
- `systemd` do worker não instalado (sem sudo no CT de dev); unit file pronto em `deploy/assistente-queue.service`.
- Permissões de `storage/`/`bootstrap/cache` para `www-data` resolvidas com `chmod o+w` (dev); decidir em prod se é `www-data` no grupo do deploy ou outra estratégia.
