# Changelog

Todas as alterações relevantes ao Segunor Intel Grid.

## [O diagrama da rede, legível] — 2026-07-27

Reportado: com muitas ligações "não se vê nada". O caso era a
**PRICEWATERHOUSECOOPERS & ASSOCIADOS - SROC**, e eram três problemas sobrepostos.

### Um auditor liga-se a meio país

A PWC tem grau 506: **430 são ligações de fiscalização** — é o ROC de 430
empresas. Nacionalmente são 9.850 arestas de auditoria, concentradas em poucas
firmas, e nenhuma delas diz o que quer que seja sobre propriedade.

Os filtros passaram a famílias com defeitos que respondem à pergunta certa:
**sócios e gerência ligados, fiscalização, cônjuges e cargos menores desligados**
— a um clique de distância para quem os quiser. Medido na PWC a 1 hop: **43 nós
e 59 arestas** por defeito, contra 372 e 492 com a fiscalização ligada.

A guarda anti-hub também tinha de mudar: decidia pelo `degree_current`, que conta
**todos** os tipos, portanto a PWC continuaria a contar 506 numa vista onde tem
54. Passa a contar sobre as arestas efectivamente lidas — sem consulta a mais.

### Os nomes tinham 160 caracteres

O nó chamava-se `PRICEWATERHOUSECOOPERS & ASSOCIADOS - SOCIEDADE DE REVISORES
OFICIAIS DE CONTAS, LDA representada por Nuno Miguel da Costa Guimarães Cordeiro
Tavares (ROC 1838)` — dois factos colados, a firma e o representante. A caixa do
diagrama corta aos 26 caracteres, portanto entidades diferentes apareciam com a
mesma etiqueta.

O parser passa a cortar ` representad[ao] por `, o `, nº NNN` das SROC e as aspas
soltas da fonte. **181 entidades limpas**, e nenhuma mudou de identidade porque
todas têm NIF (a `entity_key` vem do NIF, não do nome).

Nota: teve de ser uma passagem própria, `clean_entity_names()`, e não um reparse.
O `upsert_entity` só substitui o nome **quando o novo é mais longo** — regra que
existe por bom motivo — portanto um nome encurtado nunca ganharia ao que lá
estava, e reparsear 60 mil actos não corrigia uma linha.

### O diagrama

Passou a `@xyflow/react` + `dagre`: zoom, arrastar, enquadrar, minimapa e ecrã
inteiro. O layout continua **em camadas da esquerda para a direita** — propriedade
é hierárquica e um force-directed dá um novelo onde não se percebe quem detém
quem — mas a colocação é nossa, porque o `dagre` puro punha os 43 nós do mesmo
nível numa coluna de três mil pixéis e o enquadramento automático encolhia tudo a
fatias de oito pixéis. Um nível largo dobra-se agora em várias colunas, e o
`fitView` tem `minZoom` para nunca reduzir abaixo do legível.

Isto **só apareceu ao tirar um screenshot**. O primeiro estava tão ilegível como
o que se queria corrigir, e o mesmo método apanhou o minimapa em branco — o
`MiniMap` não desenhava nó nenhum porque as dimensões não estavam declaradas.

Interacção: abre a **1 hop** e cresce onde alguém carregar no `+` (junta ao que
já está desenhado, sem recarregar); clicar num nó ou numa ligação realça o
subgrafo e esbate o resto; a ligação seleccionada mostra o cargo ou a quota, o
acto e a data; duplo clique põe a entidade no centro.

## [Defeitos da segunda auditoria] — 2026-07-26

Seis problemas reportados na segunda ronda de auditoria. **Dois não eram
defeitos** — verifiquei-os contra o mecanismo antes de os corrigir, e em ambos os
casos a conclusão original tinha saído de uma contagem sem olhar para o código:

* os 524 anúncios sem `claim_days` **têm** prazo, calculado pelo defeito legal e
  marcado `claim_deadline_estimated`;
* os 73 clientes sem risco têm **zero processos** — o pipeline está certo, o que
  estava mal era a ficha chamar-lhe "sem dados".

### Sócios sem NIF desapareciam do diagrama

67 arestas actuais em 29 empresas têm titular sem NIF — estrangeiros e não
identificados. A travessia indexava os nós **por NIF**, portanto esses ficavam de
fora e o frontend descartava a aresta em silêncio: a ficha listava o sócio, o
diagrama não o desenhava, e nada dizia que faltava ali alguém.

Passa a indexar por `entity_key`, que existe para todas as entidades. Medido numa
das 29: **18 nós sem NIF passaram a aparecer**, zero arestas penduradas.

### A truncagem cortava por ordem de NIF

Ao bater no tecto de 400 nós ficava `sorted(nifs)[:N]` — um concorrente com
insolvência aberta caía a favor de uma entidade anónima com NIF mais baixo. A
ordem passa a ser monitorizadas, insolvências, grau — **dentro do SQL**, para não
se lerem cinco mil linhas e ficar com quatrocentas.

