+
    ʔjjH                        R t ^ RIt^ RIt^ RIHt ^ RIHt ^ RIHt ^ RI	H
t
 ^ RIHt ]P                  ! ]4      tRtR R	 ltR
tR R ltR R ltR R ltRtRtR R ltR R ltR!R R lltR"R R lltR R ltR#R R lltR R  ltR# )$u  Estado actual do grafo: quem detém o quê hoje, e quem já saiu.

Duas regras, uma por família de facto, e a diferença entre elas é a única coisa
que aqui importa entender.

**Quotas — a fotografia mais recente vale por inteiro.** Os actos de cap table
(`SÓCIOS E QUOTAS:`) publicam a estrutura *completa* da sociedade, não o delta.
Portanto a cap table actual de uma empresa é exactamente o conjunto de arestas do
acto mais recente em que o parser **encontrou** titulares. Não se reconstrói por
deltas, e não se faz merge entre actos: é o mesmo princípio já defendido no
`mj_company_enrichment.merge_company_fields` para a identidade e a morada, e a
propriedade merece-o mais ainda.

A elegibilidade é `parse_quotas > 0`, **nunca o título do acto**. Umas "alterações
ao contrato" que só mudaram a sede não têm titulares; tratá-las como fotografia
vazia apagaria os sócios da empresa em silêncio, e ficaria com aspecto normal.

**Cargos — base mais última palavra por par.** A constituição lista o órgão
completo e serve de base; cada acto posterior sobrepõe-se apenas ao par
`(pessoa, empresa, tipo)` que menciona. Uma designação abre a aresta, uma cessação
fecha-a. Um acto em falta corrompe só os pares que esse acto mencionava, nunca o
quadro todo — que é a razão para não replicar aqui a lógica de deltas.

Nada nesta camada apaga arestas. O histórico é o produto: só o reparse apaga, por
`source_act_id`, e reinsere.
N)Any)UUID)text)
DBAPIError)AsyncSession40P01c                0    V ^8  d   QhR\         R\        /# )   erroreturn)BaseExceptionbool)formats   "&backend/app/services/registry_graph.py__annotate__r   *   s     N N] Nt N    c                 @    \        \        V R R4      RR4      \        8H  # )origNsqlstate)getattr_IMPASSE)r
   s   &r   
_e_impasser   *   s    74.
DAXMMr   g      ?c                l    V ^8  d   QhR\         R\        R\        \        \        3,          R,          /# )r	   sessionnipcr   Nr   strdictr   )r   s   "r   r   r   3   s.     $ $ $S $T#s(^d=R $r   c                   "   R Fi  pV'       d   RMRpV P                  \        RV R24      RV/4      G Rj  xL
 P                  4       P                  4       pV'       g   K^  \	        V4      u # 	  R#  L;5i)uJ  O acto que define a estrutura actual, em dois degraus.

Primeiro o mais recente **elegível**: com titulares, todos com valor e nenhuma
quota órfã. Uma quota que a fonte lista sem nomear o titular desqualifica o
acto para este efeito — foi o caso do `510036899`, onde usar o acto mais
recente fazia a empresa perder o maior sócio (213.684,21 em três titulares
passava a 10.685,21 em dois) sem nada parecer errado.

Se nenhum for elegível, usa-se o mais recente com quotas e a estrutura fica
marcada como incompleta. Mais vale uma estrutura declaradamente parcial do que
nenhuma.
zAND parse_captable_eligible a  
                    SELECT id::text,
                           COALESCE(parse_effective_date, act_date) AS act_date,
                           parse_capital, parse_captable_complete,
                           parse_quota_orphans, parse_quota_orphan_total,
                           COALESCE(parse_captable_eligible, FALSE) AS eligible
                    FROM registry_acts
                    WHERE nipc = :nipc AND COALESCE(parse_quotas, 0) > 0 z
                    ORDER BY COALESCE(parse_effective_date, act_date) DESC NULLS LAST,
                             first_seen_at DESC
                    LIMIT 1
                    r   N)TF)executer   mappingsfirstr   )r   r   eligible_onlyclauserows   &&   r   _captable_actr&   3   s      '2?.R//J KQ R  " (*UUW% 	& 39- '. )s   6A6A4%A6#A6c                H    V ^8  d   QhR\         R\        R\        R\        /# )r	   r   r   usedr   )r   r   r   )r   s   "r   r   r   Z   s)     # #L # #3 #SV #r   c                   "   Vf   R# V P                  \        R4      RVRV/4      G Rj  xL
 P                  4       pV'       d
   V^ ,          # R#  L%5i)u   Data do acto com quotas mais recente que o que foi usado, se houver.

