# Contratos — Fase 3 (webapp): resultados

Módulo Contratos, secção "Contratos" do menu lateral, sobre o motor da Fase 2
(`App\Services\Contratos\{Calculadora,DocxEngine,...}`) e as decisões do
utilizador de 23/09/2026 (`docs/CONTRATOS-DECISOES.md`). Tudo em DEV — nada
instalado no CT 107, nunca usada a chave `assistente_consulta_prod` exceto
para `consulta.*`.

## O que ficou feito

### 1. Infraestrutura (CT 117)

- `qpdf` e `libreoffice-writer-nogui` instalados (Debian 13/trixie:
  `apt-get install -y qpdf libreoffice-writer-nogui`). Confirmados a
  funcionar como `www-data` (o utilizador do PHP-FPM/queue): conversão
  `Contrato_Termo_Certo_v2.docx` → PDF via `soffice --headless --convert-to
  pdf`, e encriptação com `qpdf --encrypt "" <senha> 256 --print=full
  --modify=none --extract=n --annotate=n` — `qpdf --show-encryption` confirma
  `User password = ` (vazia, abre sem password) e `modify anything: not
  allowed`.
  - **Armadilha real encontrada e documentada em `Protetor.php`**: o texto
    exato que `qpdf --show-encryption` imprime para "sem edição" é `modify
    anything: not allowed`, não `modify: none` como a primeira versão do
    código assumia — corrigido depois de testar a sério no CT.
  - **Outra armadilha (cwd, não permissões)**: `soffice` faz sempre `cd
    "$sd_cwd"` (o diretório de onde foi chamado) antes de correr; chamado a
    partir de `/root` como `www-data` falha com "can't cd to /root" mesmo
    com `HOME` correto. A app nunca tem este problema porque o processo
    PHP-FPM corre sempre a partir da raiz do projeto (`/var/www/assistente`,
    `www-data`-legível), mas fica registado para qualquer teste manual futuro
    por SSH.
- Pastas de dev criadas em `/srv/assistente-dev/` (dono `www-data`):
  `modelos/` (cópia dos 4 `.docx` reais de `/Volumes/Assistente/Modelos
  contratos`) e `contratos-feitos/` (pasta de teste, nunca a NAS real).
- Configuráveis por `.env`: `CONTRATOS_MODELOS_PATH`, `CONTRATOS_SAIDA_PATH`,
  `CONTRATOS_SOFFICE_BIN`, `CONTRATOS_QPDF_BIN` (`config/services.php`
  `contratos.*`; `CONTRATOS_MODELOS_PATH` mantém compatibilidade com o
  `CONTRATOS_MODELOS_DIR` já usado pelo comando `contratos:gerar` da Fase 2).

### 2. Wrapper (`wrapper-ct107/wrapper.php`, PHP 7.3)

Três ações novas, instaladas SÓ em DEV (CT 100, release
`20260923181556` — reinstalado 3x durante este trabalho para corrigir bugs
reais encontrados a testar, ver abaixo):

- `consulta.fila_contratos` (chave de consulta) — `SELECT id_processo, nome,
  mai, zona1 AS zona, admissao FROM processo WHERE BINARY contrato='Fazer'
  AND BINARY estado='Ativo'`.
- `consulta.dados_contrato` (chave de consulta) — as ~9 queries de
  `docs/08-CONTRATOS-WRAPPER-ACOES.md` (processo, processo_contrato,
  cliente_equipa+clientes, clientes_servicos, candidatos por LIKE,
  processamento_categorias, processamento_config, configuracoes_globais,
  feriados).
- `contrato.marcar_redigido` (**só** chave de apply) — padrão idêntico a
  `arquivar-contratos/scripts/aplicar.py::atualizar_bd`: transação,
  `SELECT ... FOR UPDATE`, `UPDATE processo SET contrato='Nao' WHERE
  id_processo=? AND BINARY contrato='Fazer' AND BINARY nif=? AND BINARY
  estado='Ativo'` (o NIF da guarda é sempre o valor CRU lido na pré-imagem,
  nunca um valor normalizado — ver bug abaixo), `ROW_COUNT()=1` obrigatório,
  `INSERT INTO log_funcionarios` com `user='Rui P. Barbosa'` e texto
  "Contrato redigido pelo Assistente RH.", idempotente (se já `'Nao'`,
  devolve `ok` sem nova linha de log).

**Bugs reais encontrados e corrigidos testando contra a BD de dev a sério**
(não em testes sintéticos — contra o schema real do CT 100):
1. `cliente_equipa` não tem coluna `estado` (só `ativa`, gerada); o "estado"
   da afetação é o `clientes.estado` do cliente. `SELECT ce.estado` dava
   `Unknown column` — corrigido para `c.estado`.
2. NIF com espaços: a guarda do `UPDATE` comparava a coluna `BINARY nif=?`
   contra o NIF **normalizado** do payload (sem espaços) em vez do valor cru
   da pré-imagem — um NIF gravado com espaços (situação conhecida do
   Sabichão, ver `reference_sabichao_ssh.md`) fazia `ROW_COUNT()=0` mesmo
   depois da validação lógica já ter confirmado a equivalência, deixando o
   contrato publicado mas preso em "por marcar" para sempre, sem forma de
   reconciliar por repetição. Corrigido: a guarda usa sempre `$linha['nif']`
   (o valor lido no `FOR UPDATE`), nunca o valor normalizado.

**Testado a sério a partir do CT 117 contra o Sabichão dev (CT 100)**, 3
ações, incluindo escrita real e reversão:
- `consulta.fila_contratos` → devolveu os 3 processos reais em
  `contrato='Fazer' AND estado='Ativo'` (11, 16, 15).
- `consulta.dados_contrato` para os 3 processos → JSON completo, sem erro
  depois da correção do bug 1.