### A cobertura custava 300 ms em todas as respostas do grafo

Apanhado ao medir a correcção anterior: o bloco de cobertura são cinco `count(*)`
sobre tabelas que já vão em 934 mil actos, e viaja em **todas** as respostas.
Media 250–350 ms enquanto a travessia em si custava 20. Com um minuto de cache, a
vizinhança da SEGUNOR passou de **377 ms para 10 ms**.

### `refresh_degrees` não escalava

Uma subconsulta correlacionada por entidade sobre a tabela toda: 4 mil entidades
não se notavam, 42 mil sim, e cresce ao quadrado. Passa a uma agregação única com
`LEFT JOIN` — que não é decorativo: sem ele um nó que perde a última aresta
ficaria com o grau antigo para sempre.

### Moeda em branco lida como euros

30 arestas actuais têm valor sem moeda porque o acto escreve `QUOTA : 1.000,00` e
mais nada. O `money()` só distinguia "não-euro" de euro, portanto desenhava `€`.
Numa empresa dos anos 90 isso pode ser escudos, e a diferença é de duzentas vezes.
Passa a imprimir o número com "(moeda não indicada)".

### "Sem dados" não é o mesmo que "sem processos"

Um cliente sem processos mostrava *"sem dados"*, que se lê como "não recolhemos
nada" quando a verdade é "procurámos e não há nada". `coverage.mark_checked()`
regista a passagem de um scraper mesmo sem achados, e o `RiskBadge` distingue os
dois. Acrescenta-se `risk.refresh_all_risk()`, que escreve **nulo** e não zero
quando não há processos — zero lê-se como avaliação feita.

Resultado: dos 87 clientes, 73 passaram a "sem incidentes"; dos 64 concorrentes,
11. Os 17 `related` continuam "sem dados", e correctamente: nunca entraram em
scraper nenhum.

## [Recolha em duas máquinas] — 2026-07-26

Faltavam 805 mil actos por abrir e o CT sozinho levava sete dias e meio. O que o
limita não é processador: é o tecto de pedidos ao IRN, que é uma escolha de
cortesia nossa e é **por endereço IP**. Um segundo trabalhador só ajuda se sair
por outra linha — e o Proxmox de casa sai por `149.90.6.67` contra os
`93.108.98.103` do escritório.

A fila já estava preparada desde a Fase 9: `take_content` reclama com
`FOR UPDATE SKIP LOCKED` e devolve sozinha o que fica reclamado há mais de 15
minutos. Só nunca tinha tido um segundo trabalhador.

**O trabalhador remoto só busca HTML.** Quem parseia e escreve arestas continua a
ser o CT, e isso não é arrumação: hoje mesmo o `REGISTRY_PARSE_VERSION` subiu para
2, e uma máquina lá fora com a versão antiga escreveria dados velhos sem nada
acusar. Também não conhece a base de dados nem tem credenciais dela.

- `registry_sweep.ingest_content()` e `release_content()` extraídas do
  `process_content`: o ciclo local e o endpoint remoto passam pelo mesmo código.
- `RateLimiter` mudou-se para `scrapers/base.py` — no `registry_sync` arrastava a
  camada de base de dados atrás de si. Continua a haver uma implementação só, e
  ser por processo é o correcto: cada IP tem o seu tecto.
- `POST /api/v1/registry/claim` e `/deliver`, com chave própria em
  `app_settings.registry_worker_key`. Não usa a tabela `api_keys` de propósito:
  as chaves de lá são de leitura e reclamar trabalho é outra coisa.
- `tools/irn_worker.py` — ciclo sem base de dados, reutiliza o `MjIrnClient` do
  repositório. Instruções em `tools/README-worker.md`.
- Em casa: LXC 112 (`intel-worker`), túnel SSH permanente para o CT com a chave
  restrita a `permitopen="127.0.0.1:80"`, duas unidades systemd.

Medido: 110 actos/min passaram a **214**. Quando a fila esvaziar, param-se as duas
unidades e apaga-se a chave.

## [Fase 10] — 2026-07-26

Auditoria feita à base de dados de produção, sem alterar nada, e depois as
correcções do que ela encontrou. Os três defeitos principais são todos da mesma
família do da Fase 9: afirmações sobre propriedade apresentadas com a
tranquilidade de quem tem a certeza, e falsas.

### Um titular é uma linha, não uma por quota

O modelo guarda **uma aresta por quota** — está certo, é o que a fonte publica —,
mas três consumidores tratavam cada aresta como se fosse um sócio. A RAMAZON
detém cinco quotas da CENTRALMED e aparecia cinco vezes, com 40%, 27,6%, 12,4%,
15% e 5%, quando é um sócio único com 100%. São 48 empresas nesta situação.

Pior do que a lista: o bloco "face à estrutura anterior" fazia `FULL OUTER JOIN`
por titular sem agregar, portanto cinco quotas antes vezes cinco agora davam 25
linhas de mudanças que nunca aconteceram.

