Files
gomag-vending/docs/cautare-selectie-client-cod-fiscal-anaf.md
Claude Agent 4a03fe1016 docs: ghid cautare/selectie client dupa cod fiscal + verificare ANAF
Document de referinta end-to-end (replicabil in alt proiect): normalizare
CUI (strip RO, typo OCR, checksum), client ANAF (v9/tva, contract tri-valent,
cache), gate CUI, mod strict platitor/neplatitor TVA si procedurile Oracle
cauta_partener_dupa_cod_fiscal / cauta_sau_creeaza_partener. Link in README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:26:38 +00:00

380 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 210.
### 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": <int>, "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<bare>` și `RO <bare>` (cu spațiu)
- input bare (neplătitor) → caută **doar** `<bare>`
**Mod NON-STRICT (`p_strict_search IS NULL`)** — anti-duplicare, caută toate cele 3 forme
(`bare`, `RO<bare>`, `RO <bare>`), 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 (210 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.