# CNSC — Consiliul Național de Soluționare a Contestațiilor

Status: **Stage 1 ingest live**. Stage 2 (PDF parse) is the next step.

Sursa: `http://portal.cnsc.ro/decizii.html` — registru oficial al deciziilor pe contestații depuse împotriva procedurilor SEAP. Bază legală: Legea 101/2016.

---

## 1. Ce s-a livrat (Stage 1)

| Artifact | Path |
|---|---|
| Schema migration | `services/seap-scraper/sql/033_cnsc.sql` |
| Scraper TS | `services/seap-scraper/src/scrape-cnsc.ts` |
| Cron wrapper | `services/seap-scraper/cron/scrape-cnsc.sh` |
| Plan / handoff | `services/seap-scraper/CNSC-PLAN.md` (this file) |

DB obiecte (schema `cnsc`):
- `cnsc.decizii` — tabel principal, PK natural `(decision_no, decision_year)`
- `cnsc.scrape_log` — istoric run-uri scraper
- `cnsc.mv_per_authority_cui` — rollup per autoritate contractantă
- `cnsc.mv_per_contestator_cui` — rollup per contestator (firmă)

Smoke test (3 pagini, run 2026-05-10):
- 150 decizii ingerate, 100% cu PDF URL
- 53% au CUI autoritate, 91% au CUI contestator (în listing-ul CNSC)
- Cross-join cu `seap.announcements`: **26,046 hits via authority_cui**, **6,260 via contestator_cui**.

---

## 2. Cum funcționează scraping-ul

Portalul CNSC e ASP.NET WebForms cu un quirk: **paginarea e stateful pe sesiune**. AJAX-ul nu acceptă pagina în body — server-ul citește pagina curentă din state-ul de sesiune, setat de un GET prealabil pe `/decizii.html?page=N`.

Flow per pagină (sesiune partajată cu `ASP.NET_SessionId` cookie):
1. `GET  /decizii.html?a=search&reg:registrationDate=-&page=N` — setează state-ul
2. `POST /Default.aspx/CallWebMethod` cu body `{sender, methodName:'get', senderParams, isBuletin:'0'}`
3. Răspunsul e JSON `{"d":"<html><table>...</table></html>"}` — 50 rânduri / pagină

Total: ~617 pagini × 50 rânduri ≈ **30,800 decizii**, datate 2016 → prezent. Pagina 617 are doar 13 rânduri (rest 2016).

Listing-ul oferă DEJA, fără să descarci PDF-ul:
- numărul deciziei + anul + data înregistrării
- numele și CUI-ul contestatorului (uneori multiplii — asociere)
- numele și CUI-ul autorității contractante
- numărul de înregistrare CNSC
- URL-ul PDF (`sivadoc/download.aspx?docUID=...&filename=...`)

Asta e **80% din valoare** — joinabil direct cu `seap.announcements` (CUI ↔ CUI), cu `firms.entities`, etc.

### Idempotență

`ON CONFLICT (decision_no, decision_year) DO UPDATE` — re-run-uri zilnice sunt fără efecte secundare. Decizii noi: INSERT. Decizii existente: UPDATE doar `fetched_at`.

### Run

```bash
# Smoke test (2 pagini ≈ 100 rânduri, ~15s)
sudo MAX_PAGES=2 /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh

# Full crawl (estimat: 7-10 min, ~617 pagini × 250ms politețe + ~7s/pagină)
sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh

# Resume după întrerupere parțială
sudo START_PAGE=400 /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh
```

Cron sugerat (zilnic, prinde decizii noi):
```
30 5 * * *  /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh
```

---

## 3. Recipe-uri cross-source posibile (LIVE acum, Stage 1)

### 3.1. Top autorități contestate

Câte contestații a primit fiecare autoritate contractantă, în trecut. Indicator de **risc procedural**.

```sql
SELECT
  ac AS authority_cui,
  e.name AS authority_name,
  COUNT(*) AS contestations_count,
  COUNT(DISTINCT cc) AS distinct_challengers,
  MIN(d.registration_date) AS first_seen,
  MAX(d.registration_date) AS last_seen
FROM cnsc.decizii d,
     unnest(d.authority_cuis) ac,
     unnest(d.contestator_cuis) cc
LEFT JOIN firms.entities e ON e.cui = ac
GROUP BY ac, e.name
HAVING COUNT(*) >= 5
ORDER BY contestations_count DESC
LIMIT 50;
```

### 3.2. Cei mai litigioși ofertanți

Firme care contestă cel mai mult. La ANAF poate fi un semnal de "vexatious bidder" sau, invers, de actor care apără concurența contra abuzurilor.