- `registry_query.entity_detail` agrega cap table, participações e as duas pontas
  do diff por titular; a ficha mostra `· 5 quotas` quando há mais do que uma.
- `api/people.py` e `api/v1/dossier.py` agregam da mesma maneira, para o Sabichão
  não contradizer o Intel Grid.
- A comparação com a estrutura anterior passa a exigir um acto **elegível**
  (mesmos dois degraus do `_captable_act`), e a não existir nenhum não se mostra
  nada — a GÁLIA apresentava os cinco sócios como "entraram" só porque não havia
  fotografia anterior com que os comparar.

### Percentagens sobre o capital, e as quotas anónimas à vista

A POLYEDRA dizia que uma empresa espanhola com 100,00 € detinha **100%** de uma
sociedade com 4.714.481,44 € de capital. A percentagem era calculada sobre a soma
das quotas *que têm titular*, como se as outras não existissem — e o acto lista
cinco sem nome. A GÁLIA dava 20% a cada um de cinco sócios de 1.000 € num capital
de 755.000 €.

- Migration `0028`: `parse_quota_orphan_total` no acto, `captable_unidentified` e
  `captable_base` na entidade. Sabia-se **quantas** quotas anónimas havia; faltava
  saber quanto valiam.
- `registry_graph._share_base`: o denominador é o capital do acto quando é maior
  ou igual à soma identificada, senão a soma mais as órfãs, senão só o
  identificado. A guarda existe porque em actos de aumento o `parse_capital` é por
  vezes o *montante do aumento* — usá-lo às cegas daria percentagens acima de 100.
- A ficha ganha a linha `8 quotas sem titular identificado · 750.000 € · 99,3%`,
  a partir de meio por cento (abaixo disso é arredondamento, não sócio escondido).
- `REGISTRY_PARSE_VERSION` sobe para 2 e `reparse_stored_acts()` reprocessa o
  corpus guardado sem um pedido à rede: 3984 actos, 1130 entidades.
- A vigia de `wipeout_candidates` deixa de contar como falha o que a fonte
  declaradamente não nomeia: de 16 empresas com desvio para 5 por explicar.

### A timeline registava corridas do scraper, não alterações

O `ON CONFLICT DO UPDATE SET last_seen_at = now()` faz `xmax <> 0` em toda a
re-observação, e o código gravava um evento `updated` de cada vez, com o mesmo
conteúdo. O processo 1846/26.5T8STS tinha **120 eventos, 118 idênticos**; a tabela
inteira tinha 16.736 `updated` para 2.272 payloads distintos, em 107 MB.

- `ingest.upsert_process` compara o estado anterior com o novo — tribunal, juízo,
  espécie, qualidade, data de distribuição — e só grava evento quando algo muda,
  com o diff no payload em vez do `raw` outra vez. O upsert passa também a
  escrever os campos que podem mudar, senão a mesma alteração seria detectada em
  todas as corridas seguintes.
- Limpeza autorizada do histórico, com `pg_dump` da tabela antes: 14.888 eventos
  redundantes apagados (mantendo a primeira ocorrência de cada payload distinto
  por processo), 107 MB → 49 MB. O 1846/26.5T8STS fica com 4 eventos.
- A ficha do processo mostra "Seguido desde … · visto pela última vez a …", que é
  o que os 118 eventos diziam, numa linha.

### Publicações oficiais por data

Ordenavam por relevância e só depois por data: com 274 "high" e 3616 "low", a
primeira página era de publicações antigas importantes e as recentes ficavam
escondidas — lia-se como se estivesse baralhada. Passa a cronológica pura; filtrar
por relevância é o que o botão "ocultar irrelevantes" já fazia.

### Mais cinco, pequenos

- **Prazos expirados** — `/deadlines` exclui o que já passou, com
  `include_expired` para quem quiser o histórico. Hoje não se via porque não há
  clientes insolventes; é precisamente por isso que tinha de ser tratado antes de
  haver.
- **`still_in` colidia em titulares sem NIF** — a chave era o NIF e há 392 arestas
  sem ele: bastava um estrangeiro continuar na empresa para os outros aparecerem
  como "alterado" em vez de "saiu". Passa a ser o id da entidade.
- **`/people/{nif}/companies`** ganha os três estados que só existiam do lado da
  empresa. Quem entrava pela pessoa continuava a ver a leitura que dizia que o
  José Ribeiro tinha saído.
- **`acts_count`** era escrito só no `apply_parse`, ou seja quando um acto era
  *parseado*; a listagem nacional acrescenta actos sem parsear e o contador ficava
  para trás — a Trablisa dizia 59 e tinha 97. Passa a ser acertado no fim do
  varrimento. 81 entidades corrigidas.
- **Cônjuges repetidos** — 349 arestas `spouse_of` duplicadas, uma por acto onde o
  casal aparece. Não se apagam (cada uma é um facto de um acto), mas a ficha passa
  a mostrar um cônjuge uma vez.
