# ANRE — Plan de ingest & cross-source matching

## Sursa

ANRE (Autoritatea Națională de Reglementare în domeniul Energiei) publică
4 registre online la `portal.anre.ro/PublicLists/`:

| Slug intern | URL | Volum | Pattern |
|-------------|-----|-------|---------|
| `electricitate` | `/LicenteAutorizatii` | ~4,927 | flat columns + JSON |
| `gaze`          | `/LicenteAutorizatiiGN` | ~353 companies → ~7,000 sub-licențe (HTML Detaliu) | parent+child |
| `atestat`       | `/Atestate` | ~9,745 companies → ~10K+ sub-atestate (HTML Detaliu) | parent+child |
| `electricieni`  | `/AutorizatiiElectricieniAutorizati` | ~101,529 | flat (persoane fizice) |

**Total estimat după ingest complet:** ~120K+ rânduri.

## Acces tehnic — fără captcha, fără VIEWSTATE

Stack server: **ASP.NET MVC 4 + Kendo Grid (2013)**. NU e WebForms — datele
se citesc direct via AJAX:

```
POST /PublicLists/<List>/Get<List>
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Body: page=1&pageSize=99999

Response: { "Data": [...], "Total": 4927 }
```

`pageSize=99999` returnează tot setul într-un singur call pentru sursele
flat (`electricitate`, `electricieni`). Sursele cu `Detaliu` (HTML mare per
rând) au timeout server-side la `pageSize > 100` → folosim paginare cu
`pageSize=25` pentru robustețe.

### Quirk: cert TLS invalid pentru Node

Node 22 returnează `UNABLE_TO_VERIFY_LEAF_SIGNATURE` la `portal.anre.ro`.
Cert este valid (verificat OOB prin handshake), dar lipsește un intermediate
din bundle-ul Node. Workaround identic cu RegAS: `NODE_TLS_REJECT_UNAUTHORIZED=0`
în envfile pentru acest scraper.

### Quirk: portal flaky — pagini intermitent timeout

Portalul ANRE timeoutează aleator 1-2 pagini per run (3-min timeout server-side
pe queries cu HTML render mare). Scraperul are retry x4 cu exponential backoff,
apoi marchează pagina ca `HARD SKIP` și continuă. Operatorul poate re-rula
scraperul — UPSERT idempotent → re-fetch pages care au eșuat.

## Schema — `services/seap-scraper/sql/028_anre.sql`

3 tabele + 1 MV:

- `anre.licente` — unified flat: 1 rând per (license_source, license_no,
  titular_name, data_emitere, license_type). PK = sha1 deterministic.
  - `license_source`: 'electricitate' | 'gaze' | 'atestat'
  - Coloane CUI matching: `titular_name_norm`, `titular_cui`, `cui_match_score`,
    `cui_match_method`, `matched_at`
- `anre.electricieni` — persoane fizice, ~101K rânduri. UNIQUE(nr_autorizare, nume_prenume).
  Nu se face fuzzy match (n-au CUI).
- `anre.scrape_log` — observabilitate per run.
- `anre.mv_licente_per_cui` — MV agregat cu COUNT per (CUI, license_source, status).
  REFRESH CONCURRENTLY după fiecare ingest.

### Atestat / Gaze — HTML parsing al `Detaliu`

Coloana `Detaliu` din JSON e un `<table>` cu mai multe rânduri (un titular are
mai multe atestate / licențe gaz). Parser-ul nostru extrage fiecare sub-rând și
îl inserează în `anre.licente` cu același titular_name. Headers detectate
automat din primul `<tr>`.

## Scraper — `services/seap-scraper/src/scrape-anre.ts`

```bash
# Smoke test (100 rows)
SOURCE=electricitate LIMIT=100 sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh

# Full ingest, all 4 sources
sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh

# Per-sursă
SOURCE=electricitate sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
SOURCE=gaze         sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
SOURCE=atestat      sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
SOURCE=electricieni sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
```

Pattern identic cu RegAS: Infisical Machine Identity → envfile → `docker run
--env-file` (NEVER `-e $VAR`), envfile șters post-launch.

## CUI matching — `cron/match-cui-anre.sh`

Reutilizează pipeline-ul Stage A (exact normalized) + B (pg_trgm 0.85/0.10) +
C (judet disambiguation) din `match-cui-external.sh`, dar pe coloana
`anre.licente.titular_name → titular_cui`.

### Rezultate finale (29,536 rânduri = electricitate + gaze + atestat):

| Method | Rânduri | % |
|--------|---------|---|
| `exact_norm`   | 23,995 | 81.2% |
| `trgm_judet`   |  3,044 | 10.3% |
| `trgm_unique`  |    236 |  0.8% |
| **TOTAL matched** | **27,275** | **92.3%** |
| Unmatched      |  2,261 |  7.7% |

Cele 7.7% unmatched = în mare parte companii străine (DK, AT, DE), atestate
emise pentru sucursale extra-RO, plus typo-uri în denumirea ANRE vs. ONRC.