- `contrato.marcar_redigido` no processo 11 (NIF 258008636): `UPDATE`
  aplicado (`contrato` `Fazer`→`Nao`), linha de log real gravada
  (`id_log=342`, `user='Rui P. Barbosa'`, texto exato pedido); repetição com
  a mesma chave de apply devolveu `ja_marcado:true` sem nova linha de log
  (idempotência confirmada); tentativa com a chave de **consulta** foi
  recusada (`"acao nao permitida para esta chave"`, autorização pela classe
  de chave, nunca pelo payload). **Revertido de imediato**: `UPDATE processo
  SET contrato='Fazer' WHERE id_processo=11`, `DELETE FROM log_funcionarios
  WHERE id_log=342`, confirmado por `SELECT` que os 3 processos da fila
  ficaram exatamente como antes (`contrato='Fazer'`, `estado='Ativo'`) e que
  não sobrou nenhuma linha de log "Assistente RH" na BD de dev.

### 3. Webapp (Laravel)

- Migrações `contratos_casos` (máquina de estados de
  `docs/10-CONTRATOS-ESTADOS.md`), `contratos_eventos` (auditoria),
  `contratos_versoes` (histórico de gerações, uma subpasta `v<numero>` por
  versão — nunca reescreve o ficheiro da versão anterior).
- Modelos `ContratoCaso` (com `TRANSICOES`/`transitarPara`, igual ao padrão
  de `FichaCaso`), `ContratoEvento`, `ContratoVersao`.
- `App\Services\Contratos\Gerador` — orquestra `Cruzamentos::dadosContrato`
  (wrapper) + `Calculadora::calcular` + `DocxEngine::preencherDocx` +
  `Conversor` (soffice) + `Protetor` (qpdf), grava o rascunho em storage
  privado (`storage/app/contratos/rascunhos/<id_processo>/v<n>/`).
- `App\Services\Contratos\Publicador` — copia `.docx`(+`.pdf`) para
  `CONTRATOS_SAIDA_PATH/<zona>/`, nunca sobrescreve, depois chama
  `Aplicador::marcarRedigido`; se a marcação falhar, o caso fica
  `publicado_por_marcar=true` (nunca apaga o ficheiro publicado). Cópia
  **atómica** (nomes temporários + `rename`, nunca deixa um `.docx` órfão se
  a cópia do `.pdf` falhar a meio) e **zona sanitizada** (só letras/dígitos/
  espaço/hífen/underscore, com confirmação por `realpath` de que o destino
  fica mesmo dentro de `CONTRATOS_SAIDA_PATH` — nunca `/`, `..` nem outro
  separador de caminho vindos de `processo.zona1`).
- `ContratosController` + rotas `contratos.*`: fila com seleção múltipla e
  "Redigir selecionados" (um job `RedigirContrato` por caso), página de
  revisão (pré-visualização do PDF por `<iframe>` autenticado/no-store,
  perguntas pendentes e campos `[FALTA]` com formulário de "Regerar",
  "Aprovar e publicar", "Descartar"), "Publicar em lote", "Tentar marcar
  outra vez", histórico por caso (`contratos_eventos`) e histórico geral
  (últimos publicados) na fila.
- Views `resources/views/contratos/{index,mostrar}.blade.php` escritas
  diretamente pelo Claude (ver `docs/FASE3-QWEN.md`, Pedido 5 — o mac mini
  foi desligado a meio deste trabalho).

### 4. Testes Pest

12 testes novos (`tests/Feature/Contratos/{FilaTest,GeradorTest,
PublicadorTest}.php`) cobrindo: fila (autenticação, listagem, criação de
caso `sem_gerar`, erro do wrapper visível, seleção múltipla despacha um job
por caso, recusa sem seleção), geração (completo sem perguntas, com
perguntas bloqueia sem escrever ficheiro, recusa regenerar um publicado),
publicação (copia para a zona e marca, nunca sobrescreve, falha de marcação
→ `publicado_por_marcar` sem apagar nada, retry não copia de novo,
idempotência do lado da app). **Suite completa no CT 117: 280 testes, 755
assertions, sem regressão em Fichas.**

### 5. Revisão própria (Codex ficou sem quota a meio do trabalho)

O utilizador avisou que o Codex ficou sem quota; a partir desse ponto os
`codex review --uncommitted` pedidos foram feitos ANTES do aviso (3 rondas,
ver abaixo) — revisão do estado atual do código é própria, sem Codex.

**3 rondas de `codex review --uncommitted` (antes da quota acabar), todos os
achados corrigidos e reconfirmados com a suite Pest a passar:**

Ronda 1 (P1+P2):
1. [P1] `wrapper.php`: NIF com espaços fazia `ROW_COUNT()=0` no `UPDATE`
   mesmo com a pré-imagem validada — corrigido (ver secção do wrapper acima).
2. [P2] `Gerador`: regenerar reescrevia o ficheiro da versão anterior no
   mesmo caminho — corrigido com subpasta `v<numero>` por versão.
3. [P2] `Publicador`: cópia dos dois ficheiros não era atómica (um `.docx`
   órfão bloqueava publicações futuras) — corrigido (nomes temporários +
   rename).

Ronda 2 (P1+P2, depois de corrigir a ronda 1):
4. [P1] `Calculadora`: mais de uma afetação ativa em `cliente_equipa`
   escolhia silenciosamente a mais recente (só um aviso) — corrigido: agora
   bloqueia com uma pergunta (`--cliente <numero_contrato>`), nunca escolhe
   sozinha (achado real nos processos 747/750 mencionado pelo Codex).
5. [P1] `Publicador`: a zona (`processo.zona1`) ia direta para o caminho de
   publicação sem validação — corrigido: `zonaSegura()` (whitelist de
   caracteres) + confirmação por `realpath` de que o destino fica dentro de
   `CONTRATOS_SAIDA_PATH`.