- **Corpos de uma linha** — 322 actos cujo "corpo" era o resumo da publicação
  (`EULEN PORTUGAL · Lisboa · Conversão total(online)`) contavam como conteúdo
  lido. Voltaram à fila com prioridade 0.

### Uma correcção ao relatório da auditoria

O relatório dizia que 394 corpos estavam truncados aos 4.000 caracteres pelo
`mj_irn_sync`. Estava errado: o `registry_backfill` já trata esse caso e há zero
actos nesse estado. Os 394 são corpos *curtos*, que é outro problema e menor —
o dos 322 acima.

## [Fase 9] — 2026-07-26

Três correcções, das quais duas eram defeitos meus da fase anterior.

### A fila de conteúdo estava a estoirar desde o início

O `take_content` reclama um acto marcando-o `running`, mas o CHECK da migration
0023 não permitia esse valor. O job morria no **primeiro** acto, sempre: fechava
em 1 segundo com `erro` e 0 linhas, com o orçamento intacto e sem um único pedido
a sair para o IRN. Os actos lidos ficaram presos nos 2734 do backfill enquanto a
listagem nacional acumulava 193 mil por abrir, e o grafo parou de crescer — que
era o objectivo da fase inteira.

O job **falhou alto** e ficou registado no `scraping_logs`, como devia. O que
faltou foi olhar para esse log em vez de assumir, pelos números da listagem a
subir, que a outra metade também andava.

- Migration `0027`: `running` no CHECK, mais `content_claimed_at` em coluna
  própria — reutilizar o `content_fetched_at` dar-lhe-ia dois significados.
- `take_content` recupera reclamações penduradas há mais de 15 minutos. Sem isso,
  um restart do contentor perdia os actos em curso para sempre; a 193 mil actos
  não se resolve à mão como se resolveu com os dias do varrimento.

### Os prazos não eram prazos

Reportado a usar o portal: *"o prazo para reclamar créditos apenas se aplica aos
meus clientes, não à concorrência"*. Ao olhar para os dados era pior — os **12
"prazos activos" eram todos de entidades no papel de credor**, 10 de concorrentes
e 2 de clientes. Esse prazo é da entidade e foi exercido ao ser listada; não é
acção da Segunor para ninguém.

O critério passa a ser um só, explícito na API, nos emails e no widget: **um
cliente declarado insolvente**, que é o caso em que há facturas por cobrar. A
página explica-o quando está vazia — e está, porque é a verdade actual. Os 562
hits de credor ficam na base de dados e visíveis em Anúncios.

### Os clientes eram invisíveis

A pesquisa global procurava só em `process_parties`, e como os clientes estavam
fora dos scrapers judiciais tinham zero processos e zero resultados: não estavam
mal apresentados, eram **inalcançáveis**.

- Pesquisa em três famílias — empresas monitorizadas, entidades do registo
  (pessoas incluídas) e partes de processos —, cada uma com o seu link.
- Página **Clientes**, que reutiliza a tabela parametrizada por tipo e portanto
  mostra só clientes.
- O tipo `MonitoringType` listava três valores e mentia sobre 104 linhas; o
  `MonitoringTypeBadge` não tinha ramo para `client` e **a ficha de um cliente
  dizia "internal"**. Três endpoints devolviam 422 para `client`.
- Clientes entram na distribuição, no CIRE e nos contratos públicos: um cliente
  com execuções é o sinal de risco de crédito que interessa, e a ficha dizia
  "Processos (0)" para todos os 87.

### Dois bugs apanhados pelo caminho

- **A pesquisa de anúncios por nome estava partida desde a Fase 7.** Com asyncpg
  o `%%` não colapsa e o operador trigram fica literalmente `%%`, que não existe.
  Por NIF entra por outro ramo do SQL e funciona — e por NIF é como se costuma
  entrar, por isso ninguém deu por isso.
- **O alarme do parser media o denominador errado** e disparava a 23%. Das 36
  constituições sem quotas, 21 tinham corpo truncado e 14 não tinham secção de
  quotas nenhuma; só 1 era candidata a falha real. O denominador passa a ser
  "actos cuja secção existe mesmo no texto" e o limiar aperta para 5%. Um alarme
  que dispara sempre é ignorado, que é a falha silenciosa que ele existe para
  apanhar.

## [Fase 8] — 2026-07-26

Grafo de relações societárias, a partir dos actos do registo comercial. O pedido
era ter "um autêntico intel em que vai cruzando dados e ligando tudo": quem é
sócio de quê, que participações essa pessoa tem noutras empresas, e os incidentes
de cada empresa ligada.

### O que estava errado

O card "Relações com outras empresas" era derivado do `process_parties` — partes
de processos judiciais. **578 420 das 588 966 partes têm o papel de credor**,
portanto o que a tabela dizia era "estas duas empresas partilham alguém que
reclamou créditos numa insolvência de cada uma". Não é uma relação entre as
empresas, e tinha três limites estruturais: `CHECK (source < target)` forçava
arestas não dirigidas, o filtro de NIFs 1/2/3 excluía por construção os sócios que
são empresas, e a chave `coalesce(nif, lower(name))` fundia homónimos.