É o que permite dizer "estrutura verificada a 2020-05-12, com alterações
posteriores em 2023-07-31" em vez de mostrar uma data sozinha e deixar quem lê
a assumir que é o presente.
Na  
                SELECT max(COALESCE(parse_effective_date, act_date)) FROM registry_acts
                 WHERE nipc = :nipc AND COALESCE(parse_quotas, 0) > 0
                   AND COALESCE(parse_effective_date, act_date) > CAST(:used AS DATE)
                r   r(   )r    r   r"   )r   r   r(   r%   s   &&& r   _newer_captable_act_dater*   Z   sa      |oo T64(	
 		
 eg  3q6"d"		
s   )AAAAc          	          V ^8  d   QhR\         \        \        3,          R\        R,          R\        \        R,          \        R,          3,          /# )r	   act
identifiedNr   )r   r   r   floattuple)r   s   "r   r   r   r   sC     3 3T#s(^ 3 3%PTV[^bVbHbBc 3r   c                   Vf   R# V P                  R4      pV P                  R4      pVe$   \        V4      R,           V8  d   \        V4      pMVe   V\        V4      ,           pMVR3# \        WA,
          ^4      pYER8  d   V3# R3# )u  Sobre o que se calculam as percentagens, e quanto fica por explicar.

Por ordem de preferência:

1. **o capital declarado no acto**, quando é maior ou igual à soma das quotas
   com titular. É o denominador certo: o que falta é capital de alguém que a
   publicação não nomeia, não capital que não existe;
2. **a soma das quotas com titular mais as órfãs**, quando o capital não
   aparece no acto mas as quotas anónimas têm valor;
3. **só o que foi identificado**, quando não há mais nada.

A guarda do primeiro caso importa: em actos de aumento, o `parse_capital` é
por vezes o *montante do aumento* e não o capital final. Aí é menor que a soma
das quotas, e usá-lo daria percentagens acima de 100.
Nparse_capitalparse_quota_orphan_totalg{Gz?)NN)getr.   round)r,   r-   capitalorphan_totalbasefaltas   &&    r   _share_baser9   r   s      ggo&G7756LuW~4
BW~		!E,//4$#Q'E4<%22T22r   z#AND subject_id = CAST(:sid AS UUID)u
  
    WITH alcance AS (
        SELECT id, subject_id, holder_id, edge_type, role_label,
               valid_from, valid_to, source_act_id, snapshot_seq
          FROM registry_edges
         WHERE edge_type NOT IN ('holds_quota', 'spouse_of')
           {scope}
    ),
    -- Quando a fonte não repete o cargo não há texto para classificar e a aresta
    -- cai em `other`: é família **desconhecida**, não uma família diferente. Vale
    -- nos dois sentidos — uma cessação `other` fecha qualquer designação anterior
    -- do mesmo par, e uma designação `other` é fechada por qualquer cessação
    -- posterior. Na IGPS PROTEK são as duas pontas do mesmo caso: a renúncia do
    -- Luís veio sem cargo, e a designação do Carlos também.
    fecho AS (
        SELECT subject_id, holder_id,
               max(valid_to) FILTER (WHERE edge_type = 'other') AS ate_other,
               max(valid_to) AS ate_qualquer
          FROM alcance
         WHERE valid_to IS NOT NULL
         GROUP BY subject_id, holder_id
    ),
    ranked AS (
        SELECT id, subject_id, holder_id, edge_type, valid_from, valid_to,
               dense_rank() OVER (
                   PARTITION BY subject_id, holder_id, edge_type
                   ORDER BY valid_from DESC NULLS LAST,
                            (valid_to IS NOT NULL) DESC,
                            source_act_id
               ) AS rn,
               -- Dentro do acto vencedor, uma linha por cargo distinto. Sem isto
               -- as repetições exactas — o mesmo acto que nomeia a mesma pessoa
               -- duas vezes, com `snapshot_seq` 0 e 1 — ficavam ambas em funções e
               -- a ficha da empresa mostrava a pessoa a dobrar.
               row_number() OVER (
                   PARTITION BY subject_id, holder_id, edge_type, COALESCE(role_label, '')
                   ORDER BY valid_from DESC NULLS LAST,
                            (valid_to IS NOT NULL) DESC,
                            source_act_id, snapshot_seq
               ) AS rn_cargo
          FROM alcance
    ),
    veredicto AS (
        SELECT r.id,
               (r.rn = 1 AND r.rn_cargo = 1 AND r.valid_to IS NULL
                AND (f.ate_other IS NULL OR r.valid_from IS NULL
                     OR f.ate_other <= r.valid_from)
                AND (r.edge_type <> 'other' OR f.ate_qualquer IS NULL
                     OR r.valid_from IS NULL OR f.ate_qualquer <= r.valid_from)) AS actual
          FROM ranked r
          LEFT JOIN fecho f
                 ON f.subject_id = r.subject_id AND f.holder_id = r.holder_id
    )
    UPDATE registry_edges e
       SET is_current = v.actual
      FROM veredicto v
     WHERE e.id = v.id
       AND e.is_current IS DISTINCT FROM v.actual
