# Porte de funcoes/ocr_docs.php (Sabichão) — mapa e estado

Fonte: `/var/www/develop/funcoes/ocr_docs.php` no CT 100 (Sabichão DEV,
192.168.69.20), lida via `sudo -n cat` (utilizador `claude`, sem sudo geral).
73 funções, PHP 7.3. sha256 confirmado igual ao de prod (ver
`app/Support/OcrDocs.php`, cabeçalho).

Decisão do utilizador (24/09/2026): as regras do Sabichão passam a ser a
PRIMEIRA leitura na app, com o modelo local (Gemma 4, via
`SegundaLeituraQwen`) como segunda opinião — não o inverso.

Este documento é o mapa completo pedido no passo 1. O estado real do porte
(o que já foi feito nesta tarefa vs. o que fica pendente) está na secção
"Estado desta entrega" no fim — **ler essa secção primeiro**, porque só uma
fatia do mapa abaixo foi portada e testada.

## Excluídas de propósito (não portar)

Chamam binários, ficheiros ou BD do Sabichão — a app já tem outra forma de
resolver a mesma necessidade (Vision do modelo local para extrair texto,
Laravel para localizar ficheiros e ler a BD via Eloquent):

| Função | O que faz no Sabichão | Porquê fica de fora |
|---|---|---|
| `ocr_binarios_ok` | Verifica se `tesseract`/`pdftoppm`/`convert` existem | App não faz OCR local, usa Vision remoto |
| `ocr_cmd` | Corre um comando de shell com timeout | Sem equivalente necessário |
| `ocr_extrair_texto` | OCR de um ficheiro (imagem/PDF) via tesseract | Vision do mini faz isto |
| `ocr_imagem_para_texto` | Pré-processa (convert) + tesseract numa imagem | idem |
| `ocr_extrair_paginas_pdf` | Rasteriza páginas de PDF (pdftoppm) | idem |
| `ocr_listar_docs` | Lista ficheiros de uma pasta de processo na NAS | `LocalizadorPasta` da app resolve isto |
| `ocr_resolver_processo` | Encontra a pasta do processo por MAI na NAS | idem |
| `ocr_scan_por_mai` | Orquestra o scan completo de um processo (chama tudo acima) | `LerCaso`/pipeline da app é o equivalente |
| `ocr_pdftext_e_lixo` | Deteta se a camada de texto de um PDF é lixo (heurística sobre binário) | Não há camada de texto — Vision lê a imagem |

## Mapa completo (função → o que faz → dependências → equivalente atual)

Legenda do estado: **PORTADA** = em `App\Support\OcrDocs`, com teste de
paridade; **existe (diferente)** = a app já tem lógica própria, ainda não
alinhada com o original; **pendente** = por portar.

