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:
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": (
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user