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>
17 KiB
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):
strip().upper()- elimină prefixul
ROcu spațiu opțional:re.sub(r'^RO\s*', '', cleaned) - corectează confuzii OCR frecvente:
O→0,I→1,L→1(viastr.maketrans('OIL', '011'))
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).
- ultima cifră = cifra de control; corpul = restul cifrelor
- corpul se aliniază la dreapta pe 9 poziții (padding cu 0 la stânga)
total = Σ(cifra_i * pondere_i)result = (total * 10) % 11; dacăresult == 10→result = 0- valid dacă
result == cifra_de_control
_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" + bareFalsesauNone→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.
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):
cod_fiscal_override=determine_correct_cod_fiscal(bare_cui, scpTVA)— corectează formaRO/bareanaf_strict = 1— semnal pentru Oracle că avem certitudine ANAF ⇒ căutare strictă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:
RETURN NULLdacăp_cod_fiscale NULL sau mai scurt deC_MIN_COD_FISCALv_cod_fiscal_curat := UPPER(TRIM(...))- extrage
v_bare_cui(fără RO) cuREGEXP_LIKE(..., '^RO\s*\d')+REGEXP_REPLACE 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ă doarRO<bare>șiRO <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.
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ă:
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ândp_strict_searche 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_juridicaare 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
-1la eșec
- tip: parametru explicit
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ștecod_fiscal= codul firmei cu spațiul colapsat (re.sub(r'\s+','',...))registru= registrul comerțuluiis_pj = 1
- PF (persoană fizică) — altfel:
denumire= numele din shipping (fallback billing), cuvinte sortate alfabetic (" ".join(sorted(...))) — stabilizează ordinea pentru matchcod_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
- Normalizare CUI: strip
RO(cu spațiu opțional), fix typo OCR (O→0,I→1,L→1), colapsare spații. - Validare: format (2–10 cifre) + checksum românesc (cheia
7,5,3,2,1,7,5,3,2,(Σ*10)%11, 10→0). - Client ANAF: POST batch (≤500) la
v9/tva, citeștescpTVA+denumire; respectă contractul tri-valent (None=down→tolerează, notFound→blochează, found→trece); retry doar pe 429/5xx/timeout. - Cache: stochează rezultatele ANAF (ex. 7 zile) ca să nu reinteroghezi la fiecare rulare.
- Gate: blochează doar PJ RO la format/checksum/notFound; PF și ANAF-down trec.
- Forma corectă CUI: aplică
ROdoar dacăscpTVA = True. - 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ă. - Creare partener nou: nume oficial ANAF pentru PJ; nu atinge partenerii existenți.
- Excluderi: ignoră înregistrările șterse; preferă active + cea mai nouă la ambiguitate.