Files
Claude VM a6c03a091e initial: split from gov-agreg — vreau.digital standalone platform
Moved from gov-agreg/src/pages/achizitii/* to root (drop prefix).
- 22 pages migrated, 127 files total
- All internal links: /achizitii/X → /X (176 occurrences fixed)
- AchizitiiLayout subnav rewritten: /X paths, top-right link to vreaudigital.ro hub
- BaseLayout new (vreau.digital branding, OG tags, site URL)
- astro.config.mjs: site https://vreau.digital, server output (was static)
- docker-compose: port 5096 (vreaudigital is 5095), container vreau-digital
- deploy.sh: paths /opt/vreau-digital, log /var/log/vreau-digital-deploy.log

Backend shared with gov-agreg:
- PostgreSQL satra (same schemas: seap, firms, anaf, anre, ...)
- Photon, Martin tiles
- Infisical /vreaudigital path (DATABASE_URL etc. shared)

build: PASS (npx astro check 0 errors, npm run build 5s vite + 10s server)
2026-05-13 00:10:32 +03:00

188 lines
8.3 KiB
Markdown

# 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`.