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

17 KiB
Raw Blame History

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'))
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 == 10result = 0
  5. 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" + bare
  • False sau Nonebare (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.scpTVAplătitor TVA? (bool)
  • date_generale.denumiredenumire 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):

  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.

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