6. [P2] `Gerador`: quando o utilizador respondia `--cliente`, esse valor não
   era passado a `consulta.dados_contrato`, deixando `cliente_flag` sempre
   vazio e a pergunta "cliente não existe" a repetir-se mesmo com a resposta
   certa — corrigido: `dadosContrato($id, $flags['cliente'] ?? '')`.

Ronda 3 (P1+P2):
7. [P1] `Publicador::marcar`: um NIF em falta no manifesto lançava uma
   exceção DEPOIS de os ficheiros já terem sido copiados e o caso já gravado
   como `publicado` — a UI escondia o botão de retry porque
   `publicado_por_marcar` continuava falso. Corrigido: trata-se como
   qualquer outra falha de marcação (`publicado_por_marcar=true`, evento
   `marcar_falhou`), nunca lança.
8. [P2] `Gerador`: regenerar um caso já `gerado_completo`/`gerado_com_falta`
   que passa a ter perguntas ficava preso em `sem_gerar` em vez de
   `gerado_com_perguntas` — corrigido.

**Pendente, sem Codex (quota esgotada a meio deste trabalho) — revisão
própria:**
9. [P2, não corrigido] `Gerador::gerar` recusa sempre regenerar um caso
   `publicado` (`estado === 'publicado'` lança já no início). O
   `docs/10-CONTRATOS-ESTADOS.md` prevê uma "regeneração de auditoria"
   pós-publicação (cria nova `contratos_versoes` + evento, sem reabrir nem
   republicar o caso) que esta guarda impede por completo. Não corrigido
   por falta de tempo nesta entrega — a decisão de desenho (como distinguir
   "gerar normal" de "gerar de auditoria só para comparar" na mesma função,
   sem risco de o caso voltar a ficar `sem_gerar`/publicável por engano)
   merece ser pensada com calma, não às pressas. Ninguém pediu esta
   funcionalidade ainda (nenhum caso publicado nesta fase); documentado aqui
   para não se perder.
10. Autorevisão dos ficheiros novos (`ContratosController`, rotas, `Gerador`,
    `Publicador`, `wrapper.php`) sem encontrar mais problemas de segurança
    óbvios: todas as rotas `contratos.*` ficam dentro do grupo
    `middleware('auth')` (mesmo padrão de `fichas.*`); `Aplicador::correr`
    continua a recusar qualquer ação fora de
    `apply/ficha_pdf/email/verify/contrato.marcar_redigido`; o wrapper
    autoriza sempre pela classe de chave (nunca pelo payload); `ficheiro()`
    do controlador serve só `docx_path`/`pdf_path` do próprio caso, nunca um
    caminho vindo do pedido. **Repetir `codex review` a sério depois do
    reset de quota, antes do próximo commit grande deste módulo.**

### 6. Verificação em browser (Playwright)

**Não feita nesta entrega.** Faltou tempo/orçamento depois do trabalho de
infraestrutura, wrapper (3 rondas de correção de bugs reais) e das 3 rondas
de revisão do Codex. A suite Pest cobre a fila, a geração (com modelo
sintético e mockado), a publicação e a autorização a nível de
HTTP/serviço, mas ninguém confirmou visualmente no browser: abrir Contratos,
selecionar 2, redigir, ver o PDF no `<iframe>`, preencher um `[FALTA]`,
regerar, aprovar e publicar. **Pendente — fazer antes de considerar este
módulo pronto para uso real, mesmo em dev.**

### 7. Ponta a ponta com os 3 processos reais de dev (contrato='Fazer')

**Parcialmente feito.** Os 3 processos reais da fila de dev (11 - Raimundo
Neto, 16 - Ricardo Manuel Soares Almeida Brandão, 15 - Luís Miguel Cachola
Filipe) foram confirmados por `SELECT` antes de começar e continuam
exatamente como estavam (`contrato='Fazer'`, `estado='Ativo'`, sem nenhuma
linha de log nova) — confirmado por `SELECT` final.

O que foi testado a sério contra estes 3 processos:
- `consulta.fila_contratos` e `consulta.dados_contrato` reais para os 3
  (secção do wrapper acima).
- `contrato.marcar_redigido` real no processo 11, com reversão completa
  (secção do wrapper acima).
- Geração completa (`Gerador::gerar`, motor real + `soffice`/`qpdf` reais)
  **tentada** para os processos 16 e 11 via `php artisan tinker` (como
  `www-data`, o mesmo utilizador do job da app) com respostas manuais às
  perguntas pendentes:
  - Processo 11: ficou bloqueado em "Horas = 0 no processo e no contrato"
    — `horas` não é uma flag que a app aceite responder (fora da lista de
    `docs/09-CONTRATOS-CAMPOS-MAPA.md` "Flags de entrada"), porque na skill
    original esse valor vem sempre do Sabichão, nunca de uma resposta
    manual. Dado real de dev genuinamente incompleto (part-time sem horas
    definidas), não um bug do módulo.
  - Processo 16: avançou até ao `DocxEngine`, que **recusou corretamente**
    gerar o `.docx` porque sobrou texto de exemplo do modelo real
    (`"01/01/202..."` — um dos textos proibidos listados em `campos.md`).
    A causa é `validade_cartao` vazio (o processo não tem nenhuma validade
    de cartão preenchida para a especialidade VIG, forçada por flag porque o
    Sabichão de dev também não tem `clientes_servicos` nem cartão nenhum
    para decidir sozinho) — o motor recusou publicar um contrato malformado
    em vez de o fazer às escondidas. **Este é o comportamento correto e
    esperado** do `DocxEngine` (o mesmo teste "texto de exemplo sobreviveu"
    que a Fase 2 já tinha e que passou 45/45 contra casos reais publicados
    pela skill) — só não foi possível completar o fluxo até "publicar" com
    ESTES dados de dev especificamente, porque estão incomuns (sem
    `processo_contrato`, sem `cliente_equipa`, sem cartão).
  - Nenhum ficheiro foi publicado em `/srv/assistente-dev/contratos-feitos/`
    (pasta confirmada vazia no final). Nenhum `ContratoCaso` de teste ficou
    na BD da app (limpos por `tinker` no final).

