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

@@ -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,15 +119,25 @@ 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:
it["cod_prestatie"] = mapping[op]
elif op:
it["cod_prestatie"] = None
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
# item fara cod si fara op: il lasam asa; validarea de continut prinde
# "prestatii goale"/cod lipsa.
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
unmapped.append({"cod_op_service": op, "denumire": it.get("denumire")})
# item fara cod si fara op: il lasam asa; validarea de continut prinde
# "prestatii goale"/cod lipsa.
resolved.append(it)
return resolved, unmapped
@@ -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.