**A informação verdadeira já existia e nunca tinha sido extraída, por causa de um
espaço.** O `mj_people_parser` exigia `NIF/NIPC:` colado ao fim de linha e o
conteúdo do IRN traz essa linha indentada. Resultado: 15 pessoas extraídas de 2252
publicações guardadas. Com a correcção, as mesmas publicações dão 2383 arestas.
Falhou em silêncio durante meses e nada acusou — daí a detecção de parser morto
passar a ser requisito.

### Novo modelo

- `registry_entities` / `registry_acts` / `registry_edges`, **fora do `companies`**:
  essa tabela é o universo vigiado (205 linhas, com risk score e um scraper por
  NIF) e enchê-la com entidades nacionais estragaria todos os ecrãs e jobs que
  iteram sobre ela. A ligação faz-se por NIF, e há um `promote` explícito.
- Arestas **dirigidas** com quota numérica, moeda, percentagem, validade e o acto
  de origem. As participações cruzadas — "a xyz tem quotas na abc" e o inverso —
  são duas linhas, coisa que o modelo antigo não conseguia representar.
- `edge_type` na chave de unicidade, o que mata o defeito do `publication_people`:
  o índice `(publication_id, person_nif)` colapsava a dualidade sócio+gerente
  exactamente nas constituições, onde é a norma.
- **Cônjuges como arestas.** Vêm sem NIF, portanto o nó é chaveado por nome e
  nunca se funde automaticamente. O que os torna úteis é herdarem a morada do
  titular: nome igual mais rua e código postal iguais dá uma sugestão de confiança
  alta em `registry_identity_links`, para alguém confirmar. A travessia só
  atravessa o que for confirmado.

### Regra de estado actual, e o modo de falha que ela evita

Os actos de cap table publicam a estrutura **completa**, não o delta, portanto a
estrutura actual é o conjunto de arestas do acto mais recente elegível. A
elegibilidade é `parse_quotas > 0` e **nunca o título do acto** — umas alterações
ao contrato que só mudaram a sede apagariam os sócios em silêncio.

A vigia de apagão disparou no primeiro backfill e apanhou algo que se teria
tornado um erro invisível: **19 dos 246 actos com quotas listam uma quota sem
titular**, e em 5 deles o valor órfão é exactamente o que falta para fechar o
capital declarado. No `510036899`, a estrutura verificada de 2020 (três titulares,
213.684,21 EUR) seria substituída pela do acto de 2023, que lista três quotas mas
só nomeia duas, somando 10.685,21 — a empresa perdia o maior sócio e ficava com
aspecto normal. Uma quota órfã passa a desqualificar o acto, e guarda-se
`captable_newer_act_date` para a ficha dizer "verificada a X, com alterações
posteriores em Y".

### Recolha

- **Via A, por NIPC** (`publications_for_nif`, sem limite de datas): histórico
  completo das 205 monitorizadas — **os cinco tipos**, o que resolve as 7 de
  análise que estavam sem qualquer informação de registo e as 17 `related` que
  nunca eram sincronizadas e travavam o grafo no primeiro hop — mais BFS até 3
  hops pelos sócios que são empresas. 226 entidades em 7 minutos.
- **Via B, varrimento nacional dia a dia**: a única forma de responder a "em que
  outras empresas é que esta pessoa é sócia", porque o IRN devolve **zero** para o
  NIF de um indivíduo. Só o filtro de um único dia é fiável — intervalos
  explícitos devolvem contagens absurdas (13 a 17 de julho → 112 292 actos, com
  dias úteis a rondar os 600) e sem filtro devolve zero. Validado: 618 actos e 31
  páginas em 2026-07-20, e repetir o mesmo dia dá 0 novos.
- Balde de fichas **partilhado** pelos trabalhadores: o que importa é o total de
  pedidos/s que sai, não quantas corrotinas há. Metade do ritmo em horário de
  trabalho.
- Canário horário: um deploy do IRN que invalide os `apiVersion` passa a ser
  detectado dentro de uma hora em vez de na manhã seguinte com três jobs falhados.

### Notificações

Cadeia explícita, em vez de espalhada pelo SQL: **interruptor geral → excepção da
empresa → defeito do tipo → destinatários**. As de análise e `related` nascem
caladas; as internas notificam. O botão de teste manda para um endereço escrito à
mão e nunca para o pré-definido — na fase anterior um teste saiu para o
`geral@segunor.pt` a sério. O `alerts_log`, que já era escrito e nunca aparecia,
passa a ser visível.

### Também

- `shared_buffers` do Postgres de **128 MB** (valor de origem) para 2 GB. Com o CT
  a 8 GB, a base de dados quase não usava a memória e os índices do grafo não
  cabiam em cache. Travessia a 3 hops: 14-37 ms.
