# Fase 2 — Resultados (motor de leitura de documentos)

Data: 2026-09-22. Autor da execução: Claude (Sonnet), sessão autónoma. Ambiente:
CT 117 `assistente-dev` (192.168.69.150, Laravel 12, PHP 8.4.24, MySQL), fonte
de desenvolvimento no Mac em `/Volumes/www/assistente/app/`.

## 1. O que foi feito

### 1.1 Modelo de dados
- 10 migrações novas + 1 alteração à tabela `users` (coluna `tema`):
  `fichas_casos`, `fichas_extracoes`, `fichas_documentos`, `fichas_cartoes`,
  `fichas_campos`, `fichas_aprovacoes`, `fichas_execucoes`, `fichas_eventos`,
  `fichas_fiabilidade`. MAI `char(6)`, NIF `char(9)`, datas em `date` na BD.
- Modelos Eloquent para todas as tabelas, com relações e a máquina de estados
  de `fichas_casos` implementada em `FichaCaso::TRANSICOES` +
  `transitarPara()`/`podeTransitarPara()` (valida e regista evento de
  auditoria a cada transição; lança exceção em transição inválida).
- `App\Support\Datas`: `paraBd()`/`paraUi()`/`valida()`, aceitando
  dd/mm/aaaa, dd-mm-aaaa e ddmmaaaa na entrada.

### 1.2 `App\Support\OcrDocs` — cópia versionada de `funcoes/ocr_docs.php`
Lido em 2026-09-22 via `ssh claude@192.168.69.20 "sudo -n cat
/var/www/develop/funcoes/ocr_docs.php"` (o utilizador `claude` está no grupo
`sudo` nesse CT e tem uma regra passwordless — não foi preciso pedir a
password). sha256 do ficheiro original nessa data:
`e15bfb551a3f30bd2db049ba22bf08dd969042b25e7c50a3153b2294bab8d9db`.

Portadas: `ocr_nif_valido`, `ocr_niss_valido`, `ocr_iban_valido`,
`ocr_ncc_luhn_valido`, `ocr_distrito_de_concelho`, `ocr_parse_rc` e os
auxiliares de que dependem. Durante os testes reais (ver §3) o Luhn do CC
inicialmente portado estava **errado** — tinha "melhorado" o algoritmo da
fonte tratando dígitos de letra (10-35) como dois algarismos separados; a
fonte trata cada carácter como um valor único (podendo ir até 35) e só depois
duplica/ajusta. Corrigido para replicar a fonte literalmente; o exemplo real
do SKILL.md (`301546630ZW7` válido) passou a confirmar depois da correção.

### 1.3 `App\Services\Leitor\LeitorClient`
Cliente HTTP dos 4 endpoints de `06-CONTRATO-LEITOR-MINI.md`
(`/v1/ocr`, `/v1/analisar-documento`, `/v1/comparar`, `/v1/saude`).
`verify=false` só para `10.0.10.231:8090` (certificado autoassinado da rede
interna), documentado em docblock. Token e URL vêm de `config('services.leitor')`
← `.env` (`LEITOR_URL`, `LEITOR_TOKEN`, `LEITOR_TIMEOUT`).

### 1.4 `App\Services\Fichas\*`
- `LocalizadorPasta`: acha `<MAI> <Nome>/Proc <n>` mais alto, ignora
  `Arquivo`/`old`/`._*`/`Thumbs.db`/`.msg`; distingue `nao_encontrada` /
  `ambigua` (→ bloqueado) de `sem_acesso` (→ falha técnica, não consome
  tentativa extra).
- `Classificador`: cartao_psp / cc_frente / cc_verso / cc_digital / rc /
  morada / iban / outro, por regras sobre o texto do Vision.
- `Extratores\CartaoPsp`, `CartaoCidadao`, `RegistoCriminal`, `Morada`,
  `Iban` — replicam as regras validadas em `ocr-mini/testar_cartoes.py` e
  `RESULTADOS-2026-09-22.md` (ver §3 para os casos de teste reais).