| Função | O que faz | Depende de | Equivalente atual na app | Estado |
|---|---|---|---|---|
| `ocr_ctype_utf8` | Valida UTF-8 | — | — | excluída (trivial, não usada fora de `ocr_cmd`) |
| `ocr_so_digitos` | Só dígitos de uma string | — | `OcrDocs::soDigitos` | PORTADA (anterior) |
| `ocr_nif_valido` | Checksum NIF | `ocr_so_digitos` | `OcrDocs::nifValido` | PORTADA (anterior) |
| `ocr_niss_valido` | Checksum NISS | `ocr_so_digitos` | `OcrDocs::nissValido` | PORTADA (anterior) |
| `ocr_iban_valido` | Checksum IBAN PT (MOD-97) | — | `OcrDocs::ibanValidoPt` | PORTADA (anterior) |
| `ocr_mrz_check` | Dígito de controlo MRZ (ICAO 7-3-1) | — | `OcrDocs::mrzCheck` | PORTADA (anterior) |
| `ocr_digitos_ocr` | Letra→dígito (O→0, I→1, ...) | — | `OcrDocs::digitosOcr` | PORTADA (anterior) |
| `ocr_niss_por_ancora` | NISS a partir de uma âncora no texto | `ocr_niss_valido` | — | OcrDocs::nissPorAncora | PORTADA (2ª entrega) |
| `ocr_iban_validado` | IBAN validado + formatado do texto | `ocr_iban_valido` | — | PORTADA (4ª entrega) — `OcrDocs::ibanValidado` |
| `ocr_ncc_de_mrz` | Nº do CC a partir dos 9 dígitos da MRZ | — | — | OcrDocs::nccDeMrz | PORTADA (2ª entrega) |
| `ocr_ncc_luhn_valido` | Checksum Luhn do nº completo do CC | — | `OcrDocs::nccLuhnValido` | PORTADA (anterior) |
| `ocr_mrz_ncc_candidatos` | Candidatos a nº de CC na MRZ | `ocr_ncc_de_mrz` | — | OcrDocs::mrzNccCandidatos | PORTADA (2ª entrega) |
| `ocr_ncc_consenso` | Consenso entre candidatos a nº de CC | `ocr_ncc_luhn_valido` | — | OcrDocs::nccConsenso | PORTADA (2ª entrega) |
| `ocr_niss_bloco_fiscal` | NISS no bloco fiscal do verso do CC | `ocr_niss_valido` | — | OcrDocs::nissBlocoFiscal | PORTADA (2ª entrega) |
| `ocr_niss_candidatos` | Todos os candidatos a NISS no texto | `ocr_niss_valido` | — | OcrDocs::nissCandidatos | PORTADA (2ª entrega) |
| `ocr_niss_prova_documento` | Confirma se o NISS tem prova documental | — | — | OcrDocs::nissProvaDocumento | PORTADA (2ª entrega) |
| `ocr_niss_consenso_documento` | Consenso final de NISS | `ocr_niss_candidatos`, `ocr_niss_prova_documento` | — | OcrDocs::nissConsensoDocumento | PORTADA (2ª entrega) |
| `ocr_filiacao_qualidade` | Qualidade de uma leitura de filiação | — | — | OcrDocs::filiacaoQualidade | PORTADA (2ª entrega) |
| `ocr_filiacao_melhor` | Escolhe a melhor entre duas leituras de filiação | `ocr_filiacao_qualidade` | — | OcrDocs::filiacaoMelhor | PORTADA (2ª entrega) |
| `ocr_normalizar` | Minúsculas + sem acentos + espaços colapsados | — | `OcrDocs::normalizar` | PORTADA (anterior) |
| `ocr_normalizar_data` | Normaliza data multi-formato → dd/mm/aaaa | `ocr_digitos_ocr` | `OcrDocs::normalizarData` | PORTADA (anterior) |
| `ocr_data_para_iso` | dd/mm/aaaa → aaaa-mm-dd | — | `OcrDocs::dataParaIso` | PORTADA (anterior) |
| `ocr_data_plausivel` | Data real + teto de sanidade | `ocr_data_para_iso`, `ocr_normalizar_data` | `OcrDocs::dataPlausivel` | PORTADA (anterior) |
| `ocr_validar_datas_resultado` | Crivo final de datas de um resultado | `ocr_data_plausivel` | — | OcrDocs::validarDatasResultado | PORTADA (2ª entrega) |
| `ocr_extrair_datas` | Todas as datas plausíveis de um texto | `ocr_data_plausivel` | `OcrDocs::extrairDatas` | PORTADA (anterior) |
| `ocr_classificar_por_nome` | Tipo de documento pelo nome do ficheiro | `ocr_tipo_por_tokens` | **App\Services\Fichas\Classificador** (lógica própria) | **PORTADA nesta entrega** — `OcrDocs::classificarPorNome` (ainda não chamada pelo Classificador, ver "Estado desta entrega") |
| `ocr_tipo_por_tokens` | Tipo de documento pelos tokens do nome | — | idem | **PORTADA nesta entrega** — `OcrDocs::tipoPorTokens` |
| `ocr_classificar_por_conteudo` | Tipo de documento pelo texto OCR (fallback) | `ocr_normalizar`, `ocr_cartao_especialidade_regex` | idem | **PORTADA e integrada nesta entrega** — `OcrDocs::classificarPorConteudo`, usada em `Classificador::classificar` |
| `ocr_mrz_datas` | Datas (nascimento/validade) da MRZ | — | Extratores próprios da app | OcrDocs::mrzDatas | PORTADA (2ª entrega) |
| `ocr_mrz_nome` | Nome a partir da MRZ (linha 3, TD1) | — | idem | OcrDocs::mrzNome | PORTADA (2ª entrega) |
| `ocr_data_apos_label` | Data a seguir a um rótulo, numa lista de linhas | `ocr_normalizar_data` | — | OcrDocs::dataAposLabel | PORTADA (2ª entrega) |
| `ocr_recuperar_acentos` | Recupera SÓ a cedilha (ç) comparando com o texto bruto | `ocr_normalizar` | — | **PORTADA nesta entrega** — `OcrDocs::recuperarAcentos` (ainda não integrada nos extratores, ver abaixo) |
| `ocr_regex_arruamento` | Regex dos prefixos de arruamento (rua/av/...) | — | `Extratores\Morada` (lógica própria) | OcrDocs::regexArruamento | PORTADA (2ª entrega) |
| `ocr_rua_plausivel` | Valida um candidato a nome de rua | `ocr_regex_arruamento` | idem | OcrDocs::ruaPlausivel | PORTADA (2ª entrega) |
| `ocr_limpar_localidade` | Limpa um candidato a localidade | — | idem | OcrDocs::limparLocalidade | PORTADA (2ª entrega) |
| `ocr_cp_localidade_plausivel` | Valida par código-postal/localidade | — | idem | OcrDocs::cpLocalidadePlausivel | PORTADA (2ª entrega) |
| `ocr_montar_morada_de_linhas` | Monta morada a partir de linhas soltas | `ocr_regex_arruamento`, `ocr_rua_plausivel` | idem | OcrDocs::montarMoradaDeLinhas | PORTADA (2ª entrega) |
| `ocr_morada_apos_nome` | Morada a seguir ao nome no verso do CC | `ocr_montar_morada_de_linhas` | idem | OcrDocs::moradaAposNome | PORTADA (2ª entrega) |
| `ocr_campos_uteis` | Conta campos não vazios de um resultado | — | — | **pendente** (trivial, baixo risco) |
| `ocr_tr_num_plausivel` | Valida nº de título de residência | — | — | OcrDocs::trNumPlausivel | PORTADA (2ª entrega) |
| `ocr_ncc_tr_de_mrz` | Nº do TR a partir da MRZ | — | — | OcrDocs::nccTrDeMrz | PORTADA (2ª entrega) |
| `ocr_num_titulo_residencia` | Extrai nº de TR do texto | `ocr_tr_num_plausivel`, `ocr_ncc_tr_de_mrz` | — | OcrDocs::numTituloResidencia | PORTADA (2ª entrega) |
| `ocr_parse_cc_frente` | Parser completo da frente do CC | várias das acima | `Extratores\CcFrente` (lógica própria) | OcrDocs::parseCcFrente | PORTADA (2ª entrega) |
| `ocr_limpar_parte_nome` | Limpa uma parte de nome (título/partícula) | — | — | OcrDocs::limparParteNome | PORTADA (2ª entrega) |
| `ocr_parse_cc_verso` | Parser completo do verso do CC (NISS, filiação, morada) | `ocr_niss_*`, `ocr_filiacao_*`, `ocr_morada_apos_nome`, `ocr_ncc_*` | `Extratores\CcVerso` (lógica própria — é aqui que o NISS/filiação vazios acontecem) | OcrDocs::parseCcVerso | PORTADA (2ª entrega) |
| `ocr_cartao_especialidade_regex` | Regex por especialidade (vig/spr/ard/are/...) | — | `Extratores\CartaoPsp` (lógica própria + porte) | PORTADA (1ª entrega) — `OcrDocs::cartaoEspecialidadeRegex` |
| `ocr_ordenar_siglas` | Ordem canónica VIG/SPR/ARD/ARE | — | — | PORTADA (4ª entrega) — `OcrDocs::ordenarSiglas` |
| `ocr_cartao_infos_por_linha` | Extrai infos de cartão por linha | várias | — | PORTADA (4ª entrega) — `OcrDocs::cartaoInfosPorLinha` |
| `ocr_parse_cartao_especialidade` | Parser completo do cartão profissional | `ocr_cartao_especialidade_regex`, `ocr_cartao_infos_por_linha`, `ocr_ordenar_siglas` | `Extratores\CartaoPsp` | **PORTADA E INTEGRADA (4ª entrega)** — `OcrDocs::parseCartaoEspecialidade`, 1ª leitura da validade por bloco em `CartaoPsp::extrairDaPagina` |
| `ocr_rc_valor_apos_rotulo` | Valor a seguir a um rótulo do RC | — | `OcrDocs::rcValorAposRotulo` | PORTADA (anterior) |
| `ocr_limpar_concelho` | Limpa candidato a concelho | `ocr_distrito_de_concelho` | `OcrDocs::limparConcelho` | PORTADA (anterior) |
| `ocr_distrito_de_concelho` | Distrito a partir do concelho (gazetteer) | — | `OcrDocs::distritoDeConcelho` | PORTADA (anterior) |
| `ocr_rc_campos_de_linhas` | Campos do RC (naturalidade/concelho/...) por linha | `ocr_rc_valor_apos_rotulo`, `ocr_limpar_concelho` | `OcrDocs::rcCamposDeLinhas` | PORTADA (anterior) |
| `ocr_parse_rc` | Parser completo do RC | as duas acima + `ocr_extrair_datas` | `OcrDocs::parseRc` | PORTADA (anterior) |
| `ocr_parse_morada` | Parser de comprovativo de morada | `ocr_montar_morada_de_linhas`, `ocr_regex_arruamento`, `ocr_cp_localidade_plausivel` | `Extratores\Morada` (lógica própria) | **PORTADA E INTEGRADA (4ª entrega)** — `OcrDocs::parseMorada`, 1ª leitura em `LerCaso::processarMorada` (regex ingénuo próprio como fallback) |
| `ocr_parse_iban` | Parser de comprovativo de IBAN | `ocr_iban_validado` | `Extratores\Iban` (lógica própria) | **PORTADA E INTEGRADA (4ª entrega)** — `OcrDocs::parseIban`, cobertura adicional em `Iban::extrair` só quando a lógica própria não acha nada |
| `ocr_habilitacoes_de_nome` | Tipo de habilitações pelo nome do ficheiro | — | — | **PORTADA E INTEGRADA (4ª entrega)** — `OcrDocs::habilitacoesDeNome`, 1ª leitura em `LerCaso::processarHabilitacoes` (capacidade nova) |
| `ocr_parse_habilitacoes` | Parser de certificado de habilitações | `ocr_extrair_datas` | — | **PORTADA E INTEGRADA (4ª entrega)** — `OcrDocs::parseHabilitacoes`, fallback em `LerCaso::processarHabilitacoes` |
| `ocr_pais_iso3` | Código ISO3 → nome do país | — | — | PORTADA (4ª entrega) — `OcrDocs::paisIso3` |
| `ocr_parse_passaporte` | Parser de passaporte (MRZ TD3) | `ocr_pais_iso3`, `ocr_mrz_check` | — | PORTADA, só paridade (4ª entrega) — `OcrDocs::parsePassaporte`; integração pendente (Classificador ainda não distingue 'passaporte') |
| `ocr_parse_documento` | Dispatcher: chama o parser certo pelo tipo | todos os `ocr_parse_*` | `Extratores\*` + `Classificador` orquestram isto de forma própria | **pendente** (é o ponto de integração final) |
| `ocr_merge_campos` | Funde campos de várias fontes com política de confiança | — | Lógica de confiança própria da app (`docs/PLANO.md`) | **pendente** — política já existe na app, precisa de comparação linha a linha antes de trocar |
| `ocr_pontuar_nome_fonte` | Pontua a fiabilidade de uma fonte de nome | — | — | OcrDocs::pontuarNomeFonte | PORTADA (2ª entrega) |
| `ocr_escolher_nome` | Escolhe o melhor nome entre candidatos | `ocr_pontuar_nome_fonte` | — | OcrDocs::escolherNome | PORTADA (2ª entrega) |