- Diagrama em SVG próprio, em camadas por hop, em vez de acrescentar cytoscape:
  propriedade é hierárquica e lê-se melhor assim do que num force-directed.
- Confirmado antes de raspar: **não existe fonte oficial em bloco.** O único
  dataset do registo comercial em dados.gov.pt são contagens agregadas por ano e
  tipo de acto.
- Sai o `entity_relations` (migration 0025). O `process_parties` fica intacto,
  portanto é reversível por recomputação.

## [Fase 7] — 2026-07-26

Três frentes: detectar insolvências de devedores a tempo de reclamar créditos,
desbloquear as publicações do registo comercial, e substituir o ptdata morto.

### Anúncios de insolvência (sweep nacional CIRE)

O scrape CIRE existente pesquisava **por NIF**, um de cada vez, e só via o que
envolvia empresas já registadas. Quando um devedor da Segunor era declarado
insolvente, o prazo legal para reclamar créditos corria sem que ninguém fosse
avisado.

- **Sweep nacional diário** (01:30) de `ConsultasCire.aspx` sem filtro de
  entidade — a página aceita pesquisa só por datas, sem captcha. Validado
  contra 24-07-2026: o site reporta 75 documentos, guardámos 75.
- **O prazo sai do PDF do anúncio**, que só é servido dentro da sessão HTTP
  que fez a pesquisa. Extraem-se o prazo, os éditos, a data da sentença, o
  insolvente e o administrador de insolvência com email. 29/29 dos PDFs do dia
  de teste deram prazo.
- Guarda-se **a frase que originou o prazo**. Quem decide sobre uma data
  calculada tem de poder confirmar a origem em vez de confiar num número sem
  rasto.
- **Guardar tudo, mesmo o que não dá match hoje**, porque o cruzamento é
  retroactivo: quando um cliente novo entra na watchlist, tem de haver
  histórico para ele. Mesma razão que levou à `dre_raw` na Fase 5.
- O PDF só é puxado para os actos que fixam prazo (~20/dia dos ~110). As
  partes ficam em tabela própria em vez de duplicadas no `raw` — a esta escala
  isso seriam ~440 MB/ano em vez de ~110 MB/ano.

### Watchlist, prazos e avisos

- `insolvency_watchlist` + `insolvency_hits`, com `monitoring_type='client'`
  para os clientes serem realmente scrapeados e não apenas listados.
- **`POST /api/v1/watchlist`**: o Sabichão empurra o NIF ao criar um cliente.
  Best-effort e depois dos INSERTs — se o Intel Grid estiver em baixo, o
  cliente é criado na mesma. Nunca remove: um cliente desactivado continua a
  ser um devedor a acompanhar. Seed inicial: 92 clientes → 87 NIFs distintos.
- **Match só por NIF.** Por nome seria tentador (o `search.py` já usa trigram)
  mas o custo de um falso positivo aqui é alto — alguém age com base no aviso.
- Emails com `SMTP_SSL_IMPLICIT` para a 465 do mail.segunor.pt; a
  implementação anterior só sabia STARTTLS e teria falhado em silêncio.
- **Um aviso por processo, não por publicação.** A mesma insolvência rende
  tipicamente a sentença e o anúncio, com prazos calculados diferentes (só uma
  menciona os éditos). Dois emails com datas contraditórias sobre o mesmo
  processo são piores que nenhum.
- Destinatários editáveis nas Definições, com `geral@segunor.pt` por defeito.
  O `/admin/alerts` já existia sem UI nenhuma.
- `alerts_log.process_id` passou a nullable com `announcement_id`: era
  `NOT NULL` com FK para `processes`, o que tornava impossível registar o
  envio de um aviso de insolvência.

### Portal MJ desbloqueado

O `publicacoes.mj.pt` tem reCAPTCHA **e** AJAX Control Toolkit NoBot — nem os
POSTs que não envolvem captcha passam. O módulo estava dormente desde abril e
o workaround era uma extensão Chrome que obrigava alguém a navegar o portal à
mão.

- O IRN migrou a pesquisa para uma app OutSystems em
  `meu.registo.justica.gov.pt`, cujos screenservices devolvem **JSON sem
  captcha a partir de IP de datacenter**. O que destranca é o `X-CSRFToken`
  tirado de dentro do cookie `nr2Users`.
- Backfill: **181 entidades, 3095 publicações novas, 8 minutos**, zero erros.
- Torna obsoletos o `mj_publications.py`, o `recaptcha_audio.py`, a extensão
  Chrome e a ideia do proxy residencial (~€3-10/mês).
- **Enriquecimento da ficha em três níveis**: qualquer publicação (até uma
  prestação de contas) traz cabeçalho com firma oficial, natureza jurídica e
  sede — serve empresas de qualquer idade; objecto, capital e sócios só vêm na
  Constituição, que existe para constituições posteriores a ~2006.
  `registry_complete` distingue os dois casos.