- `PoliticaConfianca`: confiança categórica (alta|rever) por campo, conforme
  `04-POLITICA-CONFIANCA.md` e a decisão 9 do `07-DECISOES-FASE0.md` (sem
  limiar numérico).
- `Cruzamentos`: chama o wrapper SSH (`consulta.zonas`, `consulta.reserva`,
  `consulta.funcionario`, `consulta.portal_lookup`, `dry_run`, `verify`) via
  `Illuminate\Support\Facades\Process`. **Nunca chama `apply`/`ficha_pdf`/
  `email`** (fora do âmbito desta fase, exigem a chave de apply).
- `GeradorManifesto`: monta o manifesto a partir dos campos/cartões do caso
  (decisão 8: opcionais em `""`, `nhoras` em `null`) e valida-o contra
  `01-MANIFESTO-SCHEMA.json` com `opis/json-schema`.

### 1.5 Job + comandos
- `App\Jobs\LerCaso` (fila `database`, `tries=1` — as "2 tentativas" da
  decisão 10 são geridas pelo próprio job via `fichas_casos.tentativas`,
  voltando a `registado` até à 3ª falha, que vai a `erro_tecnico`).
  Heartbeat (`lease_heartbeat`) atualizado a cada documento processado.
  Segue exatamente as transições de `02-MAQUINA-ESTADOS.md`:
  `registado → em_leitura → bloqueado|por_rever|erro_tecnico`.
- `php artisan fichas:ler {mai} [--nif=] [--admissao=]` — cria/reutiliza o
  caso, corre o job de forma síncrona, imprime a tabela Campo|Valor|Origem|
  Confiança e a tabela de cartões.
- `php artisan fichas:validar {csv} [--saida=]` — corre o motor sobre uma
  lista de processos e escreve o CSV do conjunto de validação nas colunas
  exatas de `05-GOLDEN-DATASET.md`
  (`id_processo,mai,campo,valor_sabichao,valor_ocr,valor_verdade,estado,
  fonte_documento,notas`), com a regra de classificação automática §1.1
  desse documento (`auto`/`a_confirmar`/`sem_fonte`).

### 1.6 Testes Pest (50 testes, ver §2 para os números reais)
- `tests/Unit/Support/OcrDocsChecksumsTest.php` — NIF, NISS, IBAN PT,
  Luhn do CC (13 testes).
- `tests/Unit/Support/DatasTest.php` — os 3 formatos de entrada + rejeição
  de datas impossíveis (8 testes).
- `tests/Unit/Fichas/CartaoPspTest.php` — as confusões de OCR reais
  documentadas (`10MAIZ027`→10/05/2027, `13AG02031`→13/08/2031), conflito
  sufixo×categoria impressa, número ilegível, dois cartões na mesma imagem
  (6 testes).
- `tests/Unit/Fichas/CartaoCidadaoTest.php` — CC físico (dd mm aaaa) vs
  digital (dd-mm-aaaa), MRZ a confirmar a validade da frente, NISS por
  âncora do NIF, `filho_de` com " e " (7 testes).
- `tests/Unit/Fichas/RegistoCriminalTest.php` — "CÓDIGO VIGENTE ATÉ" vs
  emissão, bloqueio por finalidade errada, bloqueio por RC caducado,
  naturalidade="Portugal" no formato novo, distrito derivado do concelho
  (nunca da COMARCA) (5 testes).
- `tests/Unit/Fichas/MoradaTest.php` — normalização ao padrão da casa com o
  exemplo real do SKILL.md, abreviaturas R/Av/Lg (5 testes).
- `tests/Unit/Fichas/IbanTest.php` — IBAN PT com mod-97, estrangeiro sem
  checksum automático, IBAN mascarado nunca aceite (3 testes).
- `tests/Feature/Fichas/FichasLerCommandTest.php` — integração do comando
  `fichas:ler` contra o leitor real do mini, com **skip automático** quando
  `/v1/saude` não responde `ok:true` (ver §4 — saltado nesta corrida porque
  o token do `.env` de dev ainda é um placeholder, Fase 1 não fechada).
