diff --git a/README.md b/README.md index f978b7d..dbb66d5 100644 --- a/README.md +++ b/README.md @@ -516,6 +516,7 @@ ssh -i ~/.ssh/id_ed25519 -p 22122 gomag@79.119.86.134 \ |--------|---------| | [docs/oracle-schema-notes.md](docs/oracle-schema-notes.md) | Schema Oracle: tabele comenzi, facturi, preturi, proceduri cheie | | [docs/pack_facturare_analysis.md](docs/pack_facturare_analysis.md) | Analiza flow facturare: call chain, parametri, STOC lookup, FACT-008 | +| [docs/cautare-selectie-client-cod-fiscal-anaf.md](docs/cautare-selectie-client-cod-fiscal-anaf.md) | Cautare/selectie client dupa cod fiscal: normalizare CUI, verificare ANAF, gate, proceduri Oracle | | [scripts/HANDOFF_MAPPING.md](scripts/HANDOFF_MAPPING.md) | Matching GoMag SKU → ROA articole (strategie si rezultate) | --- diff --git a/docs/cautare-selectie-client-cod-fiscal-anaf.md b/docs/cautare-selectie-client-cod-fiscal-anaf.md new file mode 100644 index 0000000..e42e77b --- /dev/null +++ b/docs/cautare-selectie-client-cod-fiscal-anaf.md @@ -0,0 +1,379 @@ +# 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.