- A morada é gravada **em bloco** da publicação mais recente. Campo a campo
  misturava épocas: a SEGUNOR ficava com a rua actual e o código postal de
  2018, e a firma seria `IGPSPROTEK, LDA`, o nome anterior à mudança.
- O capital da constituição **não é publicado quando houve aumentos depois** —
  é o capital inicial, e o novo não vem publicado. Campo vazio é honesto;
  número desactualizado não é.

### ptdata → VIES + SICAE

- O `api.ptdata.org` devolve 503. Pior: o job de refresh era um **no-op
  silencioso** — só gravava quando `source == "ptdata"`, condição que nunca era
  verdadeira porque o VIES responde primeiro. As últimas corridas liam 95
  empresas, actualizavam 0, e ficavam registadas como `ok`. O mesmo padrão
  existia no `create_company`.
- `scrapers/sicae.py` portado de `funcoes/empresa_nif_api.php` do Sabichão, que
  já fazia esta agregação em produção.
- `GET /companies/lookup-by-name`: pesquisa inversa nome→NIPC.

### Intel dentro do Sabichão

- **`GET /api/v1/dossier?nif=`** — identificação, prazos, anúncios, processos,
  publicações, pessoas e contratos numa só chamada.
- Tab "Intel" na ficha de cliente do Sabichão, em lazy-load. Se o Intel Grid
  não responder, a tab avisa e o resto da ficha não é afectado.
- `monitorizada: false` é explicitado na UI: um dossiê vazio lê-se como "está
  tudo bem", quando pode significar apenas que nada foi recolhido.

### Migrations

- 0020 `cire_announcements` + `cire_announcement_parties`
- 0021 `insolvency_watchlist` + `insolvency_hits`, `monitoring_type='client'`,
  `alerts_config.kind`, `alerts_log.process_id` nullable
- 0022 campos de registo comercial em `companies`

## [Phase 6] — 2026-04-21

### API pública v1

Nova superfície de API machine-to-machine em `/api/v1` para consumo
por outros serviços internos (HR, CRM, etc.) com autenticação por
`X-API-Key`.

- **Migration 0019**: tabela `api_keys` (label, hash sha256, prefix,
  active, usage tracking). Keys geradas como `ig_<token_urlsafe(32)>`.
- **Endpoint principal**: `GET /api/v1/candidates/check?name=...` —
  pesquisa fuzzy (pg_trgm) por parties em processos judiciais; filtra
  aos casos onde também aparece uma empresa com
  `monitoring_type='competitor'`; classifica cada hit em
  `high`/`medium`/`low` segundo as regras acordadas com o user.
- **Endpoint exacto**: `/candidates/check-by-nif` para quando o CV tem NIF.
- **Endpoint health**: `GET /api/v1/health` responde 200 + label da key.
- **Rate limiting**: 60 req/min por key via `slowapi` (fallback IP).
- **Admin UI em `/admin/api-keys`**: criar (raw exibido uma só vez),
  listar prefix + último uso + use_count, revogar (soft-delete).
- **Documentação**: `API_INTEGRATION.md` na raiz com exemplos curl,
  Python (httpx), Node.js (fetch), PHP (Guzzle); explicação dos
  `alert_level`, interpretação do `confidence` (pg_trgm) e Swagger UI
  auto-gerado em `/api/v1/docs`.

### Filtros de processos em Concorrentes

- `/competitors/processes` agora usa **checkboxes** de source
  (Distribuições / Insolvências CIRE) em vez do dropdown único.
- Default é só "Distribuições" (via nova prop `defaultSources` em
  `ProcessesTable`), respeitando o critério mais relevante para
  monitorização de concorrentes.
- Backend `/api/processes?source=` aceita CSV (`distribuicao,cire`)
  com validação por token.

### Reuso identificado

- Trigram + GIN index + `similarity()` já em
  `app/api/search.py` + migration 0003 — reutilizado literalmente.
- Regex de entidade jurídica de `app/services/relations.py` copiado
  para filtrar corporates do match set de candidatos.

---

## [Phase 5] — 2026-04-21

### Novos módulos

- **Scraper PSP Alvará A** (`app/scrapers/psp_alvara.py` + `app/services/alvara_sync.py`)
  - Playwright headless com Chromium, paginação completa da listagem pública
    em sigesponline.psp.pt
  - Tabelas `alvara_snapshots` + `alvara_companies` (migration 0018)
    rastreando first_seen / last_seen / removed_at por NIPC
  - Auto-registo: empresas novas inseridas como `monitoring_type='related'`
  - Cron diário 06:30 Europe/Lisbon
  - Admin UI `/admin/alvaras` com abas Novas / Removidas / Todas activas
    e botão "Correr agora"

- **Extensão Chrome MV3** para captura MJ (`extension/`)
  - Captura páginas de resultados + páginas de detalhe em
    publicacoes.mj.pt reutilizando a sessão/captcha do browser
  - Service worker isola os fetches ao backend de restrições mixed-content
  - Paginação automática via serialização completa do form (preserva o
    filtro NIF e o token de captcha)
  - Skip-set do backend evita re-fetch de linhas já com detalhe guardado
  - Extensão Bulk (`extension-bulk/`) mantida como referência; o
    reCAPTCHA v2 da MJ invalida o token por submit, inviabilizando a
    iteração multi-NIF com um captcha único