**O que falta para fechar este ponto**: ou completar os dados de teste em
dev (dar um `processo_contrato`, uma `cliente_equipa` e um cartão válido a
um dos 3 processos, só para os efeitos deste teste, e reverter tudo depois
— não feito por seria uma escrita a mais no Sabichão além do que este
trabalho já autorizou), ou aceitar comparar contra um caso sintético
completo (já coberto pelos testes Pest) como prova suficiente do motor, e
reservar o "publicar um caso real da fila" para o primeiro uso genuíno do
módulo pelo utilizador (quando o Sabichão tiver os dados mínimos).

## Teste de ponta a ponta (23/09) — dois fictícios, HTTP + Playwright, ciclo completo

Testador independente (Sonnet), sozinho, sem Codex (quota esgotada), sem subagentes. Tudo em
DEV: CT 117 (assistente) + CT 100 (Sabichão dev). Nada em produção.

### 1. Dados de teste

Dois processos fictícios criados no Sabichão dev por `INSERT` direto (NIFs com checksum
válido, copiando a estrutura de um processo real — mesmo cliente `001/2026` RNM já usado
pelo processo 11): **999960** (Contrato Teste Sintetico, termo certo 6 meses, motivo f),
NIF 999960008) e **999961** (Contrato Teste Sintetico Dois, termo incerto — para testar o
modelo `incerto` em vez do `termo_certo`, NIF 999961004), ambos `contrato='Fazer'`,
`estado='Ativo'`, especialidade VIG com `val_vig` a 1 ano, cliente `cliente_equipa` ativa
com o RNM, 40h semanais. IDs anotados para limpeza: `processo`/`processo_contrato`
999960/999961, `cliente_equipa` id 30/31, `log_funcionarios` id_log 343/344 (criado mais
tarde pela publicação).

### 2. Fluxo HTTP (curl, login+CSRF reais)

Login (campo do formulário é `username`, não `email`), fila mostra os 2 fictícios,
"Redigir selecionados" despacha 2 jobs. Caso 999960 ficou `gerado_com_perguntas`
("Periodo de renovacao vazio no Sabichão") — respondido por `--renovacao "3 meses"` no
formulário de regerar da própria página, igual ao fluxo da skill. Caso 999961 falhou o
job (ver bugs abaixo); depois de corrigido, `gerado_completo`. Ambos publicados por
"Publicar em lote" — `contrato='Nao'` no Sabichão, `publicado_por_marcar=false`,
`log_funcionarios` com `user='Rui P. Barbosa'` e o texto exato pedido. Publicar outra vez
o mesmo caso devolve erro claro ("Falha a publicar: Caso já publicado.") sem tocar nos
ficheiros (mtime confirmado igual antes/depois).

### 3. Bugs reais encontrados e corrigidos

1. **[Infra] Diretórios de rascunho sem permissão para o grupo `www-data`.**
   `storage/app/private/contratos/rascunhos` e `.../contratos` estavam `700` (dono
   `claude`, grupo `www-data` sem nenhum bit) — resultado de testes manuais anteriores
   feitos por SSH como `claude` via `tinker`. A app real (job da fila, a correr como
   `www-data`) não conseguia criar a pasta de um `id_processo` novo:
   `RuntimeException: Nao consegui criar o ficheiro de saida`. Sem código a corrigir (o
   `Gerador` já usa `Storage::makeDirectory()` sem modo fixo); corrigido com
   `chmod -R g+rwX` nos diretórios existentes. Fica documentado aqui para o próximo
   `deploy`/reset destes diretórios não repetir o erro.
2. **[Código] `Conversor` (soffice) sem `$HOME` gravável.** O job (como `www-data`, cujo
   `HOME` de sistema é `/var/www`, só escrevível por root) falhava sempre a converter
   para PDF: `dconf-CRITICAL: unable to create directory '/var/www/.cache/dconf'` →
   `LibreOffice ... User installation could not be completed`. Um teste manual anterior
   por SSH tinha funcionado porque a sessão SSH tinha um `HOME` gravável — mascarou o
   problema. Corrigido em `app/Services/Contratos/Conversor.php`: perfil dedicado
   `storage_path('app/private/contratos/.soffice-home')` passado como `HOME` ao
   `Process` (`->env(['HOME' => $homeSoffice])`), nunca o `HOME` real do processo nem a
   pasta de publicação.
3. **[Código, P1] `ContratosController::ficheiro()` com tipo de retorno errado.** Assinado
   `: Response` (`Illuminate\Http\Response`) mas `response()->file()` devolve
   `Symfony\Component\HttpFoundation\BinaryFileResponse` — `TypeError` em **toda** a
   pré-visualização do PDF/DOCX (500 em produção real de uso, nunca testado antes porque
   não havia verificação em browser nem teste HTTP desta rota). Corrigido: tipo de
   retorno para `BinaryFileResponse`. Teste de regressão novo,
   `tests/Feature/Contratos/FicheiroTest.php` (4 casos: PDF, DOCX, 404 sem ficheiro,
   exige autenticação) — cobre exatamente este caminho que faltava.