Não listadas no `grep` inicial mas mencionadas no pedido e confirmadas como
inexistentes nesta versão do ficheiro (73 funções ao todo, todas cobertas
acima): não há `ocr_niss_bloco_fiscal`/`ocr_niss_candidatos` duplicados nem
uma `ocr_rc_*` extra além das 4 listadas.

## Estado da 1ª entrega (24/09/2026)

Dado o volume (73 funções, várias com 200-330 linhas e dependências
cruzadas) e o tempo desta sessão, esta entrega faz o **mapa completo**
(acima) e porta + integra um **primeiro núcleo coerente**: a camada de
CLASSIFICAÇÃO, que é onde estava o bug mais concreto reportado (verso do CC
lido como frente).

### Portado e com teste de paridade
- `ocr_recuperar_acentos` → `OcrDocs::recuperarAcentos`
- `ocr_classificar_por_nome` → `OcrDocs::classificarPorNome`
- `ocr_tipo_por_tokens` → `OcrDocs::tipoPorTokens`
- `ocr_classificar_por_conteudo` → `OcrDocs::classificarPorConteudo`
- `ocr_cartao_especialidade_regex` → `OcrDocs::cartaoEspecialidadeRegex`

Fixtures em `tests/Unit/Fichas/fixtures/ocr_docs_paridade.json` (gerado
correndo o PHP ORIGINAL no CT 100 — ver
`tests/Unit/Fichas/fixtures/gerar_paridade.php` e o cabeçalho de
`tests/Unit/Fichas/OcrDocsParidadeTest.php` para o procedimento de
regeneração). 5 funções, ~65 casos sintéticos no total, incluindo o caso que
prova a ordem "verso antes de frente" (texto com MRZ linha 2 + "cartao de
cidadao" → `cc_verso`, nunca `cc_frente`).

### Integrado
`App\Services\Fichas\Classificador::classificar` passou a chamar
`OcrDocs::classificarPorConteudo` como primeira leitura (mapeando
vig/spr/ard/are/coordenador/diretor para `cartao_psp`, e rc/habilitacoes/
iban/cc_verso/cc_frente/morada diretamente); as regras próprias da app que já
lá estavam ficam como FALLBACK (para tr_ambos/passaporte e para quando o
porte devolve null) — mantém compatibilidade sem regressão, comprovado pelos
grupos de teste (a)(b)(c) todos verdes. `classificarPorNome`/`tipoPorTokens`
ficaram portadas mas NÃO integradas ainda — o pipeline da app não classifica
por nome de ficheiro hoje (o Vision já lê o conteúdo); integrar exigiria
decidir ONDE entra essa classificação por nome no fluxo atual, o que fica
para uma próxima entrega.

### NÃO feito nesta entrega (fica pendente, por ordem de prioridade)
1. **`ocr_parse_cc_verso`** e a família NISS/filiação (`ocr_niss_*`,
   `ocr_filiacao_*`, `ocr_ncc_*`, `ocr_morada_apos_nome`) — é onde está o
   "NISS e filiação vazios" reportado. Maior risco e maior ganho; não coube
   nesta sessão.
2. **`ocr_parse_cc_frente`** e `ocr_mrz_nome`/`ocr_mrz_datas`/
   `ocr_recuperar_acentos` já integrado no fluxo do nome — falta ligar
   `recuperarAcentos` a `Extratores\CcFrente`/`CcVerso` (o "nomes sem
   acentos" reportado).
3. **`ocr_parse_cartao_especialidade`** — os cartões PSP continuam com o
   `Extratores\CartaoPsp` próprio da app.
4. **`ocr_parse_morada`**, **`ocr_parse_iban`**, **`ocr_parse_passaporte`**,
   **`ocr_parse_habilitacoes`** — extratores próprios da app por rever.
5. **`ocr_merge_campos`** vs. a política de confiança da app — precisa de
   comparação campo a campo com `docs/PLANO.md` antes de trocar (risco de
   regressão silenciosa se forem só "parecidas").

Cada um destes é um trabalho do mesmo porte desta entrega (porte literal +
fixtures de paridade geradas no CT 100 + integração + testes). Recomenda-se
tratá-los como tarefas separadas, na ordem acima.

## Estado da 2ª entrega (24/09/2026, continuação)

Fez os itens 1 e 2 da lista de prioridades da 1ª entrega (a tabela do mapa
completo, acima, já reflete os novos estados "PORTADA (2ª entrega)").

### Item 1 — verso do CC (prioridade máxima): portado e integrado

Toda a família de `ocr_parse_cc_verso` — `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_nome`, `ocr_mrz_datas`,
`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 os
auxiliares de morada de que depende (`ocr_regex_arruamento`,
`ocr_rua_plausivel`, `ocr_limpar_localidade`, `ocr_cp_localidade_plausivel`,
`ocr_montar_morada_de_linhas`), `ocr_limpar_parte_nome`,
`ocr_validar_datas_resultado` (dependência partilhada com a frente) e o
próprio `ocr_parse_cc_verso` — está portada literalmente em
`App\Support\OcrDocs`, com testes de paridade gerados no CT 100 (mesmo
`funcoes/ocr_docs.php`, sha256 inalterado) em
`tests/Unit/Fichas/fixtures/ocr_docs_paridade.json` (chaves `parseCcVerso`,
`nissBlocoFiscal`, `mrzNome`, `mrzDatas`, `filiacaoQualidade`,
`montarMoradaDeLinhas`, `moradaAposNome`; casos gerados com IDs sintéticos
com checksum calculado, nunca reais).

**Integrado**: `CartaoCidadao::extrairVerso` chama `OcrDocs::parseCcVerso`
como 1ª leitura para `nif` (campo novo), `niss`, `ncc` (campo novo, pela
MRZ) e `filho_de` (com `OcrDocs::tituloComParticulas` por cima do resultado
do porte, para as partículas de/da/do saírem em minúsculas como a 2ª
leitura já fazia). A 2ª leitura — o método próprio da app, inalterado —
continua a correr como *fallback* quando o porte não encontra nada
(cobre, por exemplo, os separadores `•`/`·`/`●` da filiação, que o porte só
reconhece como `*`/`|`). A política de confiança não mudou: um campo só sai
`alta` quando `_fiaveis` do porte o confirma.

### Item 2 — frente do CC: portado, integração NÃO feita

`ocr_parse_cc_frente`, `ocr_pontuar_nome_fonte`, `ocr_escolher_nome` (e os
auxiliares que só a frente usa: `ocr_tr_num_plausivel`,
`ocr_num_titulo_residencia`) estão portados literalmente em `OcrDocs`, com
paridade testada (chaves `parseCcFrente`, `pontuarNomeFonte`,
`escolherNome` no mesmo fixture).

`CartaoCidadao::extrairFrente` **não foi alterado**: continua só com a
lógica própria da app (já validada com 40 processos reais,
`docs/validacao-fase2.md`). Duas razões para não integrar ainda:

1. A parte "usar `OcrDocs::parseCcFrente` como leitura adicional" é viável
   e de baixo risco (o mesmo padrão do verso), mas ficou de fora desta
   sessão por prioridade — o pedido concreto ("nomes vazios/NISS vazio") era
   do verso.
2. A parte "ligar `OcrDocs::recuperarAcentos` ao nome/nome_proprio/apelido
   usando o RC/portal como fonte de acentos" **precisa de uma fonte de texto
   que `Extratores\CartaoCidadao` não tem** — a classe só vê o texto do
   próprio documento CC (sempre em maiúsculas, sem acentos no cartão físico
   real). Recuperar o acento exige o texto de OUTRO documento da mesma
   pessoa (RC) ou os dados do Portal — isso vive na camada que combina as
   várias leituras de um caso (fora do âmbito desta classe), que não foi
   localizada/mapeada nesta sessão. Fica como o primeiro passo da próxima
   entrega: encontrar essa camada, decidir que "rawCombinado" lhe entregar,
   e só depois aplicar `recuperarAcentos` ao resultado do CC.

### Testado

`tests/Unit/Fichas/OcrDocsParidadeTest.php` (15 testes, incluindo os 9 desta
entrega) e `tests/Unit/Fichas/CartaoCidadaoTest.php` (25 testes, todos os
pré-existentes continuam verdes) no CT 117. Os 3 grupos completos do
projeto: (a) `tests/Unit` + `tests/Feature/Contratos` + `tests/Feature/Leitor`
= 242 testes; (b) `tests/Feature/Auth` + `tests/Feature/Fiabilidade` +
`tests/Feature/FilaProcessamento` + `tests/Feature/*.php` soltos = 20
testes; (c) `tests/Feature/Fichas/*.php` exceto `FichasLerCommandTest.php`
e `RetryExtracaoTest.php` = 163 testes. Total 425, 0 falhas, 24/09/2026.

### NÃO feito nesta entrega (fica pendente, por ordem de prioridade)

1. Integrar `OcrDocs::parseCcFrente` em `extrairFrente` (baixo risco, mesmo
   padrão do verso).
2. Localizar a camada que combina CC + RC + portal para ligar
   `OcrDocs::recuperarAcentos` ao nome do CC.
3. `ocr_parse_cartao_especialidade` (330 linhas, cartões PSP continuam com
   `Extratores\CartaoPsp` próprio).
4. `ocr_parse_morada`, `ocr_parse_iban`, `ocr_parse_passaporte`,
   `ocr_parse_habilitacoes`.
5. `ocr_merge_campos` vs. a política de confiança da app.

## Estado da 3ª entrega (24/09/2026, continuação — frente do CC + acentos do nome)

Fez os itens 1 e 2 da lista de prioridades da 2ª entrega.

### Item 1 — `OcrDocs::parseCcFrente` integrado em `extrairFrente`

Mesmo padrão do verso (porte como 1ª leitura, método próprio como 2ª leitura/
fallback), mas com uma nuance por campo, decidida depois de testar o porte
contra os 3 fixtures reais de `CartaoCidadaoTest.php` (`$sintetico`,
`$fisicoReal`, `$digital`) com um script local (`OcrDocs::parseCcFrente`
sem o resto do Laravel):

- **ncc**: a leitura própria (regex com dígito de controlo + Luhn) continua a
  decidir sempre que encontra alguma coisa — é a única com o Luhn, e o porte
  só devolve os 8 dígitos sem controlo. O porte só entra quando a leitura
  própria não encontra NADA (regex mais estrita falha).
- **nascimento/validade_cc**: mesma regra — a leitura própria decide sempre
  que encontra algo (já 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, e só depois de passar pelo mesmo `checkdate` (nunca inventa).
- **nome**: o porte NUNCA substitui `apelido`/`nome_proprio` (só devolve o
  nome completo, e a app precisa dos dois campos separados) nem o `nome`
  quando as letras diferem — só CONFIRMA (sobe a confiança para 'alta'
  quando as letras batem, ignorando acentos/caixa). Achado importante do
  teste com o fixture `$digital`: o porte lê "M M M" para o layout
  `app.gov.pt` (não é o formato para que `ocr_parse_cc_frente` foi pensado)
  e ainda marca esse valor como fiável (`_fiaveis`) — se fosse tratado como
  substituição, corrompia o nome digital. A guarda "só confirma, nunca
  troca" evita esse caso à partida, sem precisar de tratar o digital à
  parte.

Testado com os 25 testes de `CartaoCidadaoTest.php` (inalterados) — todos
continuam verdes, incluindo o do nome digital e o do "sem acentos" do CC
físico garbled.

### Item 2 — acentos do nome (RC como 2ª fonte)

A camada que combina os documentos de um caso é `App\Jobs\LerCaso` — o loop
principal (`lerCasoDeFacto`) já recolhia `$nomesPorOrigem` (nome lido por
`cc_frente`/`cc_digital`/`rc`, ver `processarCcFrente`/`processarRc`) mas só
para uma confirmação simplista (2 fontes a bater sobe a confiança, nunca
melhora a grafia). `RegistoCriminal::extrair` (delega em `OcrDocs::parseRc` →
`rcCamposDeLinhas`) já lia o `nome` do RC pelo rótulo `NOME (...)` — com
acentos, quando o PDF os tem (é texto digital, não OCR de cartão físico).

`LerCaso::confirmarNomePorSegundaFonte` foi reescrito para usar
`OcrDocs::escolherNome`/`pontuarNomeFonte` (porte de `ocr_escolher_nome`,
2ª entrega) sobre os candidatos de `$nomesPorOrigem`: nunca troca as LETRAS
(comparação por `OcrDocs::normalizar`, ignorando acentos/caixa), só melhora
a GRAFIA quando uma fonte com acentos bate com o valor já lido — é assim que
"Cristovao Silva" (CC físico, sempre maiúsculas sem acentos) vira "Cristóvão
Silva" quando o RC já leu o nome acentuado, sem inventar vogal a vogal (a
única recuperação letra a letra que existe, `OcrDocs::recuperarAcentos`, é
propositadamente restrita à cedilha — ver o comentário na própria função:
"os acentos de vogais introduziam erros, ex. BEATRIZ -> BEÁTRIZ"). A
melhoria de grafia também é repartida por `apelido`/`nome_proprio`
(`LerCaso::resplitarNomeCompleto`, mantendo o nº de palavras de cada um).
Confirmado por >= 2 fontes independentes -> confiança sobe a 'alta' e o
motivo "sem acentos: confirmar" sai; com 1 fonte só, a grafia melhora mas a
confiança/motivo ficam como estavam (nunca inventa confirmação que não
houve).

**Portal (`PortalDadosAplicador`)**: confirmado no wrapper
(`wrapper-ct107/wrapper.php`, `acao_consulta_portal_lookup`/
`acao_consulta_portal_por_nome`) que o `mapeado` devolvido pelo portal NUNCA
inclui `nome` — só os 8 campos de `PortalDadosAplicador::CAMPOS_PORTAL`
(email/contacto/.../habilitacoes). Não há nada para "reaplicar depois do
portal" hoje. Se o wrapper um dia passar a devolver `nome` no `mapeado`, o
loop genérico de `PortalDadosAplicador::aplicar()` já o escreveria com
origem `'portal'`/confiança `'alta'` (respeitando "nunca sobrepor uma edição
do utilizador") sem precisar de código novo — não foi alterado por não
haver o que corrigir.

Testado com `tests/Feature/Fichas/AcentosNomeTest.php` (4 testes novos:
recuperação com 2 fontes -> alta, melhoria de grafia com 1 fonte só -> fica
'rever', nunca troca as letras quando as fontes discordam, e o RC a ler o
nome já acentuado) e os 3 grupos completos do projeto — 429 testes, 0
falhas, 24/09/2026.

### NÃO feito nesta entrega (fica pendente, por ordem de prioridade)

1. `ocr_parse_cartao_especialidade` (330 linhas, cartões PSP continuam com
   `Extratores\CartaoPsp` próprio).
2. `ocr_parse_morada`, `ocr_parse_iban`, `ocr_parse_passaporte`,
   `ocr_parse_habilitacoes`.
3. `ocr_merge_campos` vs. a política de confiança da app.

## Estado da 4ª entrega (24/09/2026, continuação — cartão PSP, IBAN, morada, habilitações, passaporte)

Fez os itens 3, 4 e 5 da lista de prioridades da 3ª entrega (cartão PSP,
morada/IBAN/passaporte/habilitações — a tabela do mapa completo, acima, já
reflete os novos estados "PORTADA (4ª entrega)" / "PORTADA E INTEGRADA (4ª
entrega)").

### Item 1 — cartão PSP: portado e integrado (só a validade)

`ocr_parse_cartao_especialidade` (330 linhas) + `ocr_cartao_infos_por_linha`,
`ocr_ordenar_siglas` e o `ocr_cartao_especialidade_regex` (já portada, 1ª
entrega) estão portados literalmente em `OcrDocs`, com paridade testada
(fixtures `ordenarSiglas`, `cartaoInfosPorLinha`, `parseCartaoEspecialidade`
em `ocr_docs_paridade.json`). `extrairDatas` foi ESTENDIDA (literal do
original) com o ramo de meses por nome (JAN/FEV/.../DEZ, formato real dos
cartões "19FEV2030") e o parâmetro `$reparadas` por referência — sem isto
`parseCartaoEspecialidade` não conseguia assinalar a amarelo as validades
com leitura reparada.

**Integrado** em `CartaoPsp::extrairDaPagina`/`processarCartaoPsp`, mas com
um âmbito deliberadamente ESTREITO: o porte corre por BLOCO (o mesmo bloco
que a lógica própria já isola por número confirmado/cabeçalho "SEGURANÇA
PRIVADA") e só substitui a VALIDADE (`validadeDoPorte()`), nunca a
`categoria_texto`. A categoria impressa continua a vir de
`categoriaPorTexto()` (lógica própria, inalterada) porque é dela que sai a
deteção de CONFLITO com o sufixo do número — uma tentativa inicial de deixar
o porte decidir também a categoria tornava essa deteção impossível (o porte
usa o mesmo sufixo para escolher a categoria, por isso nunca discordava de
si próprio) e foi revertida antes de chegar a testes.

**Caso 773** (validades ARD/ARE trocadas, o motivo concreto de içar este
item ao topo da lista): como o porte lê o BLOCO já isolado pela lógica
própria (que separa os cartões por número/cabeçalho antes de qualquer
leitura de validade), a validade de cada cartão fica ligada ao seu próprio
bloco tanto na leitura própria como na do porte — os dois concordam por
construção. Dois testes novos em `CartaoPspTest.php` (formato dd/mm/aaaa e
formato "DDMESAAAA" dos cartões reais) confirmam isto com dois cartões
ARD+ARE no mesmo documento e validades diferentes.

### Item 2 — IBAN: portado e integrado como cobertura adicional (não como 1ª leitura plena)

`ocr_iban_validado`/`ocr_parse_iban` portados literalmente
(`OcrDocs::ibanValidado`/`parseIban`), com paridade testada — incluindo um
IBAN sintético com o prefixo "PT50" (o real tem sempre "50" por construção
do NIB, ver comentário do porte) para exercitar o caminho feliz da extração
por rótulo/NIB puro.

**Integração**: aqui o padrão "porte 1ª leitura, lógica própria fallback"
foi INVERTIDO deliberadamente. `Extratores\Iban` já tem uma lógica própria
madura com bugs reais documentados e corrigidos (IBAN mascarado que não
está logo a seguir ao prefixo; layout em colunas que cola um rótulo "IBAN"
a meio da sequência de dígitos e inventava lixo) — o regex do porte
(`\bPT[50O]{1,2}\s*[\d\sO]+`) é mais permissivo e reabriria exatamente esses
dois bugs se corresse primeiro sobre o texto bruto. Por isso o porte só
corre como COBERTURA ADICIONAL, depois de a lógica própria (mascarado +
`candidatoPt()` + estrangeiro) não encontrar nada — cobre o caso do NIB puro
sem prefixo "PT" (doc "nib.jpg"), que `candidatoPt()` não tenta. Dois testes
novos em `IbanTest.php` confirmam a cobertura nova e que o porte nunca
substitui um resultado já mascarado pela lógica própria.

### Item 3 — morada: portado e integrado como 1ª leitura

`ocr_parse_morada` portado literalmente (`OcrDocs::parseMorada`), com
paridade testada (guardas institucionais + domicílio fiscal + rótulo
"MORADA"/"TITULAR DO CONTRATO" + scan genérico rua+CP).

**Integrado** em `LerCaso::processarMorada`: o porte passa a ser a 1ª
leitura; o regex ingénuo antigo (bloco de texto à volta do 1º código
postal, sem guarda nenhuma) fica como fallback só quando o porte não
devolve `morada` (documento institucional detetado, ou nenhum padrão
reconhecido). `Morada::normalizar` (padrão da casa: abreviaturas expandidas,
"nº" colado, vírgula antes do CP) continua a aplicar-se sempre por cima do
resultado de qualquer um dos dois — o porte devolve já formatado
("Rua X, CP Localidade") mas sem as abreviaturas da casa, daí passar sempre
pelo normalizador. O comparador semântico do Qwen (`SegundaLeituraQwen`,
2ª leitura independente que já corre sempre para morada/rc/iban) não foi
tocado. 3 testes novos em `HabilitacoesMoradaPorteTest.php` cobrem o caminho
feliz, o domicílio fiscal (que o regex antigo não distinguia de prosa) e a
guarda institucional (nem o porte nem o fallback preenchem — "vazio >
errado" preservado).

### Item 4 — habilitações: portado e integrado como capacidade NOVA

`ocr_habilitacoes_de_nome`/`ocr_parse_habilitacoes` portados literalmente,
com paridade testada.

**Integrado** em `LerCaso::processarHabilitacoes` (método novo, chamado do
`match` que antes tinha `'habilitacoes' => default => null` — a app NUNCA
teve extração Vision própria para este tipo de documento, só Qwen/Portal).
Risco de regressão baixo por ser puramente aditivo: não havia comportamento
anterior para quebrar. O nome do ficheiro (`ocr_habilitacoes_de_nome`) é a
1ª leitura — é a fonte mais fiável, o utilizador nomeia o ficheiro
("Certificado de Habilitações 12Ano.pdf") — com o texto OCR
(`ocr_parse_habilitacoes`, CONSERVADOR, só âncoras fortes) como fallback.
4 testes novos em `HabilitacoesMoradaPorteTest.php`.

### Item 5 — passaporte: só portado e testado, NÃO integrado

`ocr_pais_iso3`/`ocr_parse_passaporte` portados literalmente, com paridade
testada (MRZ TD3 sintética com dígitos de controlo ICAO 7-3-1 calculados,
baseada no exemplo clássico da norma). Conforme instrução: a integração
fica pendente porque `Classificador::classificar` ainda não distingue
'passaporte' como tipo próprio — cai no fallback junto com 'tr_ambos' e
outros tipos "que a app ainda não trata como separados" (comentário
existente no próprio Classificador). Correr o parser sobre qualquer
documento desse fallback arriscaria interpretar um TR ou outro documento
como passaporte. Primeiro passo de uma próxima entrega: decidir o marcador
que distingue passaporte no Classificador (a MRZ TD3 tem formato próprio,
2 linhas de 44, diferente da TD1 do CC/TR) e só depois ligar o parser.

### Testado

`tests/Unit/Fichas/OcrDocsParidadeTest.php` (26 testes, incluindo os 10
desta entrega: `ordenarSiglas`, `cartaoInfosPorLinha`,
`parseCartaoEspecialidade` x2 — paridade + regressão dedicada do caso 773 —,
`ibanValidado`, `parseIban`, `parseMorada`, `habilitacoesDeNome`,
`parseHabilitacoes`, `paisIso3`, `parsePassaporte`), 2 testes novos em
`CartaoPspTest.php` (caso 773, dois formatos de data), 2 novos em
`IbanTest.php`, 7 novos em `tests/Feature/Fichas/HabilitacoesMoradaPorteTest.php`.
Os 3 grupos completos do projeto: (a) `tests/Unit` + `tests/Feature/Contratos`
+ `tests/Feature/Leitor` = 260 testes; (b) `tests/Feature/Auth` +
`tests/Feature/Fiabilidade` + `tests/Feature/FilaProcessamento` +
`tests/Feature/*.php` soltos = 20 testes; (c) `tests/Feature/Fichas/*.php`
exceto `FichasLerCommandTest.php` e `RetryExtracaoTest.php` = 174 testes.
Total 454, 0 falhas, 24/09/2026.

### NÃO feito nesta entrega (fica pendente, por ordem de prioridade)

1. Integrar `OcrDocs::parsePassaporte` quando o Classificador distinguir
   'passaporte' como tipo próprio.
2. `ocr_merge_campos` vs. a política de confiança da app (comparação campo
   a campo com `docs/PLANO.md` por fazer).
3. `ocr_classificar_por_nome`/`ocr_tipo_por_tokens` (portadas desde a 1ª
   entrega, ainda sem ponto de integração decidido — o pipeline atual não
   classifica por nome de ficheiro, o Vision já lê o conteúdo).
4. `ocr_campos_uteis` (trivial, baixo risco, não portada por não haver
   ainda um consumidor claro na app).
