# Căutarea și selecția clientului după codul fiscal (cu verificare ANAF) > Document de referință pentru replicarea logicii într-un alt proiect. > Descrie **unde** se caută, **cum** se normalizează codul fiscal, **ce** se verifică pe ANAF > și **procedurile** (Python + Oracle PL/SQL) implicate, end-to-end. Sistemul curent: GoMag (web) → ROA Oracle. Partenerii sunt căutați/creați în tabela Oracle `nom_parteneri`, iar codul fiscal este validat și corectat folosind webserviciul ANAF. --- ## 0. Fișiere sursă (în acest proiect) | Componentă | Fișier | Funcții cheie | |---|---|---| | Validare/normalizare CUI + apel ANAF | `api/app/services/anaf_service.py` | `strip_ro_prefix`, `validate_cui`, `validate_cui_checksum`, `sanitize_cui`, `check_vat_status_batch`, `_call_anaf_api`, `determine_correct_cod_fiscal`, `normalize_company_name` | | Extragere date partener din comandă | `api/app/services/import_service.py` | `determine_partner_data` | | Orchestrare sync + gate CUI + override | `api/app/services/sync_service.py` | `evaluate_cui_gate`, secțiunea Step 4/5 (ANAF batch, anaf_strict, overrides) | | Căutare/creare partener (Oracle) | `api/database-scripts/05_pack_import_parteneri.pck` | `cauta_partener_dupa_cod_fiscal`, `cauta_sau_creeaza_partener`, `cauta_partener_dupa_denumire` | | Teste | `api/tests/test_cui_validation.py`, `test_partner_cui_lookup.py`, `test_partner_anaf_override.py`, `test_sync_cui_gate.py` | — | --- ## 1. Privire de ansamblu (flux complet) ``` Comandă web (JSON) │ ▼ determine_partner_data() # PJ vs PF, extrage cod_fiscal/denumire/registru │ ▼ (doar PJ romanesc cu cod_fiscal) sanitize_cui() # scoate RO, corectează typo OCR, validează format+checksum │ ▼ check_vat_status_batch() → ANAF # POST batch la webserviciul ANAF (platitor TVA?) │ răspuns: {scpTVA, denumire_anaf, checked_at} (cache 7 zile in SQLite) ▼ evaluate_cui_gate() # BLOCHEAZĂ comanda dacă: format invalid / checksum gresit / ANAF notFound │ (ANAF down → tolerează, lasă să treacă) ▼ determine_correct_cod_fiscal() # aplică prefix RO dacă e plătitor TVA anaf_strict = 1 # marchează că avem date ANAF → căutare STRICTĂ în Oracle denumire_override = denumire_anaf # la CREARE partener nou folosește numele oficial ANAF │ ▼ PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener(cod_fiscal, denumire, registru, is_pj, anaf_strict, OUT id_part) │ ├─ STEP 1: cauta_partener_dupa_cod_fiscal() # prioritate 1 ├─ STEP 2: cauta_partener_dupa_denumire() # prioritate 2 (sărit în mod strict) ├─ STEP 2b: permutări nume (doar PF, non-strict) └─ STEP 3: creare partener nou (pack_def.adauga_partener) ``` Principiu central: **codul fiscal are prioritate** în identificarea partenerului; numele e doar fallback. ANAF servește la (a) a decide forma corectă a CUI (cu/fără `RO`) și (b) a bloca CUI-uri inexistente/invalide înainte de a polua baza cu parteneri greșiți. --- ## 2. Normalizarea codului fiscal (Python) Fișier: `api/app/services/anaf_service.py`. ### 2.1 `strip_ro_prefix(cod_fiscal) -> str` Aduce CUI-ul la forma „bare” (doar cifre): 1. `strip().upper()` 2. elimină prefixul `RO` cu spațiu opțional: `re.sub(r'^RO\s*', '', cleaned)` 3. corectează confuzii OCR frecvente: `O→0`, `I→1`, `L→1` (via `str.maketrans('OIL', '011')`) ```python def strip_ro_prefix(cod_fiscal: str) -> str: if not cod_fiscal: return "" cleaned = cod_fiscal.strip().upper() cleaned = re.sub(r'^RO\s*', '', cleaned) cleaned = cleaned.translate(str.maketrans('OIL', '011')) return cleaned ``` În plus, la extragerea din comandă (`import_service.determine_partner_data`) se colapsează **tot** spațiul intern: `cod_fiscal = re.sub(r'\s+', '', raw_cf)` → `"RO 34963277"` devine `"RO34963277"`. ### 2.2 `validate_cui(bare_cui) -> bool` Format: doar cifre, lungime 2–10. ### 2.3 `validate_cui_checksum(bare_cui) -> bool` — algoritmul oficial românesc Cheia de control: `[7, 5, 3, 2, 1, 7, 5, 3, 2]` (9 ponderi). 1. ultima cifră = cifra de control; corpul = restul cifrelor 2. corpul se aliniază la dreapta pe 9 poziții (padding cu 0 la stânga) 3. `total = Σ(cifra_i * pondere_i)` 4. `result = (total * 10) % 11`; dacă `result == 10` → `result = 0` 5. valid dacă `result == cifra_de_control` ```python _CUI_KEY = [7, 5, 3, 2, 1, 7, 5, 3, 2] def validate_cui_checksum(bare_cui: str) -> bool: if not validate_cui(bare_cui): return False digits = [int(d) for d in bare_cui] check_digit = digits[-1] body = digits[:-1] padded = [0] * (9 - len(body)) + body total = sum(d * k for d, k in zip(padded, _CUI_KEY)) result = (total * 10) % 11 if result == 10: result = 0 return result == check_digit ``` ### 2.4 `sanitize_cui(raw_cf) -> (bare_cui, warning|None)` Combină normalizarea + validarea și produce avertisment, fără a arunca excepție: - strip RO + typo fix - dacă format **și** checksum OK → `(bare, None)` - dacă format OK dar checksum greșit → `(bare, "CUI ... nu trece verificarea cifrei de control")` - dacă nici format → `(bare, "CUI ... conține caractere invalide...")` ### 2.5 `determine_correct_cod_fiscal(bare_cui, is_vat_payer) -> str` Decide forma corectă pe baza statusului TVA ANAF: - `is_vat_payer is True` → `"RO" + bare` - `False` sau `None` → `bare` (conservator) ### 2.6 `normalize_company_name(name)` (auxiliar, comparare nume firme) Uppercase, scoate diacritice, elimină forme juridice (`SRL/SA/SC/SNC/PFA/II/Întreprindere Individuală`), elimină punctuația, colapsează spațiile. Util pentru comparații nume, nu pentru lookup-ul principal. --- ## 3. Ce se verifică pe ANAF și cum Fișier: `api/app/services/anaf_service.py`. ### 3.1 Endpoint ``` POST https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva Content-Type: application/json Body: [{"cui": , "data": "YYYY-MM-DD"}, ...] # batch, max 500 / cerere ``` ### 3.2 Câmpuri citite din răspuns Pentru fiecare CUI din lista `found`: - `inregistrare_scop_Tva.scpTVA` → **plătitor TVA?** (bool) - `date_generale.denumire` → **denumire oficială ANAF** (`denumire_anaf`) - `date_generale.cui` → CUI (cheia rezultatului) Plus `checked_at` (timestamp local), folosit pentru validitatea cache-ului. CUI-urile din lista `notFound` (ANAF le întoarce ca **întregi simpli**, nu obiecte) primesc `{scpTVA: None, denumire_anaf: ""}`. ### 3.3 Contractul de date ANAF (foarte important) Restul sistemului interpretează rezultatul ANAF strict astfel: | Rezultat | Semnificație | Acțiune | |---|---|---| | `None` (lipsă din dict / batch eșuat) | ANAF down / eroare tranzitorie | **tolerează** (lasă să treacă) | | `{scpTVA: None, denumire_anaf: ""}` | ANAF notFound explicit | **blochează** comanda | | `{scpTVA: bool, denumire_anaf: str}` | ANAF found | **trece** | > Dacă schimbi această semantică în clientul ANAF, trebuie actualizat și `evaluate_cui_gate`. ### 3.4 Robustețe apel (`_call_anaf_api`) - timeout 10s - `429` → 1 retry după 10s, apoi renunță (`{}`) - `5xx` → 1 retry după 3s, apoi renunță (`{}`) - `4xx` → fail-fast, **fără** retry (`{}`) - timeout / excepție → 1 retry după 3s - „renunță” înseamnă întoarce `{}` ⇒ tratat ca *transient* ⇒ comanda **nu** e blocată ### 3.5 Cache & batching (orchestrare în `sync_service`) - se adună CUI-urile firmelor **RO** din comenzile importabile (`sanitize_cui` + `validate_cui`) - se verifică cache-ul SQLite (`anaf_cache`, valabilitate 7 zile); doar CUI-urile necacheate intră în batch - pre-populare: la fiecare sync se reîmprospătează CUI-urile din ultimele 3 luni cu cache expirat - rezultatele batch se scriu în cache (`bulk_populate_anaf_cache`) --- ## 4. „Gate”-ul CUI — când se blochează comanda Fișier: `api/app/services/sync_service.py`, funcția `evaluate_cui_gate`. ```python def evaluate_cui_gate(is_ro_company, company_code_raw, bare_cui, anaf_data) -> str | None: if not is_ro_company or not company_code_raw: return None # PF sau firmă non-RO → trece if not anaf_service.validate_cui(bare_cui): return f"CUI invalid (format): {company_code_raw!r}" if not anaf_service.validate_cui_checksum(bare_cui): return f"CUI invalid (cifra de control): {bare_cui}" if (anaf_data is not None and anaf_data.get("scpTVA") is None and not (anaf_data.get("denumire_anaf") or "").strip()): return f"CUI {company_code_raw!r} ... nu exista in registrul ANAF ..." return None # altfel trece ``` Reguli rezumate: - **PF** sau **firmă non-RO** → trece necondiționat (fără verificare ANAF/CUI) - **PJ RO**: blochează la format invalid, checksum greșit, sau ANAF notFound explicit - **ANAF down** (`anaf_data is None`) → trece (toleranță, ca să nu blocăm tot importul când ANAF pică) La blocare se scrie o comandă cu status `ERROR` (nu se creează partener). Există un prag de siguranță: după >10 erori, importul se oprește. --- ## 5. Determinarea modului de căutare și a override-urilor (Python) Fișier: `api/app/services/sync_service.py` (în bucla de import, Step 5). Pentru fiecare comandă PJ RO cu date ANAF valide (`scpTVA is not None`): 1. **`cod_fiscal_override`** = `determine_correct_cod_fiscal(bare_cui, scpTVA)` — corectează forma `RO`/bare 2. **`anaf_strict = 1`** — semnal pentru Oracle că avem certitudine ANAF ⇒ căutare strictă 3. **`denumire_override`** = `denumire_anaf` (uppercase, doar dacă non-gol) — folosit **doar la creare** partener nou Aceste valori se transmit către `import_single_order(...)`, care apoi cheamă procedura Oracle. `anaf_strict` rămâne `None` (fallback non-strict) când: firmă non-RO, fără date ANAF, sau ANAF down. --- ## 6. Căutarea/selecția în Oracle Fișier: `api/database-scripts/05_pack_import_parteneri.pck`. ### 6.1 `cauta_partener_dupa_cod_fiscal(p_cod_fiscal, p_strict_search) RETURN NUMBER` Tabela: `nom_parteneri`, coloana `cod_fiscal`. Întotdeauna filtrează `NVL(sters,0)=0` (exclude șterse) și preferă activii (`NVL(inactiv,0) ASC`), apoi `id_part DESC` (cel mai nou). Pași comuni: 1. `RETURN NULL` dacă `p_cod_fiscal` e NULL sau mai scurt de `C_MIN_COD_FISCAL` 2. `v_cod_fiscal_curat := UPPER(TRIM(...))` 3. extrage `v_bare_cui` (fără RO) cu `REGEXP_LIKE(..., '^RO\s*\d')` + `REGEXP_REPLACE` 4. `v_ro_cui := 'RO' || v_bare_cui` **Mod STRICT (`p_strict_search = 1`)** — diferențiază plătitor vs neplătitor TVA, **fără cross-match** (sunt entități fiscale distincte): - input cu prefix RO (`^RO\s*\d`, plătitor TVA) → caută **doar** `RO` și `RO ` (cu spațiu) - input bare (neplătitor) → caută **doar** `` **Mod NON-STRICT (`p_strict_search IS NULL`)** — anti-duplicare, caută toate cele 3 forme (`bare`, `RO`, `RO `), cu prioritate pe forma exactă a input-ului. ```sql FUNCTION cauta_partener_dupa_cod_fiscal(p_cod_fiscal IN VARCHAR2, p_strict_search IN NUMBER DEFAULT NULL) RETURN NUMBER IS v_id_part NUMBER; v_cod_fiscal_curat VARCHAR2(50); v_bare_cui VARCHAR2(50); v_ro_cui VARCHAR2(52); BEGIN IF p_cod_fiscal IS NULL OR LENGTH(TRIM(p_cod_fiscal)) < C_MIN_COD_FISCAL THEN RETURN NULL; END IF; v_cod_fiscal_curat := UPPER(TRIM(p_cod_fiscal)); IF REGEXP_LIKE(v_cod_fiscal_curat, '^RO\s*\d') THEN v_bare_cui := TRIM(REGEXP_REPLACE(v_cod_fiscal_curat, '^RO\s*', '')); ELSE v_bare_cui := v_cod_fiscal_curat; END IF; v_ro_cui := 'RO' || v_bare_cui; BEGIN IF p_strict_search = 1 THEN IF REGEXP_LIKE(v_cod_fiscal_curat, '^RO\s*\d') THEN SELECT id_part INTO v_id_part FROM ( SELECT id_part FROM nom_parteneri WHERE UPPER(TRIM(cod_fiscal)) IN (v_ro_cui, 'RO ' || v_bare_cui) AND NVL(sters, 0) = 0 ORDER BY NVL(inactiv, 0) ASC, id_part DESC ) WHERE ROWNUM = 1; ELSE SELECT id_part INTO v_id_part FROM ( SELECT id_part FROM nom_parteneri WHERE UPPER(TRIM(cod_fiscal)) = v_bare_cui AND NVL(sters, 0) = 0 ORDER BY NVL(inactiv, 0) ASC, id_part DESC ) WHERE ROWNUM = 1; END IF; ELSE SELECT id_part INTO v_id_part FROM ( SELECT id_part FROM nom_parteneri WHERE UPPER(TRIM(cod_fiscal)) IN (v_bare_cui, v_ro_cui, 'RO ' || v_bare_cui) AND NVL(sters, 0) = 0 ORDER BY NVL(inactiv, 0) ASC, CASE WHEN UPPER(TRIM(cod_fiscal)) = v_cod_fiscal_curat THEN 0 ELSE 1 END ASC, id_part DESC ) WHERE ROWNUM = 1; END IF; RETURN v_id_part; EXCEPTION WHEN NO_DATA_FOUND THEN RETURN NULL; END; EXCEPTION WHEN OTHERS THEN pINFO('ERROR in cauta_partener_dupa_cod_fiscal: ' || SQLERRM, 'IMPORT_PARTENERI'); RAISE; END; ``` ### 6.2 `cauta_sau_creeaza_partener(...)` — orchestrarea selecției/creării Semnătură: ```sql PROCEDURE cauta_sau_creeaza_partener( p_cod_fiscal IN VARCHAR2, p_denumire IN VARCHAR2, p_registru IN VARCHAR2, p_is_persoana_juridica IN NUMBER DEFAULT NULL, -- 1=PJ, 0=PF, NULL=auto-detect CNP p_strict_search IN NUMBER DEFAULT NULL, -- 1=avem date ANAF p_id_partener OUT NUMBER) -- >0 ok, -1 eroare ``` Ordinea priorităților: - **STEP 1 — după cod fiscal** (prioritate maximă): `cauta_partener_dupa_cod_fiscal(cod, strict)`. Dacă găsește → return. - **STEP 2 — după denumire exactă**: `cauta_partener_dupa_denumire` (match uppercase exact). **Sărit** când `p_strict_search` e setat (în mod ANAF strict vrem partener nou cu CUI corect, nu unul vechi găsit doar după nume). - **STEP 2b — permutări nume** (doar PF, doar non-strict): rezolvă inversarea nume/prenume din web. 2 cuvinte → încearcă `W2 W1`; 3 cuvinte → încearcă celelalte 5 permutări. - **STEP 3 — creare partener nou** (`pack_def.adauga_partener`): - tip: parametru explicit `p_is_persoana_juridica` are prioritate; altfel auto-detect prin CNP (13 cifre) - PF: `separa_nume_prenume`, `tnTip_persoana => 2` - PJ: `tnTip_persoana => 1` - denumirea se trece prin `strip_diacritics` înainte de stocare - return `-1` la eșec ### 6.3 `cauta_partener_dupa_denumire(p_denumire)` Match exact pe denumire normalizată (`UPPER(TRIM(...))`). Folosit ca fallback secundar și pentru permutările PF. --- ## 7. PJ vs PF: cum se decide tipul și ce date se folosesc Fișier: `api/app/services/import_service.py`, `determine_partner_data`. - **PJ (persoană juridică)** — când comanda are flag `billing.is_company`: - `denumire` = numele firmei (curățat, uppercase); fallback la numele persoanei din billing dacă lipsește - `cod_fiscal` = codul firmei cu spațiul colapsat (`re.sub(r'\s+','',...)`) - `registru` = registrul comerțului - `is_pj = 1` - **PF (persoană fizică)** — altfel: - `denumire` = numele din **shipping** (fallback billing), cuvinte **sortate alfabetic** (`" ".join(sorted(...))`) — stabilizează ordinea pentru match - `cod_fiscal = None`, `registru = None`, `is_pj = 0` La creare partener **nou PJ** se preferă numele oficial ANAF (`denumire_override`) în locul numelui din web (care poate avea typo-uri). Partenerii **existenți nu sunt modificați**. --- ## 8. Exemple (input → comportament) | Input cod_fiscal | scpTVA (ANAF) | anaf_strict | Caută în `nom_parteneri` | |---|---|---|---| | `"RO 34963277"` | True | 1 | `RO34963277`, `RO 34963277` (doar formele RO) | | `"34963277"` (plătitor) | True | 1 | corectat → `RO34963277` (override), apoi formele RO | | `"34963277"` (neplătitor) | False | 1 | doar `34963277` (bare) | | `"34963277"` | — (ANAF down) | NULL | toate 3 formele (anti-dedup) | | `"RO123"` strict | — | 1 | nu face match pe `123` bare (entități distincte) | | CUI checksum greșit | — | — | **BLOCAT** la gate (status ERROR) | | CUI inexistent ANAF | None+gol | — | **BLOCAT** la gate | | PF (fără cod) | — | — | căutare după nume (+permutări), apoi creare PF | --- ## 9. Checklist de replicare în alt proiect 1. **Normalizare CUI**: strip `RO` (cu spațiu opțional), fix typo OCR (`O→0,I→1,L→1`), colapsare spații. 2. **Validare**: format (2–10 cifre) + checksum românesc (cheia `7,5,3,2,1,7,5,3,2`, `(Σ*10)%11`, 10→0). 3. **Client ANAF**: POST batch (≤500) la `v9/tva`, citește `scpTVA` + `denumire`; respectă contractul tri-valent (None=down→tolerează, notFound→blochează, found→trece); retry doar pe 429/5xx/timeout. 4. **Cache**: stochează rezultatele ANAF (ex. 7 zile) ca să nu reinteroghezi la fiecare rulare. 5. **Gate**: blochează doar PJ RO la format/checksum/notFound; PF și ANAF-down trec. 6. **Forma corectă CUI**: aplică `RO` doar dacă `scpTVA = True`. 7. **Căutare DB**: prioritate cod fiscal → nume exact → (PF) permutări nume → creare. În mod strict (date ANAF), separă plătitor (`RO...`) de neplătitor (`bare`) **fără cross-match**; în mod non-strict, caută toate formele cu prioritate pe forma exactă. 8. **Creare partener nou**: nume oficial ANAF pentru PJ; nu atinge partenerii existenți. 9. **Excluderi**: ignoră înregistrările șterse; preferă active + cea mai nouă la ambiguitate.