- **Extracção de pessoas das publicações MJ** (`app/services/mj_people_parser.py`)
  - Regex para blocos `Nome/Firma:` + `NIF/NIPC:` + `Cargo:`
  - Regex para blocos `TITULAR:` + `QUOTA` (sócios)
  - Inferência de cargo pelo header da secção quando não há linha
    `Cargo:` explícita (FISCAL ÚNICO, CONSELHO DE ADMINISTRAÇÃO, …)
  - Tabela `publication_people` (migration 0016) com de-dup por
    `(publication_id, person_nif)`
  - Categorização: `socio` / `admin` / `fiscal` / `other`
  - Endpoints `/companies/{id}/people` e `/people/{nif}/companies`
    (lookup cross-company)

- **IMPIC contracts sync** (`app/services/contracts_impic.py`)
  - Pull directo dos ZIPs dados.gov.pt em vez do ptdata instável
  - Streaming via ijson com `use_float=True`
  - Tabela `contract_sync_state` (migration 0013) para incrementais

- **DRE Parte E archive** (migration 0014)
  - `dre_raw` com GIN trigram indexes para match retroactivo quando
    uma empresa nova é adicionada

- **MJ import tokens** (migration 0015)
  - Auth X-Intel-Token para POSTs cross-origin da extensão

### Novas categorias e UI

- `monitoring_type='related'` (migration 0017): entidades auto-criadas a
  partir de sócios com NIPC ou scrape PSP
  - Aba "Relacionadas" na página Concorrentes
  - Pessoas cujo NIF corresponde a empresa conhecida viram `<Link>`
    directo na PeopleSection em vez de modal

- Companies list: coluna **Última pub. MJ** com data + contagem
- Companies list (Concorrentes): coluna **Alvará** substituindo CAE
- CompanyDetail:
  - Card **Pessoas** logo após Detalhes (Sócios / Gerência / Fiscalização / Histórico)
  - Linha **Publicações MJ: N captadas · M com detalhe · última em …**
  - Campo "Criada" → **Constituição** com data MJ quando disponível
- DrePublicationsSection: clique abre **modal formatado** com
  campos estruturados (firma, sede, distrito, concelho) + corpo completo
- ContractsSection: modal de contrato + status `active`/`expired`
  calculado server-side
- IntelligenceSummary: contratos por ano expansíveis
- Admin UI: entradas na sidebar para **Import MJ** e **Alvarás**

### Correcções

- DRE classifier: `management_change` passa a requerer trigger
  (nomeação/cessação/designação) **e** target (gerente/administrador/…)
  em vez de frase exacta — apanha "Designação de membro(s) de orgão(s)
  social(ais)" e variantes
- Relations drill-down: `DISTINCT ON (process_number, tribunal, role)`
  + exclusão de papéis profissionais (administrador de insolvência,
  liquidatário, mandatário, advogado)
- People parser: de-dup por `(nif, role_kind)` deixa a mesma pessoa ser
  registada como sócio **e** gerente
- People categorizer: fiscal (ROC/SROC/Suplente/Efetivo) em categoria
  própria — não polui admin
- MJ import: `ON CONFLICT DO UPDATE` quando o payload traz detail_body
  (antes `DO NOTHING` descartava enriquecimentos)
- Extensão MJ: paginação preservava agora o filtro NIF (serialize do
  form completo, não só `__VIEWSTATE`)
- Extensão MJ: fetches ao backend via service worker (evita bloqueio
  mixed-content HTTP/HTTPS em LAN)
- Contract "active" badge: cálculo server-side baseado em close_date +
  signing_date + execution_days (antes mostrava activo em contratos
  terminados há anos)
- Alvará baseline: primeira corrida backdated para fora da janela
  "Novas 30d" (evita 66 falsos positivos no dia 0)

### Performance

- Tribunal-sweep CIRE: 52× mais rápido que scrape per-company
- IMPIC contracts: streaming ZIP evita OOM em dumps grandes

### Dependências

- `playwright>=1.48` + Chromium bundled no Docker image backend
- Volumes host montados no container: `/opt/judicial/extension` e
  `/opt/judicial/extension-bulk` serving ZIP directo sem rebuild

### Migrations aplicadas

- 0013 contract_sync_state
- 0014 dre_raw
- 0015 mj_import_tokens
- 0016 publication_people
- 0017 related_monitoring (enum com 'related')
- 0018 alvara_snapshots + alvara_companies

---

## [Phase 4 + tribunal-sweep scraper] — 2026-04-16

Initial commit. Base do Segunor Intel Grid com FastAPI + React +
Postgres, scrapers CIRE/Distribuição/DRE, monitoring interno, contratos
públicos, análise on-demand, search transversal.