4. **[UI] Estados técnicos em cru na fila e na página do caso** (`gerado_com_falta`,
   `gerado_com_perguntas`, etc., diretamente do enum da BD). Corrigido:
   `ContratoCaso::ESTADOS_LEGIVEIS` + `estadoLegivel()` ("Por redigir", "Perguntas
   pendentes", "Gerado — completo", "Gerado — com campos em falta", "Publicado"),
   usado em `contratos/index.blade.php` e `contratos/mostrar.blade.php` em vez de
   `$caso->estado` cru. (O histórico de eventos, que é auditoria técnica, mantém-se
   como estava.)

Todos os 4 confirmados corrigidos com sync + restart + repetição do passo que falhava, e
com a suite Pest completa a passar depois (284/284, antes 280 + os 4 testes novos de
`FicheiroTest`).

### 4. Ficheiros publicados — verificação a sério

`/srv/assistente-dev/contratos-feitos/Santo Tirso/` recebeu os 4 ficheiros esperados
(2×`.docx` + 2×`.pdf`). `qpdf --show-encryption` confirma AES-256, `User password =`
vazia (abre sem password), `modify anything: not allowed` — mesmo padrão da skill.
Comparação byte-a-byte do texto (`word/document.xml` sem tags) entre o `.docx` publicado
pela webapp e o gerado em paralelo por `php artisan contratos:gerar` com o mesmo JSON de
`consulta.dados_contrato` (rodado como `www-data`, os modelos só são legíveis por esse
utilizador): **idêntico nos dois casos** (23407 e 23022 bytes de texto, respetivamente).
Conteúdo confirmado à mão: nome, NIF (999960008/999961004), NISS, cliente RNM + morada
("Praça Vasco da Gama n116"), datas (início 23/09/2026, fim 22/03/2027 no termo certo),
duração "6 meses", 40 horas semanais, motivo da alínea f)/b) com a redação do Sabichão,
retribuição 1015,95 EUR.

### 5. Playwright (browser real, headless Chromium)

Login, fila (mostra "Perguntas pendentes"/"Por redigir" legível — confirma a correção do
bug 4 — e a secção "Histórico (últimos publicados)" com os 2 fictícios depois de
publicados), abrir o caso publicado (pill "Publicado"), voltar por "← Contratos", tema
escuro (aplicado a toda a página, confirmado por captura). Capturas em
`docs/capturas/{contratos-fila,contrato-caso-pdf,contratos-fila-voltar,
contratos-tema-escuro}.png` (fora do git, só com os fictícios).

Uma limitação **do ambiente de teste, não da app**: a área de pré-visualização do PDF
(`<iframe>`) ficou visualmente em branco no Chromium headless do Playwright — mas um
pedido direto ao mesmo URL do `src` do iframe, feito pelo próprio browser controlado,
devolveu `200`, `content-type: application/pdf`, `178109` bytes (o tamanho exato do
ficheiro no disco), confirmando que o conteúdo chega corretamente; o visualizador de PDF
embutido do Chromium simplesmente não renderiza dentro de `<iframe>` em modo headless
automatizado (limitação conhecida do Playwright/Chromium headless, não reproduz num
browser normal de utilizador).

### 6. Limpeza final (confirmada por SELECT)

Sabichão dev: `processo`, `processo_contrato` (999960/999961), `cliente_equipa` (id
30/31), `log_funcionarios` (id_log 343/344) apagados — `SELECT COUNT(*)` a 0 em todos.
Os 3 processos reais da fila (11, 15, 16) confirmados inalterados (`contrato='Fazer'`,
`estado='Ativo'`). App: `contratos_casos`/`contratos_eventos`/`contratos_versoes` dos 2
fictícios apagados (`ContratoCaso::count()` voltou a 3, só os casos pré-existentes dos
processos reais). Ficheiros publicados e pastas de rascunho de teste apagados do CT
(`/srv/assistente-dev/contratos-feitos/Santo Tirso/` e
`storage/.../rascunhos/{999960,999961}/`). `failed_jobs`/`jobs` a 0. Suite Pest final:
**284 testes, 765 assertions, sem regressão.**

## Perguntas pendentes visíveis + Fila de processamento (24/09) — duas melhorias pedidas pelo utilizador

Testador independente (Sonnet), sozinho, sem Codex (quota esgotada), sem subagentes. Motivo:
o utilizador selecionou os processos 11 e 15 na fila e carregou "Redigir"; os jobs correram
em ~250ms e os casos ficaram em `gerado_com_perguntas` (perguntas em `contratos_eventos`),
mas a fila não mostrava isso de forma óbvia — pareceu que "não saíram".

### 1. Perguntas pendentes visíveis e respondíveis

- Fila e página do caso mostram "À espera de respostas: Modalidade, Especialidade, Horas"
  (rótulos legíveis, `ContratoCaso::ROTULOS_PERGUNTAS`) em vez do estado técnico cru, com
  botão "Responder" na fila que salta direto para o formulário (`#perguntas`).
- A página do caso ganhou um formulário com o controlo certo por pergunta: `<select>` para
  modalidade (termo certo/incerto/sem termo), especialidade (a partir dos cartões do
  funcionário), documento e categoria; número + período para horas; data para data_fim;
  texto com sugestões (`datalist`) para renovação; pesquisa de cliente (reaproveita
  `consulta.clientes`, rota `/fichas/clientes` já existente) com fallback a nome/morada
  livres quando não há candidatos nem afetação ativa. Ao submeter, volta a chamar o
  `Gerador` logo (sem passo extra) e mostra o novo estado.
- `Calculadora::calcular()` passou a devolver `opcoes` (modalidade/documento/categoria
  fixas; especialidade a partir dos cartões do funcionário; cliente a partir das afetações
  ativas ou dos candidatos) — as selects deixam de depender de o utilizador escrever a
  chave técnica à mão. Modalidade/documento/categoria usam listas fixas em
  `ContratoCaso::OPCOES_*` (não o manifesto gravado) porque um manifesto antigo (gerado
  antes deste código) não tem `opcoes` — descoberto a testar os processos reais 11/15, cujo
  select de modalidade aparecia vazio sem isso.
- Nova pergunta respondível: **horas** (`--horas`/`--periodo-horas` equivalente por flag) —
  antes a skill e o motor só diziam "corrigir no Sabichão", sem forma de responder pela
  app; usada mesmo pelo processo real 11 (`nhoras=0` no Sabichão dev).