## Cross-source value

```sql
-- Furnizori care vând la stat fără licență ANRE
SELECT s.supplier_cui, e.name, COUNT(*) AS contracte_seap
FROM seap.announcements s
LEFT JOIN anre.mv_licente_per_cui a ON a.cui = s.supplier_cui
JOIN firms.entities e ON e.cui = s.supplier_cui
WHERE s.cpv_code ~ '^09(11|12|13|31|32|33|34|41)'  -- CPV energie
  AND a.cui IS NULL                             -- fără licență ANRE
GROUP BY s.supplier_cui, e.name
HAVING COUNT(*) >= 5
ORDER BY contracte_seap DESC;
```

Această query e replicabilitate-anchor pentru rețete tip
`/achizitii/energie-fara-licenta-anre`.

## Status implementare

- [x] STEP 1 — Investigated portal endpoints (no captcha, no VIEWSTATE)
- [x] STEP 2 — Schema `sql/028_anre.sql` aplicată pe satra
- [x] STEP 3 — Scraper TS + cron .sh livrate, retry/backoff/skip-page
- [x] STEP 4 — CUI matcher livrat, 79.7% match pe primele 5,540 rânduri

### Ingest runs efectuate

| Source | Total source | Rânduri DB | Inserted | Updated | Skipped | Status |
|--------|-------------:|-----------:|---------:|--------:|--------:|--------|
| electricitate | 4,927 | 4,541 | 4,445 | 145 | 337 | ✅ DONE — skipped = NrLicenta NULL (acreditare prelim) |
| gaze (sub-licențe per company) | 7,106 sub | 999 | 999 | 6,054 | 53 | ✅ DONE — 1 page (25 rânduri) lost la timeout; re-run scraperul |
| atestat (sub-atestate per company) | 34,314 sub | 23,996 | 23,996 | 8,726 | 1,592 | ✅ DONE — skipped = sub-rânduri fără Nr.atestat |
| electricieni | 101,529 | **0** | 0 | 0 | 0 | ❌ BLOCKED — vezi mai jos |

**Total în `anre.licente`: 29,536 rânduri | unique CUIs: ~6,500+ | matched: 92.3%**

### ❌ Electricieni — server-side pagination broken

Server ANRE returnează `HTTP 500 Execution Timeout Expired` la query-uri cu
`OFFSET > ~9000`. Confirmat experimental:

| pageSize | offset 0 | offset 4K | offset 9K+ |
|----------|----------|-----------|------------|
| 1000 | 15.6s ✅ | 11.4s ✅ | 33s 500 ❌ |
| 2000 | OK | OK | 500 ❌ |
| 5000 | OK | 500 ❌ | 500 ❌ |

Și endpoint-ul Excel export dă tot 500 după 253s. Înseamnă că DB-ul ANRE n-are
index pe `OFFSET/LIMIT` la cele ~101K rânduri din tabelul electricieni.

**Workarounds posibile pentru o sesiune viitoare:**
1. **Filter prin `Judet=<id>`** — dar form-encoded GET nu pare să fie respectat
   în endpoint (probabil are nevoie de payload Kendo Grid binding `filter[logic]=and&filter[filters][0][field]=Judet&filter[filters][0][value]=1`).
2. **Sort by NrAutorizare ASC + paginat cu `where NrAutorizare > last_seen`**
   în loc de OFFSET — ocoli OFFSET-ul lent. Necesită folosirea `sort` și
   `filter` din protocolul Kendo aspnetmvc-ajax.
3. **Filter prin `Stare`** — doar "Autorizat" returnează ~6,600 din 101K
   (vezi sample probe), încape în offset-ul tolerat.
4. **Scrape "ElectricieniPropusiExamen"** — sesiunea curentă, mult mai mic.

**Recomandare:** ingestă doar electricieni cu Stare='Autorizat' (active) — sunt
~6.5% din 101K = ~6,600 — încape lejer în offset-ul tolerat. Restul (Expirat,
Anulat, Neautorizat) sunt istoric, mai puțin valoroase pentru cross-reference
SEAP. Implementare: adaugă param `Stare` la fetchPage, filtrează server-side.

### Next steps

1. **Implementă filter-by-Stare pentru electricieni** — vezi mai sus.
2. **Re-rulează scraperul gaze** pentru a prinde pagina missed
   (UPSERT idempotent — sigur de re-rulat).
3. **Configure systemd timer** (gen `vreaudigital-anre-monthly.timer`) pentru
   refresh lunar — datele ANRE se actualizează rar.
4. **Match-cui re-run** după fiecare ingest nou (deja rulat — 92.3% match).
5. **Recipe:** adaugă rețetă `/achizitii/energie-fara-licenta-anre` în
   `src/lib/recipes.ts` (când se reia munca pe lib/) folosind query-ul din
   "Cross-source value".
6. **Profile-page enrichment:** adaugă bloc "Licențe ANRE" în
   `src/pages/achizitii/firma-publica/[id].astro` din `anre.mv_licente_per_cui`.
