# 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//Get 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 `` 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 ``. ## 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=`** — 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`.