- **Bug real encontrado e corrigido a testar no browser** (não pelos testes unitários, que
  mockam `Cruzamentos` e nunca reproduziam isto): quando o caso já estava em
  `gerado_com_perguntas` e o utilizador respondia a uma pergunta mas o motor revelava uma
  pergunta NOVA em cascata (ex.: responder "modalidade" revela "duração"/"data_fim" que só
  se calculam depois de a modalidade ser conhecida), o estado técnico ficava igual
  (`gerado_com_perguntas` → `gerado_com_perguntas`) e o bloco de gravação de
  `Gerador::gerar()` só corria dentro de uma mudança de estado — o manifesto/flags novos
  ficavam só em memória, nunca gravados, e `$caso->fresh()` devolvia sempre os dados
  antigos. Para o utilizador isto parecia um ciclo sem fim: responder nunca avançava.
  Corrigido em `Gerador::gerar()` (grava sempre neste ramo, com ou sem mudança de estado
  técnico) — teste de regressão em `GeradorTest.php`.
- Bug real adicional: um candidato a cliente sem a coluna `estado` (dados reais do
  processo 11) rebentava com `Undefined array key "estado"` ao construir as opções do
  select — corrigido com `?? ''` nos três sítios que leem essa coluna; teste de regressão
  em `CalculadoraTest.php`.
- Teste automatizado (`GeradorTest.php`) confirma que a resposta a uma pergunta entra
  mesmo no **texto do `.docx` gerado** (não só no manifesto), com um modelo sintético que
  inclui o parágrafo "nas instalações da CLIENTE_NOME, sitas na CLIENTE_MORADA".

### 2. Fila de processamento (página nova, menu lateral, visível a todos)

- `FilaProcessamentoController` + `resources/views/fila-processamento/index.blade.php`,
  rota `/fila-processamento`. Lê a tabela `jobs` (payload Laravel desserializado com
  `unserialize()` — dados só da própria fila, nunca de entrada externa — para tirar o
  `casoId` de `LerCaso`/`RedigirContrato`) e liga a cada linha ao caso (MAI/processo +
  nome, com link).
- Em espera / a correr (tipo legível "Leitura de ficha"/"Redação de contrato", desde
  quando, tentativas); concluídos recentes das últimas 24h (a partir dos próprios
  `fichas_casos`/`contratos_casos`, resultado legível: "Por rever", "À espera de
  respostas", "Gerado, pronto a rever", "Erro técnico", etc.); falhados (`failed_jobs`,
  erro resumido à primeira linha, sem dados pessoais) com botão "Tentar outra vez"
  (`Artisan::call('queue:retry', ...)` pelo **UUID** — o driver dos failed_jobs é
  `database-uuids`, não o id numérico).
- Aviso "processamento parece parado" quando há um pendente há mais de 2 min e nada a
  correr; estado do leitor do mini via `LeitorClient::disponivel()`.
- Atualização automática a cada 10s (`<meta http-equiv="refresh">`, preserva o filtro na
  URL) e filtro Fichas/Contratos/Todos.
- "Redigir selecionados" e "registar ficha"/"lote" passam a mostrar, junto da mensagem de
  sucesso, a ligação "Ver na fila de processamento" (`session('sucesso_link')`, novo,
  renderizado sem escaping só para este link controlado pelo próprio controlador — nunca
  texto vindo do utilizador).

### 3. Testes

12 testes novos (Pest): `GeradorTest` (perguntas visíveis/respondíveis, resposta a entrar
no `.docx`, regressão da gravação em cascata), `CalculadoraTest` (candidato sem `estado`,
`opcoes`, resposta a "horas" por flag), `PerguntasPendentesTest` (fila e página do caso),
`FilaProcessamento\IndexTest` (autenticação, listagem em espera/falhados/filtro/retry/
aviso de trabalhador parado, com dispatch real para a ligação `database` da fila — sem
mocks do payload serializado). Suite completa no CT 117: **298 testes, 834 assertions,
sem regressão** (284→298 = as 12 novas + 2 do ajuste ao formulário de perguntas).

### 4. Browser real (Playwright) contra os processos reais 11, 15, 16

Os três estavam mesmo em `contrato='Fazer'`/`estado='Ativo'` no Sabichão dev (confirmado
por uma leitura direta via `consulta.dados_contrato`, chave só de consulta, antes e depois
de todo o teste). Fila → "Redigir selecionados" (processo 16, os outros dois já tinham
sido redigidos antes desta sessão pelo próprio utilizador — é o caso real do relatório do
bug) → ligação "Ver na fila de processamento" → confirmados lá "Concluídos recentes" com
o resultado "À espera de respostas" para os três. Abertos os três, respondidas as
perguntas ronda a ronda (a especialidade escolhida em cada select é sempre uma das opções
reais mostradas, nunca inventada) até não haver mais perguntas ou surgir um erro real:

- **Processo 11** (Raimundo Neto): 2 rondas (modalidade/especialidade/horas, depois
  duração/data_fim/renovação) → **"Gerado — com campos em falta"**, `.docx` servido
  (200, 37922 bytes; sem PDF porque ficou `[FALTA]` — documento de identificação caducado
  no Sabichão, aviso do próprio motor).
- **Processo 15** (Luís Miguel Cachola Filipe): 2 rondas (modalidade/cliente, depois
  duração/data_fim/renovação) → **"Gerado — completo"**, PDF servido (200,
  `application/pdf`, 178693 bytes).
- **Processo 16** (Ricardo Manuel Soares Almeida Brandão): 1ª ronda OK
  (modalidade/cliente/especialidade), 2ª ronda (duração/data_fim/renovação) falhou com
  **"DocxEngine não gerou o .docx: Texto de exemplo do modelo sobreviveu: '01/01/202'"** —
  bug real no preenchimento do modelo `Contrato_Termo_Certo_v2.docx` real para os dados
  concretos deste processo (mesma família dos bugs de âncora do `DocxEngine` já
  documentados no histórico da skill, ex. o bug do modelo ARD de 04/09). **Não investigado
  nem corrigido nesta entrega** — fora do âmbito pedido (perguntas pendentes + fila de
  processamento); o caso ficou em `gerado_com_perguntas` (duração/data_fim/renovação),
  nada perdido, nada publicado.