- Fixtures sintéticas em `tests/fixtures/processos/999001 Nome Teste
  Vigilante/Proc 1/` (`vig.jpg`, `cc_frente.jpg`), geradas com ImageMagick
  no Mac — texto tipo "CARTÃO N.º 999001021 VALIDADE 13AGO2031", NIF/MAI
  claramente sintéticos, nunca um processo real.

## 2. Resultados reais dos testes (corridos de facto no CT 117)

```
composer install / composer update --no-interaction   → OK (ver §4.1 sobre a versão do Pest)
php artisan migrate --force                             → OK, 10 migrações aplicadas
./vendor/bin/pest                                        → Tests: 1 skipped, 49 passed (72 assertions)
                                                            Duration: 1.73s
```

Nenhum teste falhou. O único não executado (skip) é o de integração com o
leitor real — ver §4 para o motivo exato (não é uma falha do código).

Durante a escrita dos testes, correr os casos manualmente contra o PHP real
(antes de os formalizar em Pest) apanhou 3 bugs reais, todos corrigidos antes
do commit final:
1. **Luhn do CC** — ver §1.2 (algoritmo "melhorado" incorretamente).
2. **Datas do RC interpretadas como mm/dd** — `OcrDocs::parseRc` devolve
   `dd/mm/aaaa` (fiel à fonte); `RegistoCriminal::extrair` construía um
   `DateTimeImmutable` diretamente a partir disso, e o construtor do PHP
   interpreta `"10/12/2030"` com `/` como **mês/dia/ano** (formato
   americano) — `10/12/2030` virava 12 de outubro em vez de 10 de dezembro.
   Corrigido convertendo sempre para ISO com `OcrDocs::dataParaIso()` antes
   de construir a data (e o mesmo em `LerCaso::processarRc` antes de gravar
   `val_rc` no campo/BD/manifesto, que exige aaaa-mm-dd).
3. **Normalização de morada** apanhava "N" de palavras normais como "Nuno"
   (`R Dr Nuno...` → `Rua Dr nºuno...`) porque o regex de "N.º" não exigia
   que a seguir viesse um dígito. Corrigido com lookahead `(?=\d)`. Também
   sobrava uma vírgula antes de "nº" vinda do documento original
   (`Rua X, nº82` em vez de `Rua X nº82`) — corrigido.
   Datas dos cartões PSP com separador `/` (`10/05/2027`, já bem lidos, sem
   confusão de OCR) não eram reconhecidas pelo parser (que só tinha o
   padrão colado tipo `10MAI2027`) — adicionado um primeiro padrão
   `dd/mm/aaaa` explícito em `CartaoPsp::primeiraDataPsp`.

## 2a. Correção da ordem ler→cruzar (22/09/2026)

**Problema observado no CT (192.168.69.150):**
`FICHAS_PROCESSOS_PATH=/var/www/assistente/tests/fixtures/processos php
artisan fichas:ler 999001 --nif=999999990 --admissao=01/10/2026` terminava em
`bloqueado (sem_reserva)` com as tabelas de campos e cartões vazias. A leitura
em `LerCaso::lerCasoDeFacto` já gravava documentos/campos/cartões antes do
bloco de cruzamentos (a ordem descrita no PLANO.md já estava correta no
código), mas dois problemas concretos escondiam isso:

1. O bloco de cruzamentos (`consulta.reserva`/`consulta.funcionario`) engolia
   qualquer `Throwable` do wrapper SSH num `try/catch` que só fazia
   `report($e)` e deixava o caso seguir como se nada tivesse falhado — uma
   falha técnica do wrapper (timeout, SSH indisponível) nunca aparecia como
   tal, e não havia forma de testar o motor sem o wrapper.
2. As fixtures sintéticas (`vig.jpg`, `cc_frente.jpg`) são imagens JPEG
   idênticas/dummy sem texto OCR real, pelo que o classificador devolve
   sempre `outro` para ambas — as tabelas ficam vazias mesmo com a ordem
   certa e mesmo sem bloqueio, porque não há nada para classificar (não é um
   bug do motor).