```sql
SELECT
  cc AS contestator_cui,
  e.name,
  COUNT(*) AS contestations_filed,
  COUNT(DISTINCT ac) AS distinct_targets
FROM cnsc.decizii d,
     unnest(d.contestator_cuis) cc,
     unnest(d.authority_cuis) ac
LEFT JOIN firms.entities e ON e.cui = cc
GROUP BY cc, e.name
HAVING COUNT(*) >= 3
ORDER BY contestations_filed DESC
LIMIT 50;
```

### 3.3. Contestator vs SEAP-supplier overlap

Câte din contestațiile depuse de o firmă sunt împotriva unei proceduri pe care a câștigat-o ulterior cineva din vecinătate.

```sql
SELECT
  d.decision_no, d.decision_year, d.registration_date,
  d.contestator_name_raw,
  d.authority_name,
  COUNT(s.id) AS seap_announcements_with_same_supplier,
  SUM(s.awarded_value) AS total_won_by_contestator_at_same_authority
FROM cnsc.decizii d,
     unnest(d.contestator_cuis) cc,
     unnest(d.authority_cuis) ac
JOIN seap.announcements s
  ON s.supplier_cui = cc AND s.authority_cui = ac
GROUP BY d.id
ORDER BY total_won_by_contestator_at_same_authority DESC NULLS LAST
LIMIT 25;
```

---

## 4. Killer queries (UNLOCKED de Stage 2 — PDF parse)

Aceste rapoarte cer `decision_type` (admis/respins) extras din PDF.

### 4.1. Autoritățile cu cea mai mare RATĂ DE CONTESTAȚII PIERDUTE

Semnal puternic de **procedură vicioasă**: autoritatea pierde la CNSC mai des decât media → fie scrie caiete de sarcini deficitare, fie evaluează vădit părtinitor.

```sql
SELECT
  ac AS cui,
  e.name,
  COUNT(*) FILTER (WHERE decision_type IN ('admis','admis_in_parte')) AS lost,
  COUNT(*) FILTER (WHERE decision_type = 'respins')                  AS won,
  COUNT(*) FILTER (WHERE decision_type IS NOT NULL)                  AS resolved,
  ROUND(
    100.0 * COUNT(*) FILTER (WHERE decision_type IN ('admis','admis_in_parte'))
          / NULLIF(COUNT(*) FILTER (WHERE decision_type IS NOT NULL), 0)
  , 1) AS pct_lost
FROM cnsc.decizii d, unnest(d.authority_cuis) ac
LEFT JOIN firms.entities e ON e.cui = ac
WHERE d.decision_type IS NOT NULL
GROUP BY ac, e.name
HAVING COUNT(*) FILTER (WHERE decision_type IS NOT NULL) >= 5
ORDER BY pct_lost DESC, resolved DESC
LIMIT 50;
```

### 4.2. SEAP procedure → CNSC outcome → award

```sql
SELECT
  s.ref_number, s.title, s.authority_name,
  s.awarded_value, s.supplier_name,
  d.decision_no, d.decision_type, d.contestator_name_raw
FROM seap.announcements s
JOIN cnsc.decizii d ON d.seap_procedure_ref = s.ref_number
WHERE s.awarded_value > 1000000
  AND d.decision_type = 'admis'
ORDER BY s.awarded_value DESC
LIMIT 100;
```

→ "Tendere mari unde contestația A FOST admisă (procedura era vicioasă) DAR procedura totuși s-a finalizat cu un câștigător." Multe au fost adjudecate exact acelorași firme atacate inițial — pattern de captură.

---

## 5. Stage 2 — Estimare PDF parse (15-25h)

### Ce trebuie extras din fiecare PDF

1. **`seap_procedure_ref`** — pattern variabil în text liber:
   - "în cadrul procedurii simplificată...nr. CN1234567"
   - "anunț de participare nr. ADV2024XXXXX"
   - "concurs de soluții...SCN2023..."
   - Uneori e absent (decizii pe contestații de clarificări — ~15-20%)
2. **`decision_type`** — căutat în zonă "DISPUNE / DISPOZITIV / DECIDE":
   - "admite contestația" → `admis`
   - "admite în parte" → `admis_in_parte`
   - "respinge contestația" → `respins`
   - "redirecționează" → `redirectionat`
   - "arhivează" → `arhivat`
   - "constată inadmisibilitatea" → `respins` (subtype)
3. **`decision_date`** — data deciziei (≠ data înregistrării; e mai târziu)
4. **`decision_summary`** — primele 500 chars după "DECIDE"