- Confirmado por leitura direta ao Sabichão dev, no fim: os três processos continuam
  `contrato='Fazer'`, `estado='Ativo'` — nada publicado, como pedido. Capturas em
  `docs/capturas/fase3b-*.png` (fora do git).

### 5. Pendências desta entrega

- O bug do processo 16 (texto de exemplo sobrevivente no `.docx` real) fica por investigar
  — precisa dos dados reais desse processo para reproduzir (o modelo sintético dos testes
  não expõe este caminho).
- `codex review`: quota esgotada, instrução explícita do utilizador para não usar Codex
  nesta tarefa — revisão por repetir quando a quota voltar.
- O primeiro "publicado" com um processo real genuíno continua por fazer (os 3 da fila têm
  dados incompletos no Sabichão dev, por natureza — não é suposto forçar isso).

## Paginação verificada com o LibreOffice (24/09/2026)

Problema reportado pelo utilizador: os PDFs gerados no CT (LibreOffice) não respeitavam a
paginação dos originais feitos pela skill `/fazer-contrato` com o Word — partiam o bloco
"elaborado em duplicado" + data + assinaturas no fim, e tinham quebras em sítios diferentes
dos 12 contratos publicados pela skill (todos com 15 páginas). Causa: até aqui o motor PHP
(`Paginacao::arrumarEstatico`, porte das regras **estáticas** da skill) corria sozinho, sem
o equivalente ao passo **dinâmico** da skill (perguntar ao Word em que página cai cada
parágrafo e corrigir o que sobrar) — decisão assumida em `docs/CONTRATOS-DECISOES.md` #2
("sem paginação dinâmica no MVP"), agora substituída por esta entrega.

### O que mudou

- **Fontes Aptos/Aptos Display instaladas no CT 117** (`/usr/local/share/fonts/aptos` +
  `fc-cache`) — sem elas o LibreOffice substitui a fonte do modelo por outra com métricas
  diferentes e pagina de forma completamente distinta (o contrato do processo 15 passou de
  17 para 14 páginas só com esta instalação, antes de qualquer código novo). A instalar
  também em prod (CT 107) quando o módulo lá chegar — nota de licença da Aptos (vem com o
  Office 2024/365) por decidir pelo utilizador antes de replicar; ver `docs/DEPLOY.md`.