c                0    V ^8  d   QhR\         R\        /# r	   r   r   r   int)r   s   "r   r   r      s     	  	 < 	 C 	 r   c                   "   V P                  \        \        P                  RR7      4      4      G Rj  xL
 pVP                  ;'       g    ^ #  L5i)u.  O mesmo cálculo de `recompute_current`, mas para toda a base de uma vez.

São 276 mil empresas com arestas de cargo: um ciclo a chamar
`recompute_current` uma vez por empresa não acabaria em tempo útil. Escreve só
as linhas que mudam, portanto correr duas vezes seguidas não faz nada à
segunda.
r   scopeN)r    r   _ROLES_CURRENT_SQLr   rowcount)r   results   & r   recompute_rolesrD      sD      ??4(:(A(A(A(K#LMMF??a Ns   2AAAAc          
      j    V ^8  d   QhR\         R\        R\        R\        \        \        3,          /# )r	   r   
subject_idr   r   r   )r   s   "r   r   r      s;     w w\ ws w# wRVWZ\_W_R` wr   c                R  "   RRR^ RR/pV P                  \        R4      RV/4      G Rj  xL
 P                  4       P                  4       p\	        W4      G Rj  xL
 pV'       Edm   V P                  \        R4      RVR	VR
,          RVR,          /4      G Rj  xL
  V P                  \        R4      RV/4      G Rj  xL
 P                  4       P                  4       pV'       d   VR,          '       d   V'       d   VR,          ^ 8H  p\        VR,          ;'       g    ^ 4      ^ 8  ;'       d;    \        VR,          ;'       g    ^ 4      \        VR,          4      \        ,          8  pV'       g	   V'       dC   RVR&   \        P                  RW$R,          VR,          VR,          VR,          VR
,          4       V'       d"   VR,          '       d   \        VR,          4      MRp	V'       d   VR,          ^8X  d
   VR,          MRp
\        WVR,          4      G Rj  xL
 p\        WY4      w  rV P                  \        R4      RVR	VR
,          RVR,          RVRV	RVRVRV
RVR,          /	4      G Rj  xL
  WR&   WR&   V P                  \        R4      R	VR
,          RV/4      G Rj  xL
  VR
,          VR&   V'       d
   VR,          M^ VR&   V P                  \        \        P                  \        R7      4      RV/4      G Rj  xL
  V#  EL EL EL EL] L L Lu L5i)z4Recalcula `is_current` de uma entidade. Idempotente.captable_act_idNquota_edgeswipeoutFz
                SELECT COALESCE(sum(quota_amount), 0) AS total, count(*) AS n
                FROM registry_edges
                WHERE subject_id = CAST(:sid AS UUID) AND edge_type = 'holds_quota' AND is_current
                sida  
                UPDATE registry_edges SET
                    is_current = (source_act_id = CAST(:aid AS UUID)),
                    valid_to = CASE
                        WHEN source_act_id = CAST(:aid AS UUID) THEN NULL
                        ELSE COALESCE(valid_to, CAST(:adate AS DATE))
                    END
                WHERE subject_id = CAST(:sid AS UUID) AND edge_type = 'holds_quota'
                aididadateact_datea  
                    SELECT COALESCE(sum(quota_amount), 0) AS total, count(*) AS n,
                           count(DISTINCT currency) AS moedas,
                           min(currency) AS currency
                    FROM registry_edges
                    WHERE subject_id = CAST(:sid AS UUID) AND edge_type = 'holds_quota'
                      AND is_current
                    ntotalTzBcap table de %s encolheu: %s holders/%s -> %s holders/%s (acto %s)moedascurrencyaV  
                UPDATE registry_entities SET
                    captable_act_id = CAST(:aid AS UUID),
                    captable_as_of = CAST(:adate AS DATE),
                    captable_newer_act_date = CAST(:newer AS DATE),
                    captable_total = :total,
                    captable_base = :base,
                    captable_unidentified = :unidentified,
                    captable_currency = :currency,
                    captable_complete = COALESCE(:complete, FALSE),
                    updated_at = now()
                WHERE id = CAST(:sid AS UUID)
                newerr7   unidentifiedcompleteparse_captable_completenewer_act_dateaF  
                UPDATE registry_edges e SET quota_share = ROUND(
                    (e.quota_amount / NULLIF(CAST(:base AS NUMERIC), 0))::numeric, 4)
                WHERE e.source_act_id = CAST(:aid AS UUID)
                  AND e.edge_type = 'holds_quota'
                  AND e.quota_amount IS NOT NULL
                r?   )r    r   r!   r"   r&   r.   WIPEOUT_RATIOloggererrorr*   r9   rA   r   _ROLES_SCOPE_ONE)r   rF   r   outbeforer,   afterlost_all	lost_halfrQ   rS   rT   r7   rU   s   &&&           r   recompute_currentrb      s    ,dM1iQVWC oo J	
 		
 hj  g,
,C
soo
 Js4y'3z?K
 	
 	
 //	 
#  (*UUW 	& fSkkeSzQHfWo**+a/ X X%.--A.vg1G-1WW  9!%IX+vgc
E'NTWX\T]
 */5::eGn%4(-%/Q2F5$D.wc*oNN(4oo  z5#d)Wc*onlHC 9:!
 	
 	
0 !&*N oo CIvt,
 	
 	
 "%T+0U3ZaM //$188?O8PQTY[eSf
gggJe		
 -	
B O	
@	
" hs   )L'L.L'LL'(2L'L$L'?L 'L'(L'7L'? L' L'1L',L'4L'<A	L'L'L'1/L' L!AL'7L!85L'-L#.L'AL'L%L'L'L'L'L'!L'#L'%L'c                `    V ^8  d   QhR\         R\        \        ,          R,          R\        /# )r	   r   
entity_idsNr   )r   listr   r=   )r   s   "r   r   r   d  s+     8 8< 8T#Y=M 8Y\ 8r   c           	       "   Rp/ pV'       d   RpWR&   \        RV'       d   VP                  RR4      MR R24      p\        ^4       Fd  p V P                  4       ;_uu_4       GRj  xL
  V P	                  WC4      G Rj  xL
 P
                  ;'       g    ^ pRRR4      GRj  xL
  Xu # 	  ^ #  LI L2 L  + GRj  xL 
 '       g   i     L'; i  \         dh   p\        T4      '       d   T^8X  d   h \        P                  R	T^,           4       \        P                  ! R
T,           4      G Rj  xL 
   Rp?K  Rp?ii ; i5i)u"  Grau actual de cada nó, para a guarda anti-hub da travessia.

Uma agregação só, e não uma subconsulta por entidade. A versão anterior
percorria as arestas uma vez **por cada nó**: com 4 mil entidades não se
notava, com as 42 mil de hoje e o índice nacional ainda a criá-las passava a
doer, e cresce ao quadrado.

O `LEFT JOIN` não é decorativo: um nó que perca a última aresta desaparece da
agregação, e sem ele ficaria com o grau antigo para sempre — a marca de hub
colada a quem já não tem ligação nenhuma.

**Insiste depois de um impasse.** Isto é uma actualização em massa sobre
`registry_entities`, e três trabalhos chamam-na: o `registry_content`, o
`registry_expand` e o backfill nocturno. Quando duas correm ao mesmo tempo
tocam as mesmas linhas em ordens diferentes e o Postgres mata uma delas —
matava com ela a corrida inteira, que só ia embora fazer o trabalho da
recolha outra vez uma hora depois. Um recálculo de manutenção é repetível por
definição: recomeça-se no ponto de salvaguarda e tenta-se de novo.
r   z&WHERE e.id = ANY(CAST(:ids AS UUID[]))idsa3  
        WITH pontas AS (
            SELECT holder_id AS id FROM registry_edges WHERE is_current
            UNION ALL
            SELECT subject_id FROM registry_edges WHERE is_current
        ),
        graus AS (
            SELECT id, count(*) AS deg FROM pontas GROUP BY id
        )
        UPDATE registry_entities e
           SET degree_current = COALESCE(g.deg, 0)
          FROM registry_entities e2
          LEFT JOIN graus g ON g.id = e2.id
         WHERE e.id = e2.id
           AND e.degree_current IS DISTINCT FROM COALESCE(g.deg, 0)
           WHEREANDz	
        Nz graus: impasse, a repetir (%d/3)g      ?)r   replacerangebegin_nestedr    rB   r   r   rZ   warningasynciosleep)r   rd   whereparamssql	tentativamudadasr
   s   &&      r   refresh_degreesru   d  s    ( EF8"u
 .3EMM'5); <		C& 1X	
	1 ++---!(!==GGLL1 .-N   .= .---  	1d##yA~NN=y1}M--i000		1s   A
E
C)B3
*C-B9B5
B9B9C&B7'C,E
3C5B97C9C?C 
CCCE AE6D97E<E
EE
c          	      t    V ^8  d   QhR\         R\        R\        \        \        \
        3,          ,          /# r	   r   limitr   )r   r=   re   r   r   r   )r   s   "r   r   r     s1     # #l #3 #TRUWZRZ^H\ #r   c                   "   V P                  \        R4      RV/4      G Rj  xL
 P                  4       P                  4       pV Uu. uF  p\	        V4      NK  	  up#  L;u upi 5i)u  Empresas cuja cap table actual não fecha o capital do acto.

A parte que a fonte lista sem titular **não** conta como falha: é conhecida,
está quantificada em `captable_unidentified` e aparece na ficha como uma linha
própria. O que esta vigia procura é o que sobra depois disso — capital que não
se explica nem por titulares nem por quotas anónimas, e que portanto é parse
incompleto ou estrutura parcial.
a  
                SELECT e.nif, e.name, e.captable_as_of, e.captable_total,
                       e.captable_unidentified, a.parse_capital, a.act_type,
                       a.id::text AS act_id,
                       abs(e.captable_total + COALESCE(e.captable_unidentified, 0)
                           - a.parse_capital) AS por_explicar
                FROM registry_entities e
                JOIN registry_acts a ON a.id = e.captable_act_id
                WHERE e.captable_total IS NOT NULL
                  AND a.parse_capital IS NOT NULL
                  AND abs(e.captable_total + COALESCE(e.captable_unidentified, 0)
                          - a.parse_capital) > 0.02
                ORDER BY por_explicar DESC
                LIMIT :lim
                limN)r    r   r!   allr   )r   rx   rowsrs   &&  r   wipeout_candidatesr~     sg      oo" EN%
 	