**Alterações:**
- `app/Jobs/LerCaso.php` — removido o `try/catch` que engolia falhas do
  wrapper no passo 3 (cruzamentos); uma exceção do wrapper propaga agora até
  ao `catch` de `handle()`, que aplica a política de retry existente
  (decisão 10: 2 tentativas → volta a `registado`; 3ª falha → `erro_tecnico`)
  — nunca mais fica `bloqueado` por uma falha técnica. O passo de
  cruzamentos passou a condicional a `! $this->semCruzamentos`.
- `LerCaso::__construct` ganhou o parâmetro `bool $semCruzamentos = false`.
- `app/Console/Commands/FichasLer.php` — nova opção `--sem-cruzamentos`
  (salta `consulta.reserva`/`consulta.funcionario`, para testar o motor de
  leitura isoladamente do Sabichão) e `--zona=` opcional (só usada ao criar
  um caso novo). As duas tabelas (`Campo|Valor|Origem|Confiança` e
  `Nº cartão|Categoria|Validade|Confiança`) já eram sempre impressas no fim,
  independentemente do estado final — confirmado nos testes abaixo.

**Testes reais no CT (192.168.69.150), output colado:**

Com cruzamentos (wrapper real de dev, `192.168.69.20`) — bloqueia por falta
de reserva, mas os documentos já foram lidos e gravados antes do bloqueio
(confirmado por tinker: `docs=2` para o caso):
```
$ FICHAS_PROCESSOS_PATH=/var/www/assistente/tests/fixtures/processos php artisan fichas:ler 999001 --nif=999999990 --admissao=01/10/2026
Caso #2 criado (MAI 999001).
Estado final: bloqueado (sem_reserva)
+-------+-------+--------+-----------+
| Campo | Valor | Origem | Confiança |
+-------+-------+--------+-----------+
+-----------+-----------+----------+-----------+
| Nº cartão | Categoria | Validade | Confiança |
+-----------+-----------+----------+-----------+
```
(tabelas vazias por causa das fixtures dummy sem texto OCR real, não por
ordem errada — confirmado com `docs()->count() === 2` e `tipo === 'outro'`
para os dois ficheiros).

Com `--sem-cruzamentos` (mesmas fixtures, sem depender do wrapper) — o caso
chega a `por_rever` em vez de ficar preso no cruzamento:
```
$ FICHAS_PROCESSOS_PATH=/var/www/assistente/tests/fixtures/processos php artisan fichas:ler 999002 --nif=999999991 --admissao=01/10/2026 --sem-cruzamentos --zona=Porto
Caso #3 criado (MAI 999002).
--sem-cruzamentos: a saltar consulta.reserva/consulta.funcionario (wrapper do Sabichão).
Estado final: por_rever
+-------+-------+--------+-----------+
| Campo | Valor | Origem | Confiança |
+-------+-------+--------+-----------+
+-----------+-----------+----------+-----------+
| Nº cartão | Categoria | Validade | Confiança |
+-----------+-----------+----------+-----------+
```

Falha técnica do wrapper (`WRAPPER_HOST` inatingível) — nunca fica
`bloqueado`, entra na política de retry e só ao fim de 3 tentativas passa a
`erro_tecnico`:
```
$ FICHAS_PROCESSOS_PATH=... WRAPPER_HOST=10.255.255.1 WRAPPER_TIMEOUT=3 php artisan fichas:ler 999003 --nif=999999992 --admissao=01/10/2026
Estado final: registado          # tentativa 1/3
Estado final: registado          # tentativa 2/3
Estado final: registado          # tentativa 3/3 (última chamada, ver estado a seguir)
$ php artisan tinker --execute="echo App\Models\FichaCaso::find(4)->estado;"
erro_tecnico
```

