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>
This commit is contained in:
Claude Agent
2026-06-17 06:26:38 +00:00
parent 71a3b32bd7
commit 4a03fe1016
2 changed files with 380 additions and 0 deletions

View File

@@ -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/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/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) | | [scripts/HANDOFF_MAPPING.md](scripts/HANDOFF_MAPPING.md) | Matching GoMag SKU → ROA articole (strategie si rezultate) |
--- ---

View File

@@ -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 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.