( hj+ 	, ""TDGT""+	
* #s!   !A&A$A&A!A&!A&c                R    V ^8  d   QhR\         R\        \        \        3,          /# r;   )r   r   r   r   )r   s   "r   r   r     s"     7 7 7$sCx. 7r   c                  "   V P                  \        R4      4      G Rj  xL
 P                  4       P                  4       pV'       d   \	        V4      M/ pR F]  w  r4pVP                  V4      ;'       g    ^ pVP                  V4      ;'       g    ^ pV'       d   \        Wv,          ^4      MRW% R2&   K_  	  \        VP                  R4      ;'       g    ^ R8  ;'       g    VP                  R4      ;'       g    ^ R8  4      VR&   V#  L5i)	u8  Sinais de parser morto.

O defeito que motivou toda esta fase — uma regex que exigia `NIF/NIPC:` colado
ao fim de linha — devolveu zero durante meses sem nada acusar. Daí
`parse_quotas` ser INT: "parseei e não encontrei" tem de se distinguir de
"nunca parseei", e é a primeira que tem de disparar alarme.
a  
                SELECT
                  count(*) FILTER (WHERE body IS NOT NULL) AS com_corpo,
                  count(*) FILTER (WHERE body IS NOT NULL AND length(body) < 400) AS corpos_truncados,
                  count(*) FILTER (WHERE body IS NOT NULL AND parse_version = 0) AS por_parsear,
                  count(*) FILTER (WHERE act_class = 'constituicao'
                                     AND body ILIKE '%%QUOTA%%') AS constituicoes,
                  count(*) FILTER (WHERE act_class = 'constituicao'
                                     AND body ILIKE '%%QUOTA%%'
                                     AND parse_quotas = 0) AS constituicoes_vazias,
                  count(*) FILTER (WHERE act_class IN ('designacao','cessacao')
                                     AND body ILIKE '%%NIF/NIPC%%') AS actos_orgaos,
                  count(*) FILTER (WHERE act_class IN ('designacao','cessacao')
                                     AND body ILIKE '%%NIF/NIPC%%'
                                     AND parse_people = 0) AS orgaos_vazios,
                  count(*) FILTER (WHERE parse_error IS NOT NULL) AS com_nota
                FROM registry_acts
                N_taxa_vaziaconstituicao_taxa_vaziag?orgaos_taxa_vaziaalarme))constituicoesconstituicoes_vaziasconstituicao)actos_orgaosorgaos_vaziosorgaos)r    r   r!   r"   r   r3   r4   r   )r   r%   stats	total_key	empty_keynamerQ   emptys   &       r   parser_healthr     s       oo
 	