- **`App\Services\Contratos\PaginacaoVerificada`** (nova classe, chamada por `Gerador`
  depois das regras estáticas de `DocxEngine`/`Paginacao::arrumarEstatico`): usa o
  LibreOffice como oráculo de páginas — porte do passo (b) "com o Word" de
  `~/.claude/skills/fazer-contrato/paginacao.py`, mas com `pdftotext -layout`
  (poppler-utils) em vez do AppleScript do Word, que não existe no CT. Localiza os
  parágrafos "marcador" (títulos de cláusula, "elaborado em duplicado", a linha da data
  "Santo Tirso, ...", "O PRIMEIRO/SEGUNDO OUTORGANTE", "Anexo I") em cada página do PDF e
  aplica as mesmas 4 correções da skill, por esta ordem, até 4 iterações
  (converter→verificar→corrigir→repetir):
  1. Bloco duplicado+data+assinaturas partido entre páginas → `pageBreakBefore` no início
     do bloco (remove os parágrafos vazios que ficam órfãos antes dele).
  2. Título de cláusula sozinho no fundo de uma página → `pageBreakBefore` no título.
  3. Página a começar com parágrafos vazios antes de um marcador → remove esses vazios.
  4. Anexo I em página par → insere uma página em branco **real** antes dele (parágrafo com
     `pageBreakBefore`, não uma secção "página ímpar" — o Word não mostra essa página em
     branco no ecrã e salta a numeração, o mesmo problema que a skill já tinha resolvido
     para o Word; decisão do utilizador, 24/09/2026, substitui `docs/CONTRATOS-DECISOES.md`
     #2 nesta parte). Por causa disto, `DocxEngine` deixou de gerar o Anexo I com a secção
     `oddPage` (modo `seccao_impar`) e passou a usar sempre `pageBreakBefore` simples (modo
     `quebra`) — é `PaginacaoVerificada` que decide, com o LibreOffice a sério, se é preciso
     a página em branco.
  - Se ao fim de 4 iterações ainda houver problema (ou o passo falhar, ex. `pdftotext`
    indisponível), o caso **não publica calado**: fica com um aviso legível "Paginação por
    rever: ..." no manifesto e desce (ou fica) em `gerado_com_falta` em vez de
    `gerado_completo`, mesmo com `n_falta=0` — o utilizador vê o aviso na página do caso
    antes de publicar.
  - Cada geração regista em `contratos_eventos` o resultado completo
    (`paginacao_verificada => {ok, paginas, correcoes, aviso}`), para auditoria.

### Testes

- `tests/Unit/Contratos/PaginacaoVerificadaAnaliseTest.php` (9 testes): a parte pura
  (mapear marcadores + detetar problemas a partir de texto de página já extraído) sem
  LibreOffice nem `pdftotext` — corre sempre, mesmo fora do CT.
- `tests/Feature/Contratos/PaginacaoVerificadaTest.php` (2 testes, `->group('libreoffice')`,
  salta automaticamente sem `soffice`/`pdftotext`): gera um `.docx` sintético longo (sem
  dados pessoais) com um bloco de assinatura no fim e confirma que, **sem** as regras
  estáticas, o LibreOffice real o parte entre páginas (reproduz o relato do utilizador); com
  `PaginacaoVerificada` por cima, o bloco fica sempre numa página só. Testado no CT 117: a
  transição de 1 para 2 páginas do modelo sintético cai por volta de 21-24 parágrafos de
  enchimento — a gama testada (16-32) tem folga para outras versões do LibreOffice.
- `tests/Feature/Contratos/GeradorTest.php`: `PaginacaoVerificada` mockada (`ok=>true`) nos
  testes que geram um `.docx` completo — o motor (Calculadora/DocxEngine) continua a testar-se
  sem depender do LibreOffice; a integração a sério tem os testes acima.
- Suite completa no CT 117 (24/09/2026, PHP 8.4): **309 testes, 860 asserções, tudo verde**
  (298 antes desta entrega + 11 novos: 9 unitários + 2 de integração).

### Validação real no CT 117 — processo 15 (dev)

Regenerado pela app (`http://192.168.69.150`, utilizador `rh`, HTTP com CSRF real,
`POST /contratos/redigir-selecionados`), **sem publicar**. Resultado gravado em
`contratos_eventos` (evento `regenerado`, versão 2):

```
paginacao_verificada: {"ok":true,"paginas":15,
  "correcoes":["iteração 1: Anexo I calhava em página par: inserida página em branco real antes dele"],
  "aviso":null}
```

Confirmado por `pdftotext -layout` a sério sobre o PDF gerado (só posições/contagens de
marcadores lidas, nunca o conteúdo pessoal copiado para fora do CT):

| Verificação | Resultado |
|---|---|
| Nº de páginas | **15** — igual aos 15 dos 12 contratos publicados pela skill com Word |
| Bloco duplicado+data+assinaturas | Página 13, tudo junto (nada partido) |
| Títulos de cláusula órfãos no fundo de página | Nenhum |
| Página a começar com linhas vazias | Nenhuma |
| Anexo I | Página 15 (ímpar); página 14 fica em branco (inserida pela correção 4, porque o Anexo calhava originalmente em página par) |

`processo.contrato` no Sabichão dev confirmado por `SELECT` a seguir: `id_processo=15 →
'Fazer'` (não tocado, como pedido).

O processo 11 (também pedido para validação) já estava em estado `publicado` na app
(`contratos_casos`, sessão anterior de Fase 3, antes desta entrega) — estado terminal por
desenho (`docs/10-CONTRATOS-ESTADOS.md`); `redigirSelecionados`/`Gerador::gerar` recusam
deliberadamente regenerar um caso publicado, e esta entrega respeitou essa regra em vez de
contornar o estado só para o teste. `processo.contrato` do 11 no Sabichão dev confirma
`'Nao'` (publicado nessa sessão anterior, não nesta). A validação de ponta a ponta desta
entrega ficou feita com o processo 15 (acima) mais o teste de integração com LibreOffice
real (acima) — cobertura equivalente sem violar o estado terminal do 11.

### Pendências

- `pdftotext` só localiza **marcadores** (títulos de cláusula, bloco de assinatura, Anexo
  I), não cada parágrafo como o Word faz para a skill — suficiente para os problemas reais
  documentados (histórico 02-04/09 da skill), mas um problema de paginação fora desse
  vocabulário (ex. um parágrafo de corpo qualquer partido a meio) não é apanhado.
- Fontes Aptos ainda só no CT 117 (dev) — replicar em prod (CT 107) fica para quando o
  módulo Contratos for instalado lá, com a nota de licença por decidir primeiro.

## O que falta (não feito nesta entrega)

- ~~Verificação em browser com Playwright (secção 6)~~ — feita em 23/09 com dois
  processos fictícios, ver "Teste de ponta a ponta (23/09)" acima.
- ~~Fechar o ponta a ponta até "publicado" (secção 7)~~ — feito em 23/09, mas com
  processos **fictícios** criados só para este teste (os 3 processos reais da fila
  continuam com dados incompletos, ver secção 7 acima); o primeiro "publicado" com um
  processo real genuíno continua por fazer.
- `codex review` da ronda 3 dos achados (10, autorevisão) e de qualquer
  commit grande seguinte — quota esgotada a meio deste trabalho, repetir
  depois do reset.
- Item 9 da lista de achados (regeneração de auditoria pós-publicação).
- Conta NAS de escrita dedicada (`Assistente/Contratos Feitos` real) — por
  criar pelo utilizador no DSM; até lá, `CONTRATOS_SAIDA_PATH` continua a
  apontar para a pasta de teste em `/srv/assistente-dev/contratos-feitos/`.
- Instalação em prod (CT 107): **nada foi tocado**, como pedido — nem
  `qpdf`/`libreoffice` nem o wrapper novo.

### Correção do diagnóstico do processo 16 (24/09/2026)

A recusa "Texto de exemplo do modelo sobreviveu: '01/01/202'" era um **falso
positivo**: a data de início real do processo é 01/01/2026, que contém o texto
proibido "01/01/202". Não sobrava nada do modelo. O mesmo podia acontecer com
"22 de julho"/"21 de julho" numa data de assinatura ou "123456" num valor
real. Correção em `DocxEngine::preencherDocx`: antes de procurar os textos de
exemplo, retira-se UMA ocorrência de cada valor inserido nos slots (e do
`doc_texto`); um exemplo que fique mesmo no modelo continua a ser apanhado
(teste novo). O texto gerado não muda, só a verificação.

Validado em dev: processo 16 → `gerado_com_falta` (só `validade_cartao`, que
o Sabichão dev não tem) com PDF de revisão; valor escrito à mão → regerado
pelo Word, `gerado_completo`, 15 páginas, 70 s, uma marca cinzenta no PDF de
revisão, `.docx` final sem cores.

A skill `fazer-contrato` (gerar_contrato.py, linha ~824) tem a mesma
verificação e o mesmo falso positivo; não foi alterada.