### Parser pseudocode

```typescript
import { execFile } from 'child_process';

async function pdfText(pdfUrl: string): Promise<string> {
  // Fetch PDF, save to temp, run pdftotext -layout, return text
  // Cache by sha1 of bytes; idempotent.
}

function parseDecision(text: string) {
  const seapRefMatch = text.match(/\b(CN[0-9]{6,}|SCN[0-9]+|ADV[0-9]+|RFQ[0-9]+)\b/i);

  // Decision type — search after dispositive heading
  const dispoIdx = Math.max(text.indexOf('DISPUNE'), text.indexOf('DISPOZITIV'), text.indexOf('Decide'));
  const dispo = dispoIdx > 0 ? text.slice(dispoIdx, dispoIdx + 1500).toLowerCase() : '';
  let decisionType: string | null = null;
  if (/admite[^a-zăîâșț]+\s*(în parte|in parte)/.test(dispo)) decisionType = 'admis_in_parte';
  else if (/admite\b/.test(dispo)) decisionType = 'admis';
  else if (/respinge\b/.test(dispo)) decisionType = 'respins';
  else if (/redirec[țt]ion/.test(dispo)) decisionType = 'redirectionat';
  else if (/arhiv/.test(dispo)) decisionType = 'arhivat';

  const dateMatch = text.match(/Data:?\s*(\d{1,2})[./](\d{1,2})[./](\d{4})/);

  return { seapRef: seapRefMatch?.[0] ?? null, decisionType, decisionDate: dateMatch ? `${dateMatch[3]}-${dateMatch[2].padStart(2,'0')}-${dateMatch[1].padStart(2,'0')}` : null };
}
```

### Effort breakdown (15-25h)

| Task | h |
|---|---|
| Set up `pdftotext` invocation + tempfile cleanup, retry on transient HTTP errors | 1.5 |
| Download throttling (1 PDF/s polite) + resumable per-doc state | 1 |
| First-pass parser (regex above) on 500-PDF eval set + measure coverage | 3 |
| Iterate on edge cases (admite parțial, multi-procedure decisions, scanned PDFs that need OCR) | 4-6 |
| OCR fallback (~5-10% of older PDFs are images) — `tesseract -l ron` | 3-5 |
| Concurrency runner with rate limit, persistent skip log, MV refresh | 2 |
| Productionize cron + monitoring | 1 |
| Documentation + recipe pages on UI | 1-2 |

Total descărcare: ~30K PDF × ~100 KB = ~3 GB → trivial pe satra.

---

## 6. Riscuri și ce să nu facem

- **NU îmbunătățim Stage 2 fără să avem un eval set adnotat manual.** Pe 30K PDF-uri o regexă poate avea 20% fals-pozitivi pe `decision_type` — aproape inutilizabil pentru recipe-ul "rate de contestații pierdute" (semnalul e zgomotos). Investește 2h să adnotezi 200 PDF-uri pe mână, apoi măsoară.
- **Scrape rate**: serverul portal.cnsc.ro pare modest (vechi); 250ms / pagină politețe e setat în scraper, NU coborî sub 100ms.
- **Schema cnsc.decizii NU stochează PDF-ul** (doar URL + docuid_b64). PDF-urile rămân la sursă; refeed e oricând posibil. Asta evită 3 GB în DB.
- **CUI-uri în listing au prefix uneori (RO123)**, alteori cifre pure. Normalizat la cifre-only în array, raw păstrat în `*_raw`. Joinabil cu `firms.entities.cui` (care e la fel cifre-only).
- Listing-ul are inconsistențe: `1378/2025` poate apărea pe pagină 2 (între numerele 2026), pentru că numerotarea e per-comisie (`Cx`), nu strict cronologică. UNIQUE pe `(decision_no, decision_year)` previne duplicarea.

---

## 7. Plan imediat / next steps

1. **Run full Stage 1** (~10 min): `sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh`
   → ~30K rânduri în `cnsc.decizii`.
2. **Adaugă cron daily** (5:30 AM) — capturează decizii noi.
3. **Schiță 2 recipe-uri pe `src/lib/recipes.ts`** (de către agent UI):
   - `cnscTopAutoritatiContestate` (3.1)
   - `cnscTopContestatori` (3.2)
4. **Stage 2 PDF parse** — programează după ce avem o sesiune dedicată ~25h.
5. **(Opțional)** verifică dacă portal.cnsc.ro publică un buletin oficial structurat (am văzut `/buletinoficial.html` — 1.1MB de CPV-uri; n-am explorat) care ar putea oferi mai mult metadata per-decizie.