, hj/ 0 DI"E'"	d 		)$))		)$))AFeEM1&=Dk"#' 	,	-	2	2d: 	8 	8II)*//a47E(O LO	
s9   DD'D	,D6D
D9DD#D<Dc                <    V ^8  d   QhR\         R\        R\        /# rw   r<   )r   s   "r   r   r     s+     yG yG, yGs yGS yGr   c                  "   V P                  \        R4      RV/4      G Rj  xL
 pVP                  ;'       g    ^ pV P                  \        R4      4      G Rj  xL
 pV P                  \        R4      4      G Rj  xL
 pY4P                  ;'       g    ^ ,           VP                  ;'       g    ^ ,           #  L L_ L?5i)u,  Candidatos a fundir um nó sem NIF com o nó com NIF da mesma pessoa.

O problema é da fonte: o bloco de quotas de muitos actos está em prosa e não
traz NIF nenhum, enquanto a secção dos órgãos sociais do **mesmo acto** dá o
NIF de cada membro. A pessoa fica com dois nós, e o que detém a quota não
leva a lado nenhum.

Três evidências, por ordem de força:

* **`nome+empresa`** (0.95) — nome exacto e as duas pontas ligadas à mesma
  empresa. É o caso da GARCIA,GARCIA & CA, onde quatro dos cinco sócios sem
  NIF são administradores com NIF da própria empresa;