Os casos de teste (#2, #3, #4) e as pastas de fixtures copiadas para 999002/
999003 foram removidos do CT no fim (`FichaCaso::whereIn('id',[2,3,4])->delete()`).

`./vendor/bin/pest` no CT continua verde (ver `FichasLerCommandTest`, que já
aceita `por_rever`/`bloqueado`/`erro_tecnico` como estados finais válidos):
```
Tests:    50 passed (75 assertions)
Duration: 2.37s
```

## 2b. Afinações CC/nome/duplicados (22/09/2026)

**Problema observado no CT** depois de correr `fichas:ler` sobre o caso
999001 (leitor real do mini): o cartão PSP (999001021 VIG 13/08/2031) saía
correto, mas `nascimento`, `ncc` e `validade_cc` saíam sempre `rever`, o
`nome` não aparecia, as datas da tabela vinham em `aaaa-mm-dd`, e reler o
mesmo caso duplicava as linhas de `fichas_cartoes` (a cada `fichas:ler`
repetido, o número de cartões crescia em vez de se manter).

**Alterações:**

1. **Sem duplicados ao reler** — `App\Jobs\LerCaso::lerCasoDeFacto` apaga
   agora `fichas_cartoes`/`fichas_campos`/`fichas_documentos` do caso no
   arranque de cada extração (antes de criar a nova `FichaExtracao` e ler os
   ficheiros), ficando só a extração corrente visível. `fichas_aprovacoes`
   não é tocada aqui — continua a ser invalidada pela deteção de mudança de
   hash já descrita no doc 02 (referencia hashes, não FK a estas tabelas).
2. **Nº de CC completo (`ncc`)** — `CartaoCidadao::extrairFrente` passou a
   capturar os 12 caracteres (`8 dígitos + dígito de controlo + 2 letras de
   versão + dígito`), normalizando o "O" que o Vision por vezes lê no lugar
   do dígito de controlo `0` (ex. `11224033 OZX2` → `112240330ZX2`). Corre
   `OcrDocs::nccLuhnValido()` sobre os 12 chars: Luhn OK → confiança `alta`;
   falha → `rever` com motivo. `fichas:ler` mostra `<ncc> (Luhn OK)` /
   `(Luhn falhou)` na tabela.
3. **Nome** — nova extração de `apelido`/`nome_proprio`/`nome`:
   - Digital (app gov.pt): âncoras `Apelido(s)`/`Nome(s)` — o valor já vem
     com acentos e caixa certa na linha seguinte → confiança `alta`.
   - Físico: varrimento de linhas até à âncora "nascimento/birth" (a mais
     estável mesmo com etiquetas estragadas — ex. real `REPDELICA
     PORTUGUESA`), filtrando linhas-etiqueta (lista PT/EN + tolerância a
     etiquetas garbled por semelhança/substring) e ficando com as duas
     últimas linhas de conteúdo que sobram (apelido, nome) → confiança
     `rever`, motivo "sem acentos: confirmar com o portal/RC" (o Vision
     perde acentos no físico).
   - Caso adversarial conhecido: na fixture sintética 999001 o valor do
     "nome" do especime é literalmente a palavra `NOME` (igual à etiqueta
     `NOME(S)`/`NAME` da lista) — fica ambíguo por desenho e o motor só
     confirma o `apelido` (`Teste Vigilante`), sem montar `nome` completo;
     não é regressão, é a mesma ambiguidade que existiria para um humano a
     ler a mesma imagem-especime.
4. **Datas na tabela** — `fichas:ler` passou a converter `nascimento`,
   `validade_cc`, `val_rc`, `admissao`, `data_consulta` para `dd/mm/aaaa`
   (`Datas::paraUi`) na tabela `Campo|Valor|Origem|Confiança`; a BD continua
   `aaaa-mm-dd`.

**Testes Pest novos** (`tests/Unit/Fichas/CartaoCidadaoTest.php`), usando os
três textos reais fornecidos como fixtures (sintético, físico garbled,
digital): ncc completo + Luhn OK/falha nos três formatos, nome completo no
digital (`alta`), apelido+nome no físico garbled (`rever` + motivo), e o
caso adversarial do `NOME` sintético (só apelido, sem `nome`). Mais um teste
de integração em `FichasLerCommandTest` que corre `fichas:ler` duas vezes
sobre o mesmo caso e confirma que a contagem de documentos/cartões/campos
não duplica.

**Output real do CT (192.168.69.150), leitor real do mini:**
```
$ FICHAS_PROCESSOS_PATH=/var/www/assistente/tests/fixtures/processos php artisan fichas:ler 999001 --sem-cruzamentos --nif=999999990 --admissao=01/10/2026 --zona=Porto
Caso #3 criado (MAI 999001).
--sem-cruzamentos: a saltar consulta.reserva/consulta.funcionario (wrapper do Sabichão).
Estado final: por_rever
+-------------+------------------------+--------+-----------+
| Campo       | Valor                  | Origem | Confiança |
+-------------+------------------------+--------+-----------+
| apelido     | Teste Vigilante        | vision | rever     |
| nascimento  | 02/09/1980             | vision | rever     |
| ncc         | 116509589ZX0 (Luhn OK) | vision | alta      |
| validade_cc | 29/11/2028             | vision | rever     |
+-------------+------------------------+--------+-----------+
+-----------+-----------+------------+-----------+
| Nº cartão | Categoria | Validade   | Confiança |
+-----------+-----------+------------+-----------+
| 999001021 | VIG       | 13/08/2031 | alta      |
+-----------+-----------+------------+-----------+
```
Reler o mesmo caso (`fichas:ler 999001 --sem-cruzamentos`, sem `--nif`) dá
exatamente a mesma tabela, sem duplicar a linha do cartão nem os campos.

`php artisan test` no CT, verde:
```
Tests:    57 passed (98 assertions)
Duration: 3.96s
```

## 3. Casos de teste validados contra as regras reais da skill

| Regra | Entrada de teste | Resultado |
|---|---|---|
| NIF mod-11 | `210902540` (exemplo real citado na fonte) | válido |
| NISS pesos 29,23,19,17,13,11,7,5,3,2 | `11076455844` (exemplo real citado na fonte) | válido |
| IBAN PT mod-97 | `PT50 0033 0000 4576 8592 3270 5` (exemplo do SKILL.md) | válido |
| Luhn do CC | `301546630ZW7` (exemplo do SKILL.md) | válido |
| Confusão OCR "Z"→"2" | `10MAIZ027` | `2027-05-10` |
| Confusão OCR mês colado | `13AG02031` | `2031-08-13` |
| Morada "N.º"/"/" | `Travessa Rua de Roupar, No. 82 / 4620-225 Lodares` (exemplo do SKILL.md) | `Travessa Rua de Roupar nº82, 4620-225 Lodares` |
| RC "CÓDIGO VIGENTE ATÉ" vs emissão | Certificado sintético com as duas datas | usa só a "vigente até" |
| RC finalidade errada | "OUTRA FINALIDADE" | bloqueado |
| RC caducado | vigente até no passado | bloqueado (pergunta-se sempre) |
| Distrito por concelho, não por COMARCA | "COMARCA DE LISBOA OESTE" + concelho "Setúbal" | distrito = Setúbal |

## 4. O que não foi possível testar, e porquê

### 4.1 Instalação real de dependências
`composer.json` pedia inicialmente `pestphp/pest ^3.7`, que exige
`phpunit/phpunit ^11`, em conflito com o `^12.5.12` já fixado no esqueleto
do CT 117. Resolvido subindo para `pestphp/pest ^4.0` (compatível com
PHPUnit 12) — `composer update` correu sem mais conflitos. `composer.lock`
copiado de volta do CT para o repo no Mac (não gerado localmente porque o
Mac só tem PHP 8.2, abaixo do `^8.3` exigido pelo `composer.json`).

### 4.2 `pdo_sqlite` não está instalado no CT 117
O esqueleto do Laravel vem configurado para testar com sqlite em memória
(`phpunit.xml`), mas o módulo `pdo_sqlite` não está instalado no CT e o
utilizador `claude` não tem `sudo` nesse CT (ao contrário do CT 100 de
dev do Sabichão) — não foi possível `apt-get install php8.4-sqlite3`.
**Decisão tomada**: os testes correm contra a BD MySQL `assistente` já
configurada no `.env` (mesma BD das migrações), com `RefreshDatabase` a
migrar/limpar a cada execução — aceitável em dev porque a BD está vazia e
nenhum dado de funcionário real lá entra (nenhum teste escreve num
funcionário real; os únicos dados são os dos próprios testes, sintéticos).
**Pendente**: pedir a alguém com acesso root no CT 117 para instalar
`php8.4-sqlite3` e voltar ao sqlite em memória (mais rápido, isola melhor).

### 4.3 Teste de integração `fichas:ler` contra o leitor real — SALTADO
O serviço no mini respondeu (`https://10.0.10.231:8090/v1/saude` deu
`{"detail":"token em falta"}`, ou seja, o serviço está de facto no ar), mas
o `.env` do CT 117 ainda tem `LEITOR_TOKEN=changeme-fase1` (placeholder
desta sessão — o token real gerado em `~/leitor/.env` no mini, conforme o
README, não foi copiado para aqui porque não faz parte do âmbito da Fase 2
e não deve ficar num ficheiro versionável). Com o token errado,
`LeitorClient::disponivel()` devolve `false` e o teste salta-se
automaticamente, como pedido. **Para correr a sério**: copiar o token de
`~/leitor/.env` (mini) para `LEITOR_TOKEN` no `.env` do CT 117 e voltar a
correr `./vendor/bin/pest --group=integracao-leitor`.

### 4.4 NAS ainda não montada
`/mnt/processos` está vazio no CT 117 (confirmado no PLANO.md, Fase 1 por
fechar). O motor aceita o caminho base via `FICHAS_PROCESSOS_PATH` no
`.env` (default `/mnt/processos`); `LocalizadorPasta` devolve
`status=sem_acesso` de forma limpa quando a pasta não existe, tratado como
falha técnica recuperável pelo `LerCaso` (não consome as 2 tentativas —
na prática vai consumir porque a exceção é lançada e apanhada por
`tratarFalha`; **nota**: isto é uma simplificação aceitável para a Fase 2,
mas convém revisitar na Fase 3/4 para distinguir explicitamente "NAS
desmontada" de outras falhas técnicas, de forma a não gastar tentativas
por um problema de infraestrutura que não depende do caso).

### 4.5 `fichas:validar` sobre os 40 processos reais (770-851)
Não foi corrido a sério porque precisa dos dois pré-requisitos acima em
simultâneo: NAS montada (para localizar as pastas) e wrapper com a chave de
consulta de dev instalada e a apontar para uma BD com esses processos
reais. O comando está implementado e pronto (`php artisan fichas:validar
{csv}`), só falta o ambiente da Fase 1.

### 4.6 Wrapper SSH — não testado a partir desta sessão
`Cruzamentos` chama o wrapper (`docs/03-CONTRATO-WRAPPER-CT107.md`) via
`ssh -i <chave_consulta> assistente@192.168.69.20`. A chave
`assistente_consulta` fica no **CT 117** (`~/.ssh/assistente_consulta`,
utilizador `claude`), não no Mac — confirmado no `wrapper-ct107/README.md`
("Testes reais feitos a partir do CT 117"). Esta sessão correu a partir do
Mac (sem essa chave) e do CT 117 só até `composer`/`migrate`/`pest`; não
tentei repetir os testes do wrapper que já estão documentados como feitos
com sucesso em `wrapper-ct107/README.md` — o código de `Cruzamentos` segue
exatamente esse contrato (mesmas ações, mesmo payload/stdin) mas não foi
exercitado de ponta a ponta pelo `LerCaso` desta sessão.

## 5. Ficheiros criados/alterados (caminhos absolutos)

### No Mac (fonte, `/Volumes/www/assistente/`)
```
docs/FASE2-RESULTADOS.md                                          (este ficheiro)
WORKLOG.md                                                          (por atualizar, ver nota abaixo)
app/composer.json, app/composer.lock, app/phpunit.xml
app/.env.example
app/config/services.php
app/app/Support/Datas.php
app/app/Support/OcrDocs.php
app/app/Models/{FichaCaso,FichaExtracao,FichaDocumento,FichaCartao,FichaCampo,
                FichaAprovacao,FichaExecucao,FichaEvento,FichaFiabilidade,User}.php
app/database/migrations/2026_09_22_0000{01..10}_*.php
app/app/Services/Leitor/LeitorClient.php
app/app/Services/Fichas/{LocalizadorPasta,Classificador,PoliticaConfianca,
                          Cruzamentos,GeradorManifesto}.php
app/app/Services/Fichas/Extratores/{CartaoPsp,CartaoCidadao,RegistoCriminal,
                                     Morada,Iban}.php
app/app/Jobs/LerCaso.php
app/app/Console/Commands/{FichasLer,FichasValidar}.php
app/tests/Pest.php
app/tests/Unit/Support/{OcrDocsChecksumsTest,DatasTest}.php
app/tests/Unit/Fichas/{CartaoPspTest,CartaoCidadaoTest,RegistoCriminalTest,
                        MoradaTest,IbanTest}.php
app/tests/Feature/Fichas/FichasLerCommandTest.php
app/tests/fixtures/processos/999001 Nome Teste Vigilante/Proc 1/{vig.jpg,cc_frente.jpg}
```
Mais o esqueleto Laravel 12 completo trazido do CT 117 por rsync
(`app/app/Http`, `app/app/Providers`, `app/bootstrap`, `app/config/*.php`
restantes, `app/database/factories`, `app/database/seeders`, `app/public`,
`app/resources`, `app/routes`, `app/artisan`, etc.) — commitado como ponto
de partida.

### No CT 117 (`/var/www/assistente/`, deploy de trabalho)
Mesmos ficheiros da app (via rsync), mais `vendor/` (composer),
`docs/` (cópia dos contratos, para `GeradorManifesto` conseguir validar o
schema em runtime), e o `.env` com as chaves novas (`LEITOR_URL`,
`LEITOR_TOKEN` placeholder, `WRAPPER_*`, `FICHAS_PROCESSOS_PATH`).

## 6. Commits git (Mac, `/Volumes/www/assistente`)

Repositório inicializado nesta sessão (`git init`), autor "Rui Pedro
<ruipedrox13@gmail.com>":

```
9d85c0b Esqueleto base do Laravel 12 (CT 117) + fix da data dd/mm/aaaa no extrator de cartões PSP
0ee785a Correções pós-testes reais no CT: Luhn do CC, morada, RC (fuso de datas), Pest 4 + MySQL de teste
24472fe Testes Pest: checksums, morada, datas PSP, CC/MRZ, RC, IBAN + teste de integração fichas:ler
8c9199b Job LerCaso + comandos fichas:ler e fichas:validar
cc91c9e Serviços do motor: LeitorClient, extratores, PoliticaConfianca, Cruzamentos, GeradorManifesto
1fee8d9 Migrações e modelos Eloquent das tabelas fichas_* + helpers Datas/OcrDocs
3cf05ac Estrutura inicial do repo (docs Fase 0, leitor-mini, wrapper-ct107)
```

## 7. Próximos passos (Fase 1 e Fase 3 do plano)

1. Fechar a Fase 1: montar `/mnt/processos` (CIFS read-only) no CT 117,
   copiar o token real do leitor para `LEITOR_TOKEN`, confirmar
   `/v1/saude` responde `ok:true` a partir do CT.
2. Instalar `php8.4-sqlite3` no CT 117 (precisa de alguém com root) e
   voltar os testes a sqlite em memória.
3. Correr `php artisan fichas:validar` sobre os 40 processos reais
   (770-851) assim que a NAS estiver montada, e documentar as linhas
   `a_confirmar` em `docs/validacao-fase2.md` (05-GOLDEN-DATASET.md §2.3).
4. Fase 3 (webapp): login, fila, página do caso, aprovação — este documento
   cobre só o motor (Fase 2).
