-- 026_bugetar.sql -- Transparență Bugetară MFP — execuții bugetare ale entităților publice. -- Source primar: https://mfinante.gov.ro/apps/transparenta-bugetara/index.htm -- → redirecționează spre aplicația activă: extranet.anaf.mfinante.gov.ro/anaf/extranet/EXECUTIEBUGETARA -- -- KILLER USE CASE: cross-reference cu seap.announcements pentru a calcula -- "ponderea unui furnizor SEAP în cheltuielile totale ale unei UAT" — adică -- "Comuna X a cheltuit 80% din buget cu 1 furnizor". Plus "Capitole bugetare -- consumate disproporționat de 1 firmă" (cap 51 admin pub × top vendor). -- -- Volum estimat: ~13.700 entități × 12 luni × 5 ani × ~30 linii/raport -- ≈ 25M rânduri pentru detaliat (FXB-EXB-900). Pentru raport -- COFOG3 agregat (FXB-EXB-901), ~822K rânduri pentru perioada -- 2021-2025 la nivel ordonator principal. -- -- ─── LIMITĂRI SURSE DE DATE (2026-05-09) ──────────────────────────────────── -- 1. Aplicația oficială (extranet.anaf.mfinante.gov.ro/EXECUTIEBUGETARA) e -- IBM WebSphere Portal cu CAPTCHA imagine pe fiecare căutare. Endpoint-ul -- de căutare e POST cu un URL stateful + `seccode`. Nu există URL deep -- direct per (CUI, perioadă) fără sesiune + captcha solver. -- 2. Există un endpoint de autocomplete EXPUS fără captcha care întoarce TOATE -- denumirile entităților publice per (sector_bugetar, județ): -- POST /Rapoarte_Forexe/.../res/id=populateEpAJAX/.../ -- data: idSector=02&idJudet=CJ -- response: ["BIBLIOTECA JUDETEANA OCTAVIAN GOGA CLUJ", ...] (JSON array). -- Util pentru a construi universul ~13.7K entități, dar NU întoarce CUI-urile. -- 3. Fișiere XML/XLSX detaliate (FXB-EXB-900) se descarcă DOAR dintr-un raport -- de rezultate randat după captcha. Hash-uri de URL sunt valide ~minute. -- 4. data.gov.ro publică doar agregate naționale (BGC = Bugetul General -- Consolidat) ca XLS lunar — NU per-CUI. -- 5. Multe primării publică propriile execuții pe site-urile lor (PDF/XLSX), -- dar formatele variază — Plan B pentru top-N municipii. -- -- ─── STRATEGIA INGEST (faze) ──────────────────────────────────────────────── -- Faza 1 (THIS migration): schema completă pregătită pentru parser FXB-EXB-900 -- + tabelă auxiliară bugetar.entitate cu universul EP din autocomplete API -- (~13.7K rânduri × 5 sectoare × 42 județe ≈ 30K seed-uri ce vor fi dedupe-uite). -- + descrierea formatelor XML/XLSX (din PDF-urile MFP "Structura fisier XML -- raport FXB-900/901/905") așa încât parserul să fie deterministic. -- Faza 2 (TODO ~80h): integrare captcha solver (2captcha/anti-captcha) + -- crawler asincron care urmează (sector × județ × tipRaport × an × lună). -- Faza 3: cross-link cu firms.entities + seap.announcements pentru recipe-uri -- "buget vs procurement". CREATE SCHEMA IF NOT EXISTS bugetar; -- ──────────────────────────────────────────────────────────────────────────── -- Tabel principal: linii de execuție bugetară per (entitate × perioadă × clasificație) -- ──────────────────────────────────────────────────────────────────────────── -- Format aliniat pe FXB-EXB-900 (raport detaliat per entitate publică) + -- agregatele FXB-EXB-901 (ordonator principal) și FXB-EXB-905 (ordonator secundar). -- Câmpurile clasificării bugetare urmăresc structura ROMC (Clasificația -- Bugetară Românească): Capitol → Subcapitol → Paragraf → Articol → Aliniat. CREATE TABLE IF NOT EXISTS bugetar.executie ( id bigserial PRIMARY KEY, -- Identificare entitate cui text NOT NULL, -- CUI entitate publică (fără prefix RO) cui_ordonator text, -- CUI ordonator principal (poate diferi de cui) entity_name text, -- denumire la momentul raportării sector_bugetar text, -- '01' BS, '02' BL, '03' BASS, '04' SOMAJ, '05' FNUASS judet text, -- cod 2 litere (AB, CJ, B, ...) -- Perioada raportării period text NOT NULL, -- 'YYYY-MM' (cumulat de la 1 ian până la sfârșitul lunii) period_year smallint NOT NULL, period_month smallint NOT NULL, -- 1..12 -- Tip raport sursă raport_tip text NOT NULL, -- 'FXB-EXB-900' | 'FXB-EXB-901' | 'FXB-EXB-905' | 'FXB-RBG-003' | 'FXB-EXB-902' raport_nivel text, -- 'entitate' | 'ordonator_principal' | 'ordonator_secundar' -- Clasificația bugetară (5 niveluri Cf. ROMC) side text NOT NULL, -- 'venituri' | 'cheltuieli' capitol text, -- 4 cifre, ex '5101' = Autorități publice subcapitol text, -- 6 cifre, ex '510102' paragraf text, -- 8 cifre articol text, -- 10 cifre, ex '5101010101' aliniat text, -- 12 cifre (rar folosit) classification_label text, -- denumire human-readable cofog3 text, -- cod COFOG3 (Classification of Functions of Government, agregat) -- Sumele cheie (toate în RON, cumulat de la 1 ian) -- Înțeles per FXB-EXB-900: -- credite_bug_aprobate_ini = bugetul aprobat inițial pentru anul curent -- credite_bug_aprobate_def = bugetul aprobat definitiv (după rectificări) la sfârșit perioadă -- credite_bug_trimestru = creditele bugetare trimestriale cumulate -- angajamente_bugetare = sumele angajate (FXB-EXB-902) -- angajamente_legale = sumele angajate prin contracte ferme -- plati_efectuate = plăți efective la sfârșit perioadă (= "execuția cumulată") -- incasari_realizate = pentru side='venituri', sumele încasate credite_bug_aprobate_ini numeric(20,2), credite_bug_aprobate_def numeric(20,2), credite_bug_trimestru numeric(20,2), angajamente_bugetare numeric(20,2), angajamente_legale numeric(20,2), plati_efectuate numeric(20,2), incasari_realizate numeric(20,2), -- Sumă "primary" pentru queries simple — pentru side='cheltuieli' = plati_efectuate, -- pentru side='venituri' = incasari_realizate. Calculat la INSERT. suma_executat numeric(20,2), -- Metadata sursă source_url text, -- URL original al fișierului XML/XLSX source_hash text, -- sha256(URL + filename) pentru dedup fetched_at timestamptz NOT NULL DEFAULT now(), -- Constraint de unicitate: un (entitate, perioadă, side, clasificare, raport_tip, sursa) e unic. -- aliniat poate fi NULL — folosim COALESCE prin index parțial. CONSTRAINT uq_bugetar_executie_full UNIQUE (cui, period, raport_tip, side, capitol, subcapitol, paragraf, articol, aliniat, sector_bugetar) ); CREATE INDEX IF NOT EXISTS idx_bugetar_executie_cui_year ON bugetar.executie(cui, period_year DESC); CREATE INDEX IF NOT EXISTS idx_bugetar_executie_period ON bugetar.executie(period_year, period_month); CREATE INDEX IF NOT EXISTS idx_bugetar_executie_capitol ON bugetar.executie(capitol) WHERE capitol IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_bugetar_executie_judet_sector ON bugetar.executie(judet, sector_bugetar); CREATE INDEX IF NOT EXISTS idx_bugetar_executie_side_amount ON bugetar.executie(side, suma_executat DESC NULLS LAST); COMMENT ON TABLE bugetar.executie IS 'Linii de execuție bugetară (FXB-EXB-900/901/905). Un rând per (entitate, perioadă, clasificație, side).'; COMMENT ON COLUMN bugetar.executie.suma_executat IS 'Suma "principală" pentru queries: plati_efectuate la cheltuieli, incasari_realizate la venituri.'; -- ──────────────────────────────────────────────────────────────────────────── -- Universul entităților publice raportoare (descoperit din autocomplete API) -- ──────────────────────────────────────────────────────────────────────────── -- Pasul 1 al ingest-ului: enumeră (sector_bugetar × județ) → descarcă lista -- denumirilor de entități publice. Apoi fuzzy-match cu firms.entities pentru a -- atașa CUI. Asta deblochează crawl-ul Fazei 2 (când avem captcha solver). CREATE TABLE IF NOT EXISTS bugetar.entitate ( id bigserial PRIMARY KEY, entity_name text NOT NULL, -- denumire raw din MFP (case-sensitive) sector_bugetar text NOT NULL, -- '01' .. '05' judet text NOT NULL, -- cod 2 litere cui text, -- atașat post-hoc prin fuzzy match cui_match_score numeric(5,2), -- 0..1 confidence pentru matching cui_match_method text, -- 'exact' | 'fuzzy_anaf' | 'manual' is_ordonator_principal boolean DEFAULT false, -- true dacă apare în lista populateOcpAJAX raport_count integer DEFAULT 0, -- număr de raport-perioade descărcate cu succes last_fetched_period text, -- 'YYYY-MM' al ultimei perioade ingest-uate fetched_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (entity_name, sector_bugetar, judet) ); CREATE INDEX IF NOT EXISTS idx_bugetar_entitate_cui ON bugetar.entitate(cui) WHERE cui IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_bugetar_entitate_judet ON bugetar.entitate(judet, sector_bugetar); CREATE INDEX IF NOT EXISTS idx_bugetar_entitate_name_trgm ON bugetar.entitate USING gin(entity_name gin_trgm_ops); COMMENT ON TABLE bugetar.entitate IS 'Universul entităților publice raportoare descoperit din autocomplete API MFP. CUI-ul se atașează post-hoc prin fuzzy match cu firms.entities.'; -- ──────────────────────────────────────────────────────────────────────────── -- Job tracking — pentru reluare crawl la întreruperi -- ──────────────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS bugetar.crawl_job ( id bigserial PRIMARY KEY, cui text, entity_name text, period text NOT NULL, -- 'YYYY-MM' raport_tip text NOT NULL, status text NOT NULL DEFAULT 'pending', -- 'pending' | 'fetched' | 'parsed' | 'failed' | 'no_data' attempts smallint NOT NULL DEFAULT 0, last_error text, rows_inserted integer, fetched_at timestamptz, parsed_at timestamptz, updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (cui, period, raport_tip) ); CREATE INDEX IF NOT EXISTS idx_bugetar_crawl_status ON bugetar.crawl_job(status, period); -- ──────────────────────────────────────────────────────────────────────────── -- Materialized views pentru dashboard rapid -- ──────────────────────────────────────────────────────────────────────────── -- Sumar per (CUI × an): venituri totale + cheltuieli totale + nr luni raportate. CREATE MATERIALIZED VIEW IF NOT EXISTS bugetar.mv_per_cui_year AS SELECT cui, period_year, SUM(suma_executat) FILTER (WHERE side = 'venituri') AS venituri_total, SUM(suma_executat) FILTER (WHERE side = 'cheltuieli') AS cheltuieli_total, COUNT(DISTINCT period) AS months_reported, MAX(entity_name) AS entity_name_sample, MAX(judet) AS judet, MAX(sector_bugetar) AS sector_bugetar FROM bugetar.executie GROUP BY cui, period_year; CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_bugetar_cui_year ON bugetar.mv_per_cui_year(cui, period_year); CREATE INDEX IF NOT EXISTS idx_mv_bugetar_year_chelt ON bugetar.mv_per_cui_year(period_year, cheltuieli_total DESC NULLS LAST); COMMENT ON MATERIALIZED VIEW bugetar.mv_per_cui_year IS 'Sumar venituri+cheltuieli per (CUI × an). Refresh după fiecare ingest batch.'; -- Sumar per (CUI × an × capitol) — pentru analiza distribuției pe capitole bugetare. CREATE MATERIALIZED VIEW IF NOT EXISTS bugetar.mv_per_cui_capitol_year AS SELECT cui, period_year, capitol, side, SUM(suma_executat) AS suma_total, MAX(classification_label) AS capitol_label FROM bugetar.executie WHERE capitol IS NOT NULL GROUP BY cui, period_year, capitol, side; CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_bugetar_cui_cap_year ON bugetar.mv_per_cui_capitol_year(cui, period_year, capitol, side); COMMENT ON MATERIALIZED VIEW bugetar.mv_per_cui_capitol_year IS 'Sumar pe capitol bugetar per (CUI × an). Pentru analiza "pe ce s-au cheltuit banii".'; -- ──────────────────────────────────────────────────────────────────────────── -- COMMENTS & schema-level metadata -- ──────────────────────────────────────────────────────────────────────────── COMMENT ON SCHEMA bugetar IS 'Transparență Bugetară MFP — execuția bugetară lunară a entităților publice. Sursă: https://mfinante.gov.ro/apps/transparenta-bugetara/';