* **`nome+rua+cp`** (0.90) — nome exacto e a **morada completa** igual. Medido
  em 5.356 pares: a morada tem 45 caracteres em média e 5.348 deles trazem
  número de porta ou lote. Não é "a mesma rua", é a mesma casa;
* **`nome+cp`** (0.60) — código postal sem rua. Fica sempre para revisão: um
  código postal chega para um bairro inteiro.

As duas primeiras confirmam-se sozinhas, **mas só quando o nó sem NIF tem um
único candidato**. Com dois, escolher seria tirar à sorte, e esses continuam a
precisar de uma pessoa — são 3 nós em 5.353.

**Só nome não gera sugestão.** São 10.323 pares no índice e homónimos foi
exactamente o que estragou as relações antigas — uma lista dessas não se
revê, ignora-se.
a  
            WITH pares AS (
                SELECT DISTINCT u.id AS unknown_id, c.id AS candidate_id,
                       CASE
                         WHEN EXISTS (
                            SELECT 1 FROM registry_edges r1
                              JOIN registry_edges r2 ON r2.subject_id = r1.subject_id
                             WHERE r1.holder_id = u.id AND r2.holder_id = c.id
                         ) THEN 'nome+empresa'
                         WHEN u.address_norm IS NOT NULL
                              AND u.address_norm = c.address_norm THEN 'nome+rua+cp'
                         WHEN u.codigo_postal IS NOT NULL
                              AND u.codigo_postal = c.codigo_postal THEN 'nome+cp'
                       END AS evidence
                  FROM registry_entities u
                  JOIN registry_entities c
                    ON c.name_norm = u.name_norm
                   AND c.nif IS NOT NULL
                   AND c.kind = 'person'
                   AND c.id <> u.id
                 WHERE u.nif IS NULL
                   AND u.merged_into IS NULL
                   AND length(u.name_norm) >= 10
            ),
            contadas AS (
                SELECT p.*, count(*) OVER (PARTITION BY p.unknown_id) AS candidatos
                  FROM pares p
                 WHERE p.evidence IS NOT NULL
            )
            INSERT INTO registry_identity_links
                (unknown_id, candidate_id, evidence, score, confirmed_at)
            SELECT unknown_id, candidate_id, evidence,
                   CASE evidence WHEN 'nome+empresa' THEN 0.95
                                 WHEN 'nome+rua+cp' THEN 0.90
                                 ELSE 0.60 END,
                   CASE WHEN candidatos = 1
                         AND evidence IN ('nome+empresa', 'nome+rua+cp') THEN now() END
              FROM contadas
             LIMIT :lim
            ON CONFLICT (unknown_id, candidate_id) DO NOTHING
            rz   Na-  
            UPDATE registry_identity_links l
               SET evidence = 'nome+empresa', score = 0.95, confirmed_at = now()
             WHERE l.confirmed_at IS NULL
               AND l.rejected_at IS NULL
               AND l.evidence <> 'nome+empresa'
               AND EXISTS (
                    SELECT 1 FROM registry_edges r1
                      JOIN registry_edges r2 ON r2.subject_id = r1.subject_id
                     WHERE r1.holder_id = l.unknown_id
                       AND r2.holder_id = l.candidate_id
               )
            a  
            UPDATE registry_identity_links l
               SET confirmed_at = now()
             WHERE l.confirmed_at IS NULL
               AND l.rejected_at IS NULL
               AND l.evidence = 'nome+rua+cp'
               AND NOT EXISTS (
                    SELECT 1 FROM registry_identity_links o
                     WHERE o.unknown_id = l.unknown_id
                       AND o.id <> l.id
                       AND o.rejected_at IS NULL
               )
            )r    r   rB   )r   rx   rC   rQ   
