feat(api): validare cod_prestatie la nomenclator + optiune on_unmapped_error

Cod_prestatie necunoscut in nomenclator nu se mai trimite raw la RAR (HTTP 500
ORA-12899 + record partial FINALIZATA pe care reconcilierea il marca fals sent):
e promovat la cod_op_service si tratat ca operatie de mapat.

Optiune top-level boolean on_unmapped_error pe POST /v1/prezentari + /valideaza:
  - false (default) -> submission needs_mapping (intra in editor)
  - true            -> respinge fara enqueue (status error, submission_id=null, erori)
  - None            -> default per-cont accounts.on_unmapped_error_default (implicit 0)
Inlocuieste enum-ul anterior on_unmapped (needs_mapping/error) cu un boolean mai
simplu; coloana de cont migrata aditiv la INTEGER on_unmapped_error_default.

Izolare teste de .env-ul de dezvoltare: tests/conftest.py fixeaza default sigur
pe AUTOPASS_REQUIRE_API_KEY / AUTOPASS_WORKER_USE_TEST_CREDS (precedenta peste
.env in pydantic-settings) + fixturile env din test_creds_delivery/test_t1 pineaza
explicit aceste flag-uri, ca fallback-ul creds pe cont sa fie atins.

Teste: 752 passed (fara flag pe CLI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-23 19:35:47 +00:00
parent c842e3352a
commit 6bad6bc01e
17 changed files with 376 additions and 23 deletions

View File

@@ -59,18 +59,19 @@ Flux: validare (`validation.py`) → mapare operatie→cod (`mapping.py`) → en
- **`AUTOPASS_CREDS_KEY` trebuie sa fie ACEEASI intre API si worker.** API cripteaza creds RAR (Fernet), worker le decripteaza. Chei diferite → worker nu poate decripta → trimiterile esueaza. `start.sh both` genereaza o cheie efemera partajata; pentru prod pune una persistenta in `.env`. (`crypto.py`)
- **Idempotenta = hash de continut canonic** server-side (`idempotency.py`), pentru ca RAR accepta duplicate si nu are nr. comanda. `build_key` normalizeaza INTOTDEAUNA `account_id` la `account_or_default` (None == 1) INAINTE de hash — altfel acelasi rand logic primeste chei diferite pe canalele API vs import (OV-2). `canonicalize_row` normeaza VIN/nr/odometru (strip ".0" din coercion Excel) inainte de validare si de cheie.
- **`FINALIZATA` e terminal la RAR** — fara anulare/corectie prin API. De aceea reconcilierea anti-duplicat: pe eroare tranzitorie sau rand `sending` orfan, worker-ul cauta in finalizate (match pe vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara a re-trimite (`reconcile.py`).
- **`FINALIZATA` e terminal la RAR** — fara anulare/corectie prin API. De aceea reconcilierea anti-duplicat: pe eroare **ambigua** (timeout / TransportError / 502/503/504 / 429 / 408) sau rand `sending` orfan, worker-ul cauta in finalizate (match pe vin+dataPrestatie+odometruFinal) si marcheaza `sent` fara a re-trimite (`reconcile.py`). **EXCEPTIE: un RAR 500 cu mesaj** (`RarError.rar_message`, ex. `ORA-12899`) e un esec DEFINITIV (RAR a raspuns „am esuat", nu o pierdere de raspuns) → worker-ul NU reconciliaza si NU reincearca, marcheaza `error` cu mesajul RAR (`RAR_EROARE_SERVER`). Altfel ar marca fals `sent` pe un record PARTIAL pe care RAR (ne-tranzactional) il lasa la esec.
- **Creds RAR per cont**: durabile in `accounts.rar_creds_enc` (canal web, fallback re-login) SAU efemere in `submissions.rar_creds_enc` (canal API, sterse dupa primul login reusit). Worker incearca submission-ul intai, apoi fallback la cont. Purjarea sterge DOAR `submissions.rar_creds_enc`, NU `accounts.rar_creds_enc`.
- **Auth API-key** (`auth.py`): identifica CONTUL ROAAUTO, separat de credentialele RAR. Stocam doar SHA-256 al cheii. Enforcement prin `AUTOPASS_REQUIRE_API_KEY`: `false` (dev) → fara cheie merge pe cont id=1, cheie invalida → 401; `true` (prod) → cheie obligatorie pe `/v1/*` protejat. POST-urile + rutele de import sunt account-scoped; GET-urile de listare sunt momentan globale + neprotejate (de remediat — vezi ROADMAP).
- **Mapare coloane retinuta per `(account_id, signature_coloane)`** (`column_mappings`): la urmatorul fisier cu aceleasi coloane, pentru acelasi cont, maparea se reaplica automat. Un cont poate avea mai multe formate memorate simultan.
- **Mapare operatie→cod**: prestatie poate veni cu `cod_prestatie` (cod RAR direct) sau `cod_op_service` (cod intern) + `denumire`. Nerezolvat → submission `needs_mapping` (nu se trimite), apare in editorul web cu sugestie fuzzy; la salvarea maparii se re-rezolva automat submission-urile blocate.
- **`cod_prestatie` e VALIDAT fata de nomenclator la ingestie** (`resolve_prestatii(..., valid_codes)`): un cod direct NECUNOSCUT in nomenclator NU se mai trimite raw — e promovat la `cod_op_service` (denumire=cod) si tratat ca operatie de mapat. Motiv (confirmat live 2026-06-23): RAR accepta NUMAI coduri din nomenclator (coloana `COD_PRESTATIE` max 5 car.); un cod necunoscut da **HTTP 500** (`ORA-12899`), iar RAR **NU e tranzactional** → lasa un record PARTIAL `FINALIZATA` (terminal) chiar pe esec, pe care reconcilierea worker-ului l-ar marca fals `sent`. Comportamentul la cod necunoscut/nemapat: `on_unmapped_error` (camp boolean top-level pe `POST /v1/prezentari` + `/valideaza`) = `false` (intra in editor, `needs_mapping`) sau `true` (respinge fara enqueue → `submission_id=null` + `erori`). Default = `accounts.on_unmapped_error_default` (implicit `false`/`0`); precedenta cerere > cont > `false`.
- **WAF RAR da 403 fara User-Agent de browser** — toate apelurile httpx trimit `User-Agent: Mozilla/5.0` (`config.py`, confirmat live).
- **422 fara echo de credentiale**: handler-ul global de validare in `main.py` pastreaza type/loc/msg dar DROP-a `input`/`ctx` (altfel ar reflecta `rar_credentials.password`).
- **Retentie**: `submissions` sent + `import_batches` primesc `purge_after = now + 90 zile`; worker-ul purjeaza odata pe ora (T16, GDPR/L.142).
### Masina de stari submissions
`queued → sending → sent` (succes, cu `id_prezentare` de la RAR). Ramuri: `needs_mapping` (cod nerezolvat), `needs_data` (RAR 400, validare continut), `error` (max retries / 4xx nerecuperabil / creds invalide / login 401 — NU se face retry pe creds gresite). Backoff: `next_attempt_at = now + base*2^retry`, plafonat. Schema completa: `app/schema.sql`.
`queued → sending → sent` (succes, cu `id_prezentare` de la RAR). Ramuri: `needs_mapping` (cod nerezolvat), `needs_data` (RAR 400, validare continut), `error` (max retries / 4xx nerecuperabil / **RAR 500 cu mesaj — esec definitiv** / creds invalide / login 401 — NU se face retry pe creds gresite). Backoff: `next_attempt_at = now + base*2^retry`, plafonat. Schema completa: `app/schema.sql`.
## Mod non-interactiv

View File

@@ -29,6 +29,7 @@ from ...mapping import (
account_scope_clause,
classify_prezentare,
load_mapping_meta,
load_nomenclator_codes,
pending_unmapped,
reresolve_account,
save_mapping,
@@ -53,6 +54,36 @@ from ...submissions_admin import (
router = APIRouter(prefix="/v1", tags=["v1"])
def _effective_on_unmapped_error(conn, acct: int, req_value: bool | None) -> bool:
"""Modul efectiv la cod necunoscut/nemapat (True => respinge cererea, False => needs_mapping).
Precedenta: override per-cerere > default cont (on_unmapped_error_default) > False.
"""
if req_value is not None:
return req_value
row = conn.execute("SELECT on_unmapped_error_default FROM accounts WHERE id=?", (acct,)).fetchone()
return bool(row["on_unmapped_error_default"]) if row else False
def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) -> dict:
"""classify_prezentare + aplicarea modului on_unmapped_error.
Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere
(blocked_error=True): rutele NU mai fac enqueue, ci intorc o eroare per-element.
"""
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes)
cl["blocked_error"] = bool(cl["unmapped"]) and error_mode
return cl
def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
"""Erori 3 niveluri (COD_NEMAPAT) pentru raspunsul on_unmapped_error=True."""
return [
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
for u in unmapped
]
@router.post("/prezentari", response_model=PrezentariResponse)
def create_prezentari(
req: PrezentareRequest,
@@ -82,6 +113,10 @@ def create_prezentari(
# T6/OV-1: load_mapping_meta include auto_send per op (gate pentru coduri noi).
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
# Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat.
# valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot).
valid_codes = load_nomenclator_codes(conn) or None
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
for prez in req.prezentari:
content = prez.model_dump()
# T9/OV-2: canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
@@ -104,7 +139,14 @@ def create_prezentari(
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
if existing["status"] == "error":
cl = classify_prezentare(content, mapping, mapping_meta)
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode)
if cl["blocked_error"]:
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
results.append(SubmissionResult(
submission_id=existing["id"], status="error",
erori=_erori_nemapate(cl["unmapped"]),
))
continue
cur = conn.execute(
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
@@ -143,7 +185,13 @@ def create_prezentari(
# Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea
# (canonicalize + mapare op->cod + validare + auto_send gate).
cl = classify_prezentare(content, mapping, mapping_meta)
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode)
if cl["blocked_error"]:
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
results.append(SubmissionResult(
submission_id=None, status="error", erori=_erori_nemapate(cl["unmapped"]),
))
continue
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
"VALUES (?, ?, ?, ?, ?, ?)",
@@ -191,9 +239,13 @@ def valideaza_prezentari(
try:
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
for i, prez in enumerate(req.prezentari):
content = prez.model_dump()
res = classify_prezentare(content, mapping, mapping_meta)
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode)
if res["blocked_error"]:
res = {**res, "status": "error"}
# US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT
nemapate = [
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")}

View File

@@ -75,6 +75,12 @@ def _migrate(conn: sqlite3.Connection) -> None:
conn.execute(
"UPDATE accounts SET status='pending' WHERE active=0 AND status='active'"
)
if "on_unmapped_error_default" not in acc_cols:
# Comportament la cod necunoscut/nemapat pe canalul API (default non-distructiv = 0).
conn.execute(
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
"CHECK (on_unmapped_error_default IN (0, 1))"
)
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"

View File

@@ -103,6 +103,14 @@ CATALOG: dict[str, dict[str, str]] = {
" detaliile exacte sunt in mesajul tehnic RAR."
),
},
"RAR_EROARE_SERVER": {
"problema": "RAR a esuat la inregistrarea prezentarii",
"fix": (
"RAR a raspuns cu o eroare de server (vezi cauza). Trimiterea NU se"
" reincearca automat si NU a fost confirmata — verifica datele (in special"
" codul prestatiei) si re-trimite dupa corectare."
),
},
"RAR_CREDS_INVALIDE": {
"problema": "Credentiale RAR invalide",
"fix": (

View File

@@ -90,13 +90,23 @@ def suggest_codes(
def resolve_prestatii(
prestatii: list[dict] | None,
mapping: dict[str, str],
valid_codes: set[str] | None = None,
) -> tuple[list[dict], list[dict]]:
"""Rezolva fiecare item: umple `cod_prestatie` din maparea op->cod unde lipseste.
Reguli (hibrid):
- item cu `cod_prestatie` -> pastrat ca atare (cod RAR direct).
- item cu `cod_prestatie` valid (in nomenclator) -> pastrat ca atare.
- item fara cod, cu `cod_op_service` in `mapping` -> umplem cod_prestatie.
- item fara cod si fara mapare -> ramane nemapat.
- item cu `cod_prestatie` NECUNOSCUT in nomenclator -> tratat ca operatie de
mapat: il promovam la `cod_op_service` (daca nu exista deja) ca sa intre in
fluxul needs_mapping. Confirmat live (2026-06-23): RAR accepta NUMAI coduri
din nomenclator (coloana COD_PRESTATIE max 5 car.); un cod necunoscut da
HTTP 500 si RECORD PARTIAL la RAR (terminal) -> nu-l trimitem niciodata raw.
`valid_codes` = setul de coduri RAR valide (uppercase) din nomenclator. Cand e
None, validarea e dezactivata (compat: comportamentul vechi „cod_prestatie trece
neatins"); rutele API il paseaza intotdeauna.
Intoarce (prestatii_rezolvate, nemapate). `prestatii_rezolvate` pastreaza
si campurile originale (cod_op_service/denumire) ca re-rezolvarea sa aiba
@@ -109,9 +119,19 @@ def resolve_prestatii(
it = dict(item)
cod = (it.get("cod_prestatie") or "").strip().upper()
op = (it.get("cod_op_service") or "").strip()
if cod:
cod_valid = bool(cod) and (valid_codes is None or cod in valid_codes)
if cod_valid:
it["cod_prestatie"] = cod
elif op and op in mapping:
else:
# cod lipsa SAU necunoscut in nomenclator -> ruta de mapare.
if cod and not op:
# Promovam codul direct necunoscut la cod_op_service ca sa-l poti mapa
# in editor (cu denumire = codul, pentru sugestia fuzzy) si sa se retina.
op = cod
it["cod_op_service"] = op
if not it.get("denumire"):
it["denumire"] = cod
if op and op in mapping:
it["cod_prestatie"] = mapping[op]
elif op:
it["cod_prestatie"] = None
@@ -192,6 +212,17 @@ def load_nomenclator(conn) -> list[dict]:
return [dict(r) for r in rows]
def load_nomenclator_codes(conn) -> set[str]:
"""Setul de coduri RAR valide (uppercase) pentru validarea cod_prestatie la ingestie.
Intoarce set() daca nomenclatorul e gol -> apelantul trebuie sa NU valideze in
acel caz (altfel ar bloca totul). In practica nomenclatorul e mereu populat:
seed fallback (18 coduri) la boot + upsert live de la worker la fiecare login.
"""
rows = conn.execute("SELECT cod_prestatie FROM nomenclator_rar").fetchall()
return {(r["cod_prestatie"] or "").strip().upper() for r in rows if (r["cod_prestatie"] or "").strip()}
def load_mapping(conn, account_id: int | None) -> dict[str, str]:
"""{cod_op_service -> cod_prestatie} pentru un cont."""
acct = account_or_default(account_id)
@@ -222,6 +253,7 @@ def classify_prezentare(
content: dict,
mapping: dict[str, str],
mapping_meta: dict[str, dict],
valid_codes: set[str] | None = None,
) -> dict:
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
@@ -241,7 +273,7 @@ def classify_prezentare(
"odometru_final": canon["odometru_final"],
})
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping)
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping, valid_codes)
c["prestatii"] = resolved
if unmapped:
@@ -380,6 +412,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
acct = account_or_default(account_id)
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
if batch_id is not None:
# T7: scope la batch-ul specificat (import commit explicit).
@@ -405,7 +438,7 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
content = json.loads(r["payload_json"])
except (ValueError, TypeError):
continue
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes)
content["prestatii"] = resolved
payload_json = json.dumps(content, ensure_ascii=False)

View File

@@ -89,10 +89,16 @@ class PrezentareRequest(BaseModel):
rar_credentials: RarCredentials | None = None
prezentari: list[PrezentareIn] = Field(..., min_length=1)
# Optional: override per-cerere al comportamentului la cod necunoscut/nemapat.
# True -> respinge cererea fara enqueue (status 'error');
# False -> submission 'needs_mapping' (intra in editorul de mapare);
# None -> se foloseste accounts.on_unmapped_error_default (implicit False).
on_unmapped_error: bool | None = None
class SubmissionResult(BaseModel):
submission_id: int
# submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True).
submission_id: int | None = None
status: str
id_prezentare: int | None = None
deduped: bool = False # True daca idempotency a intors un submission existent
@@ -100,6 +106,9 @@ class SubmissionResult(BaseModel):
# cheie de continut a fost RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit.
# `deduped` pastreaza semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
reactivated: bool = False
# Populat cand status='error' din cauza on_unmapped='error': erori 3 niveluri
# (COD_NEMAPAT) pentru fiecare cod necunoscut/nemapat. Gol altfel.
erori: list[dict] = []
class PrezentariResponse(BaseModel):
@@ -111,6 +120,7 @@ class ValidarePrezentariRequest(BaseModel):
rar_credentials: RarCredentials | None = None
prezentari: list[PrezentareIn] = Field(..., min_length=1)
on_unmapped_error: bool | None = None
class ValidareResult(BaseModel):

View File

@@ -19,12 +19,25 @@ from .config import Settings, get_settings
class RarError(Exception):
"""Eroare la apel RAR. `status_code` = HTTP RAR; `field_errors` = lista [{field,message}] la 400."""
"""Eroare la apel RAR. `status_code` = HTTP RAR; `field_errors` = lista [{field,message}] la 400.
def __init__(self, message: str, *, status_code: int | None = None, field_errors: list[dict] | None = None):
`rar_message` = mesajul din envelope-ul de eroare al RAR (`{statusCode, message, data}`),
cand exista. Prezenta lui pe un 5xx inseamna ca RAR A RASPUNS definitiv „am esuat"
(nu o pierdere de raspuns) -> worker-ul il trateaza ca permanent, nu reconciliaza.
"""
def __init__(
self,
message: str,
*,
status_code: int | None = None,
field_errors: list[dict] | None = None,
rar_message: str | None = None,
):
super().__init__(message)
self.status_code = status_code
self.field_errors = field_errors or []
self.rar_message = rar_message
class RarAuthError(RarError):
@@ -105,7 +118,14 @@ class RarClient:
errors = body.get("data") if isinstance(body.get("data"), list) else []
msg = body.get("message", "Validare esuata la RAR")
raise RarError(msg, status_code=400, field_errors=errors)
raise RarError(f"postPrezentare esuat (HTTP {resp.status_code})", status_code=resp.status_code)
# Non-200/non-400: pastram mesajul din envelope-ul RAR daca exista (ex. 500 cu
# `{"statusCode":500,"message":"Eroare la adaugarea prezentarii : ORA-..."}`).
rar_message = body.get("message") if isinstance(body, dict) else None
raise RarError(
f"postPrezentare esuat (HTTP {resp.status_code})",
status_code=resp.status_code,
rar_message=rar_message,
)
def get_finalizate(self, token: str) -> list[dict]:
"""Lista prezentarilor finalizate (pentru reconciliere — T2).

View File

@@ -19,6 +19,11 @@ CREATE TABLE IF NOT EXISTS accounts (
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('pending','active','blocked','archived','deleted')),
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1)
-- Comportament implicit la cod prestatie necunoscut/nemapat pe canalul API:
-- 0 (default, non-distructiv: submission 'needs_mapping', intra in editorul de mapare) sau
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
CHECK (on_unmapped_error_default IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi

View File

@@ -25,6 +25,7 @@ from .mapping import (
account_scope_clause,
classify_prezentare,
load_mapping_meta,
load_nomenclator_codes,
)
from .observ import log_event
@@ -99,7 +100,8 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict:
mapping_meta = load_mapping_meta(conn, account_id)
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
cl = classify_prezentare(content, mapping, mapping_meta)
valid_codes = load_nomenclator_codes(conn) or None
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes)
conn.execute(
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, retry_count=0, "

View File

@@ -249,6 +249,17 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d
nivel="WARNING", account_id=account_id, cod="RAR_VALIDARE",
context={"submission_id": sid})
return "needs_data"
if exc.status_code == 500 and exc.rar_message:
# RAR a raspuns DEFINITIV cu o eroare de procesare (ex. ORA-12899). NU e o
# pierdere de raspuns ambigua -> NU reconcilia (recordul, daca exista la RAR,
# e PARTIAL/rupt si nu trebuie marcat fals 'sent') si NU reincerca (acelasi
# input va esua iar). Marcam 'error' cu mesajul real RAR. (Confirmat live 2026-06-23.)
detail = json.dumps(errors.eroare("RAR_EROARE_SERVER", cauza=exc.rar_message), ensure_ascii=False)
mark(conn, sid, "error", rar_status_code=500, rar_error=detail)
_wlog(conn, "submission_error", f"submission {sid} -> error (RAR 500): {exc.rar_message}",
nivel="ERROR", account_id=account_id, cod="RAR_EROARE_SERVER",
context={"submission_id": sid, "http": 500})
return "error"
if _is_transient(exc):
return _handle_transient(conn, settings, rar, token, sid, content, str(exc))
# 4xx nerecuperabil (nu 400/401/408/429) -> error.

File diff suppressed because one or more lines are too long

View File

@@ -280,8 +280,17 @@ Campul `detail` din raspunsurile de eroare este superset: contine cheile vechi `
| Cod | Problema | Fix |
|---|---|---|
| `RAR_VALIDARE` | RAR a respins prezentarea | Corecteaza campul semnalat de RAR (vezi cauza) si reincearca; detaliile exacte sunt in mesajul tehnic RAR. |
| `RAR_EROARE_SERVER` | RAR a esuat la inregistrarea prezentarii | RAR a raspuns cu o eroare de server (vezi cauza). Trimiterea NU se reincearca automat si NU a fost confirmata — verifica datele (in special codul prestatiei) si re-trimite dupa corectare. |
| `RAR_CREDS_INVALIDE` | Credentiale RAR invalide | Verifica email-ul si parola contului RAR in tab-ul Cont; trimiterea nu se reincearca automat la credentiale gresite. |
> **Clasificarea esecurilor RAR la `postPrezentare` (worker).** Un **400** -> `needs_data`
> (validare continut). Un **500 cu corp de eroare** (`{statusCode,message}`, ex. `ORA-12899`)
> e un esec DEFINITIV: RAR a raspuns „am esuat", deci NU e o pierdere de raspuns ambigua
> -> worker-ul marcheaza `error` (`RAR_EROARE_SERVER`), **fara reconciliere si fara retry**
> (altfel ar marca fals `sent` pe un record PARTIAL pe care RAR, ne-tranzactional, il lasa
> la esec). Doar erorile **ambigue** — timeout / TransportError / 502/503/504 / 429 / 408 —
> declanseaza reconcilierea anti-duplicat + retry cu backoff.
#### Import fisier
| Cod | Problema | Fix |
@@ -393,7 +402,7 @@ Aceasta e suprafata **gateway-ului**, nu RAR. Un item din `prestatii` la
| Camp item | Note |
|---|---|
| `cod_prestatie` | cod RAR direct (ex. `OE-1`). Trece neatins -> validare T3 -> coada. |
| `cod_prestatie` | cod RAR direct (ex. `OE-1`). **Validat fata de nomenclator** -> validare T3 -> coada. Cod NECUNOSCUT in nomenclator e tratat ca operatie de mapat (vezi mai jos). |
| `cod_op_service` | cod intern ROAAUTO. Gateway-ul il traduce in cod RAR prin `operations_mapping`. |
| `denumire` | denumirea operatiei ROAAUTO; folosita pentru fuzzy lookup in editor. |
@@ -404,6 +413,23 @@ web. La salvarea maparii, submission-urile blocate pe acel cod se re-rezolva aut
rezolvat se scrie inapoi in `payload_json`, deci payload builder + worker raman
code-driven.
**Validare `cod_prestatie` la ingestie (2026-06-23).** RAR accepta NUMAI coduri din
nomenclator: coloana `COD_PRESTATIE` are max 5 caractere si un cod necunoscut
intoarce **HTTP 500** (`ORA-12899`) — confirmat live. Periculos: RAR NU e tranzactional
si lasa un **record partial** (`FINALIZATA`, terminal) chiar cand apelul esueaza, iar
reconcilierea worker-ului il poate marca fals `sent`. De aceea gateway-ul NU mai trimite
un `cod_prestatie` care nu e in nomenclator: il promoveaza la `cod_op_service` (cu
`denumire`=cod, pentru fuzzy) si il trateaza ca operatie de mapat.
**Optiunea `on_unmapped_error`** (camp boolean top-level optional pe `POST /v1/prezentari`
si `/v1/prezentari/valideaza`) controleaza ce se intampla la cod necunoscut/nemapat:
- `false` (default) — submission `needs_mapping`, apare in editor (non-distructiv);
- `true` — respinge fara enqueue: `SubmissionResult` cu `status="error"`,
`submission_id=null`, `erori=[COD_NEMAPAT...]`.
Cand campul lipseste se aplica default-ul contului (`accounts.on_unmapped_error_default`,
implicit `false`/`0`). Override per-cerere > default cont > `false`.
Endpointuri noi:
- `GET /v1/mapari/pending` — operatii nemapate distincte + sugestii fuzzy (`{cod_prestatie, nume_prestatie, score}`).
- `POST /v1/mapari` `{account_id?, cod_op_service, cod_prestatie, auto_send}` — upsert mapare + re-rezolvare. Respinge `cod_prestatie` inexistent in nomenclator (422).

17
tests/conftest.py Normal file
View File

@@ -0,0 +1,17 @@
"""Configurare pytest la nivel de suita: izoleaza testele de `.env`-ul de dezvoltare.
In working-dir exista un `.env` pentru probe live (ex. `AUTOPASS_REQUIRE_API_KEY=true`,
`AUTOPASS_WORKER_USE_TEST_CREDS=true`, creds RAR de test) care e citit automat de
pydantic Settings. Fara izolare, acele flag-uri ar regla tacit comportamentul
testelor: 401 pe rutele protejate si creds <test> in loc de fallback-ul pe cont.
Fixam un default sigur pe variabilele de mediu — care au PRECEDENTA peste fisierul
`.env` in pydantic-settings — deci neutralizam doar valorile din fisier, nu si o
variabila exportata explicit in shell. Testele care chiar verifica enforcement-ul
(auth pornit, creds <test>) il seteaza punctual prin `monkeypatch`/`object.__setattr__`.
"""
import os
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")

View File

@@ -23,6 +23,10 @@ def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode())
# Izolare de .env-ul de dezvoltare (creds <test> + cheie API): testele isi
# controleaza explicit aceste flag-uri, altfel fallback-ul pe cont nu se atinge.
monkeypatch.setenv("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
from app.config import get_settings
from app import crypto

View File

@@ -68,6 +68,46 @@ def test_resolve_op_nemapata_iese_in_unmapped():
assert unmapped == [{"cod_op_service": "9999", "denumire": "Operatie noua"}]
def test_resolve_cod_valid_cu_nomenclator_trece():
"""cod_prestatie in nomenclator -> pastrat (validare activa)."""
resolved, unmapped = resolve_prestatii([{"cod_prestatie": "oe-1"}], {}, valid_codes={"OE-1"})
assert resolved[0]["cod_prestatie"] == "OE-1"
assert unmapped == []
def test_resolve_cod_necunoscut_devine_unmapped():
"""cod_prestatie NECUNOSCUT in nomenclator -> promovat la cod_op_service + needs_mapping.
Regresie pentru bug-ul real: un cod intern in cod_prestatie (ex. 'DIVERSE
VERIFICARI 159002') NU trebuie trimis raw la RAR (HTTP 500 + record partial).
"""
resolved, unmapped = resolve_prestatii(
[{"cod_prestatie": "DIVERSE VERIFICARI 159002"}], {}, valid_codes={"OE-1", "R-ODO"}
)
assert resolved[0]["cod_prestatie"] is None
assert resolved[0]["cod_op_service"] == "DIVERSE VERIFICARI 159002" # promovat
assert unmapped == [{"cod_op_service": "DIVERSE VERIFICARI 159002",
"denumire": "DIVERSE VERIFICARI 159002"}]
def test_resolve_cod_necunoscut_cu_mapare_se_rezolva():
"""Dupa ce codul necunoscut a fost mapat, se rezolva la codul RAR (re-rezolvare)."""
resolved, unmapped = resolve_prestatii(
[{"cod_prestatie": "DIVERSE VERIFICARI 159002"}],
{"DIVERSE VERIFICARI 159002": "OE-1"},
valid_codes={"OE-1"},
)
assert resolved[0]["cod_prestatie"] == "OE-1"
assert unmapped == []
def test_resolve_fara_valid_codes_e_backcompat():
"""valid_codes=None -> validarea dezactivata: cod direct trece neatins (compat)."""
resolved, unmapped = resolve_prestatii([{"cod_prestatie": "ORICE-COD"}], {})
assert resolved[0]["cod_prestatie"] == "ORICE-COD"
assert unmapped == []
# --------------------------------------------------------------------------- #
# Flux complet (API) #
# --------------------------------------------------------------------------- #
@@ -163,6 +203,75 @@ def test_item_fara_cod_si_fara_op_e_422(client):
assert r.status_code == 422
# --------------------------------------------------------------------------- #
# Cod_prestatie necunoscut in nomenclator + optiunea on_unmapped #
# (RAR accepta NUMAI coduri din nomenclator; cod necunoscut -> 500 + record #
# partial. Gateway-ul nu-l mai trimite raw.) #
# --------------------------------------------------------------------------- #
_COD_INTERN = "DIVERSE VERIFICARI 159002" # >5 car., nu e in nomenclator
def test_cod_prestatie_necunoscut_da_needs_mapping(client):
"""Default: cod_prestatie necunoscut -> needs_mapping, apare in pending pentru mapare."""
r = client.post("/v1/prezentari", json=_body([{"cod_prestatie": _COD_INTERN}]))
assert r.status_code == 200
assert r.json()["results"][0]["status"] == "needs_mapping"
pend = client.get("/v1/mapari/pending").json()["pending"]
assert len(pend) == 1
assert pend[0]["cod_op_service"] == _COD_INTERN # promovat din cod_prestatie
def test_cod_necunoscut_mapat_se_trimite(client):
"""Flux complet: cod necunoscut -> needs_mapping -> mapezi -> queued."""
client.post("/v1/prezentari", json=_body([{"cod_prestatie": _COD_INTERN}]))
r = client.post("/v1/mapari", json={"cod_op_service": _COD_INTERN, "cod_prestatie": "OE-1", "auto_send": True})
assert r.json()["reresolve"]["requeued"] == 1
subs = client.get("/v1/prezentari", params={"status": "queued"}).json()["submissions"]
assert len(subs) == 1
def test_on_unmapped_error_respinge_fara_enqueue(client):
"""on_unmapped_error=True per-cerere: cod necunoscut -> status error, fara submission."""
body = _body([{"cod_prestatie": _COD_INTERN}])
body["on_unmapped_error"] = True
r = client.post("/v1/prezentari", json=body)
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status"] == "error"
assert res["submission_id"] is None
assert res["erori"] and res["erori"][0]["cod"] == "COD_NEMAPAT"
# Nu s-a creat nimic in coada.
assert client.get("/v1/prezentari").json()["submissions"] == []
def test_on_unmapped_default_cont_error(client):
"""Default per-cont (on_unmapped_error_default=1) se aplica cand cererea nu specifica optiunea."""
from app.db import get_connection
conn = get_connection()
conn.execute("UPDATE accounts SET on_unmapped_error_default=1 WHERE id=1")
conn.commit()
conn.close()
r = client.post("/v1/prezentari", json=_body([{"cod_prestatie": _COD_INTERN}]))
res = r.json()["results"][0]
assert res["status"] == "error" and res["submission_id"] is None
# Override per-cerere bate default-ul de cont:
body = _body([{"cod_prestatie": _COD_INTERN}], vin="WVWZZZ1KZAW000999")
body["on_unmapped_error"] = False
r2 = client.post("/v1/prezentari", json=body)
assert r2.json()["results"][0]["status"] == "needs_mapping"
def test_valideaza_error_mode(client):
"""Dry-run reflecta modul error: status_estimat='error' pentru cod necunoscut."""
body = _body([{"cod_prestatie": _COD_INTERN}])
body["on_unmapped_error"] = True
r = client.post("/v1/prezentari/valideaza", json=body)
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status_estimat"] == "error" and res["valid"] is False
# --------------------------------------------------------------------------- #
# US-003: 3 niveluri in classify_prezentare (needs_mapping) #
# --------------------------------------------------------------------------- #

View File

@@ -21,6 +21,10 @@ def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t1.db"))
monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode())
# Izolare de .env-ul de dezvoltare (creds <test> + cheie API): testele isi
# controleaza explicit aceste flag-uri, altfel fallback-ul pe cont nu se atinge.
monkeypatch.setenv("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
from app.config import get_settings
from app import crypto
get_settings.cache_clear()

View File

@@ -138,6 +138,51 @@ def test_raspuns_pierdut_reconciliere_fara_duplicat(env):
assert rar.finalizate_calls == 1 # a reconciliat
def test_rar_500_definitiv_devine_error_fara_reconciliere(env):
"""RAR 500 cu mesaj (ex. ORA-12899) = esec DEFINITIV: NU reconcilia, NU reincerca.
Bug-ul real: 500 era tratat ca tranzitoriu -> reconciliere -> marca fals 'sent'
pe un record PARTIAL creat de RAR (ne-tranzactional). Aici, chiar daca exista un
record care s-ar potrivi, NU trebuie marcat sent.
"""
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
rar = FakeRar(
finalizate=[{"id": 99999, "vin": "WVWZZZ1KZAW000123",
"dataPrestatie": "2026-06-15", "odometruFinal": 123456}],
post_exc=RarError(
"postPrezentare esuat (HTTP 500)", status_code=500,
rar_message="Eroare la adaugarea prezentarii : ORA-12899: value too large for column COD_PRESTATIE",
),
)
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "error"
row = _status(conn, sid)
assert row["status"] == "error"
assert row["id_prezentare"] is None # NU fals sent
assert rar.post_calls == 1 # nu re-trimite
assert rar.finalizate_calls == 0 # NU reconciliaza
err = json.loads(row["rar_error"])
assert err["cod"] == "RAR_EROARE_SERVER" and "ORA-12899" in err["cauza"]
def test_rar_503_ramane_tranzitoriu(env):
"""Boundary: doar 500-cu-mesaj e permanent; 503 (infra) ramane ambiguu -> reconciliere."""
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
rar = FakeRar(
finalizate=[{"id": 68514, "vin": "WVWZZZ1KZAW000123",
"dataPrestatie": "2026-06-15", "odometruFinal": 123456}],
post_exc=RarError("service unavailable", status_code=503, rar_message="temporar indisponibil"),
)
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "sent"
assert _status(conn, sid)["id_prezentare"] == 68514
assert rar.finalizate_calls == 1
def test_tranzitoriu_neinregistrat_requeue(env):
from app.worker.__main__ import process_one
conn, settings = env