promovidosmoradas   &&    r   suggest_identity_linksr     s     < ??(*	
V 
Y- -F\ OO  qE 	
 J, ??	
 F" '',,1-1E1EAFFw-h,sD   !B?B9B? B?B;!B?=B=>B?B?0
B?;B?=B?c                R    V ^8  d   QhR\         R\        \        \        3,          /# r;   )r   r   r   r=   )r   s   "r   r   r   w  s&     d d d$sCx. dr   c                  "   R^ R^ R^ /p\        4       pV P                  \        R4      4      G Rj  xL
 P                  4       pV F  w  rEV P                  \        R4      RVRV/4      G Rj  xL
 pTR;;,          VP                  ;'       g    ^ ,          uu&   V P                  \        R	4      RVRV/4      G Rj  xL
 pTR;;,          VP                  ;'       g    ^ ,          uu&   V P                  \        R
4      RVRV/4      G Rj  xL
  VR;;,          ^,          uu&   VP                  V4       K  	  VR,          '       do   V P                  \        R4      R\        V4      /4      G Rj  xL
 P                  4       pV F  w  r\        W	V
4      G Rj  xL
  K  	  \        V 4      G Rj  xL
  \        P                  RV4       V#  EL ELg EL L Lg L@ L+5i)u3  Aponta as arestas dos nós absorvidos para a identidade confirmada.

Não apaga nada. O nó sem NIF continua a existir com o nome que o acto
publicou — é um facto da fonte, e é o que permite desfazer — mas as arestas
dele passam a sair do nó com NIF, que é a mesma pessoa. É isto que liga a
quota de 235.000 € da Teresa Maria ao resto do grafo em vez de a deixar num
beco.

Uma aresta que ao ser reapontada colidisse com uma já existente (o mesmo
acto, o mesmo par, o mesmo tipo) é apagada em vez de reapontada: seria a
mesma afirmação duas vezes.
nosarestas
duplicadasaP  
                SELECT l.unknown_id::text, l.candidate_id::text
                  FROM registry_identity_links l
                  JOIN registry_entities u ON u.id = l.unknown_id
                 WHERE l.confirmed_at IS NOT NULL
                   AND l.rejected_at IS NULL
                   AND u.merged_into IS NULL
                Na  
                DELETE FROM registry_edges e
                 WHERE e.holder_id = CAST(:u AS UUID)
                   AND EXISTS (
                        SELECT 1 FROM registry_edges o
                         WHERE o.source_act_id = e.source_act_id
                           AND o.holder_id = CAST(:c AS UUID)
                           AND o.subject_id = e.subject_id
                           AND o.edge_type = e.edge_type
                           AND o.snapshot_seq = e.snapshot_seq
                   )
                ucz
                UPDATE registry_edges SET holder_id = CAST(:c AS UUID)
                 WHERE holder_id = CAST(:u AS UUID)
                   AND subject_id <> CAST(:c AS UUID)
                zkUPDATE registry_entities SET merged_into = CAST(:c AS UUID), updated_at = now() WHERE id = CAST(:u AS UUID)a-  
                    SELECT DISTINCT s.id::text, s.nif
                      FROM registry_edges r
                      JOIN registry_entities s ON s.id = r.subject_id
                     WHERE r.holder_id = ANY(CAST(:ids AS UUID[]))
                       AND s.nif IS NOT NULL
                    rg   u   fusões de identidade: %s)setr    r   r{   rB   addsortedrb   ru   rZ   info)r   r]   absorventespares
unknown_idcandidate_iddupmoved	afectadasrF   r   s   &          r   apply_identity_mergesr   w  s     !Y<
3CEKoo	
 	
 
ce 
 %* 
OO *c<0
 
" 	LS\\..Q.oo *c<0	
 	
 	I%..--A-ooB *c<0
 	
 	
 	E
a
%Q %*T 5zz // {+,  #% 	 !*J#G>>> !*g&&&
KK+S1Ji	

&	
	
* ?&s   0G#G;G#.G/ G#.G#>G? G# .G#G<G#)G#5G6(G#GG#5G!6G#G#G#G#G#G#!G#)N)2   )i  )__doc__rn   loggingtypingr   uuidr   
sqlalchemyr   sqlalchemy.excr   sqlalchemy.ext.asyncior   	getLogger__name__rZ   r   r   rY   r&   r*   r9   r\   rA   rD   rb   ru   r~   r   r   r    r   r   <module>r      s   4      % /			8	$ N $N#03Z 9 : z	 wt8v#D7tyGxdr   