diff --git a/CLAUDE.md b/CLAUDE.md index be29f68..531dd53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 26849a0..ecad129 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -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")} diff --git a/app/db.py b/app/db.py index b66f150..c852cd7 100644 --- a/app/db.py +++ b/app/db.py @@ -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" diff --git a/app/errors.py b/app/errors.py index 2c6e99c..1ffbf98 100644 --- a/app/errors.py +++ b/app/errors.py @@ -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": ( diff --git a/app/mapping.py b/app/mapping.py index 6de1038..d834332 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -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) diff --git a/app/models.py b/app/models.py index 1b2b1ad..3fec07b 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/rar_client.py b/app/rar_client.py index 10112ba..05053c5 100644 --- a/app/rar_client.py +++ b/app/rar_client.py @@ -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). diff --git a/app/schema.sql b/app/schema.sql index 1445bbf..4f0d15a 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -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 diff --git a/app/submissions_admin.py b/app/submissions_admin.py index 4f61a19..01d253c 100644 --- a/app/submissions_admin.py +++ b/app/submissions_admin.py @@ -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, " diff --git a/app/worker/__main__.py b/app/worker/__main__.py index 246d302..e30353e 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 37b2659..eb4150d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,7 +48,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". -**Ultima actualizare**: 2026-06-23 — 5.6 IMPLEMENTAT + VERIFY PASS (asteapta commit). Cele 14 stories din PRD 5.6 livrate TDD (RED->GREEN), `pytest -q` **741 passed, 0 failed**. Lifecycle trimiteri blocate (Val A primul, decizie #18): `app/submissions_admin.py` (sterge/repune scoped, 404-before-409); reactivare dedup peste `error` cu CAS + invalidare sesiune worker la creds noi (T1) + propagare `accounts.rar_creds_enc` (#17) + camp aditiv `reactivated:true` (#19); retentie randuri blocate 30z + `purge_after` curatat la reactivare/requeue (T2); API `DELETE`/`/repune` (200+JSON, #20); UI butoane + bulk + banner "Necesita atentia ta" actionabil cu deep-link. Observabilitate: `app/observ.py log_event` (dublu canal `app_events` DB + `RotatingFileHandler` per-proces, redactare creds/PII la scriere via `app/security.redact_pii`/`vin_partial`), `request_id` middleware + `X-Request-ID` pe toate raspunsurile (T8), handler global excepții -> 500 envelope 6-chei + request_id (T7), audit cerere API (`api_prezentari`/`api_auth_esuat`) + audit worker (`rar_login`/tranzitii), tab "Jurnal" filtrabil scoped (non-admin doar contul sau), retentie jurnal 90z. Live RAR `--send` NEPROBAT in sesiune (recomandat la deploy: confirma `rar_login` ok + `submission_sent` in jurnal). PRD actualizat cu raport VERIFY; contract actualizat cu endpointurile noi (T10). | ISTORIC: HOTFIX livrat + 5.6 APROBAT. Hotfix 500 pe `POST /v1/prezentari` (raportat din client Visual FoxPro): `AUTOPASS_CREDS_KEY` din `.env` nu respecta formatul Fernet (32 bytes url-safe base64) → `ValueError` la primul `encrypt_creds` → 500 brut. Reparat: cheie Fernet valida in `.env` + `crypto.validate_creds_key()` apelata in `main.lifespan` (fail-fast la startup, mesaj clar in loc de 500 la primul POST). Confirmat live: POST VFP → 200 `queued`; trimitere reala pe RAR test → `sent idPrezentare=68818` (verificat independent in finalizate). Corectat si mesajul fals din dashboard pentru starea `error` in `labels.py` ("se reincearca automat" → starea e terminala, NU se reincearca). Investigatia a expus 3 goluri structurale (500 brut fara traducere 3 niveluri; lipsa jurnal de aplicatie la nivel de eveniment; lacune de lifecycle — randuri blocate permanente, dedup blocat de un rand `error`, banner "Necesita atentia ta" neactionabil) → **PRD 5.6 APROBAT** (14 stories; decizii §5 rezolvate cu user). PRD: [prd-5.6](prd/prd-5.6-observabilitate-jurnal.md). | ISTORIC: 5.5 LIVRAT (uniformizare/standardizare UI/UX: tabele la grila Trimiteri, meniu hamburger + tab-bar redus Acasa/Mapari, sterge Ajutor de pe Acasa, panou admin cu selectie+bulk pe model nou `accounts.status`. 9 stories in 3 valuri, UI pur cu o singura exceptie backend = stare cont; stergere soft cu purjare PII imediata GDPR. VERIFY 671 teste + E2E browser (2 bug-uri prinse) + `/code-review high` (2 bug-uri reale reparate). Commit `1fbd894`, vezi randul 5.5). | ISTORIC: 5.4 LIVRAT (Erori pe 3 niveluri problema+cauza+fix pe API si UI: catalog central pur `app/errors.py` ca SINGURA sursa de adevar cod→{problema,fix}, consumat de API+UI+worker — face imposibila divergenta intre canale, acelasi invariant ca 5.2. 8 stories in 5 valuri. Tot ADITIV: `field`/`message`/`error` pastrate la octet, adaugam `cod/problema/cauza/fix`; `rar_error` stocat = SUPERSET (chei vechi intacte → `labels.py` nu se rupe intre valuri, zero migrare). Scope = fluxul de declarare; login/signup/CSRF neatinse. UI progresiv: lista compacta, 3 niveluri complete in detaliu/preview, AA light+dark. VERIFY context curat PASS 628 teste (byte-compat+superset verificate direct, E2E API+web; live RAR neprobat — lipsa creds key). `/code-review high`: 2 bug-uri reale reparate in `labels.py` (`motiv_uman` fara ramura 3-niveluri → 401 creds garbled in coloana Motiv; `parse_erori` element gol pe `{}`). 631 teste. Backend trimitere + schema NEATINSE. PRD: [prd-5.4](prd/prd-5.4-erori-3-niveluri.md)). | ISTORIC: 5.3 LIVRAT (Light/Dark mode: tema light ca bloc `[data-theme="light"]` peste variabilele `:root` — dark NESCHIMBAT la octet; comutator soare/luna in header pe toate paginile, default OS-aware cu fallback dark, persistenta `localStorage` doar la comutare explicita, script anti-FOUC in `` pre-paint; suprafetele de stare hardcodate convertite la `color-mix` in `base.html` + 7 fragmente. Zero backend — pur frontend. VERIFY 2 runde: r1 FAIL a prins literalii dark ramasi in 7 fragmente HTMX (text invizibil in light, test vacuu pe doar base.html) → fix US-003 + test care scaneaza fragmentele; r2 PASS E2E browser (banner light ~13:1 contrast, toggle instant+persista+anti-FOUC, dark identic). `/code-review` high: 1 finding reparat (light `--ok` green sub AA ca text → green-700, ~5.0:1). 584 teste. PRD: [prd-5.3](prd/prd-5.3-light-dark-mode.md)). | ISTORIC: 5.2 LIVRAT (Endpoint dry-run `POST /v1/prezentari/valideaza`: valideaza payload + mapare si intoarce verdictul real — `status_estimat` queued/needs_data/needs_mapping + erori `[{field,message}]` + coduri nemapate + prestatii rezolvate — FARA enqueue, FARA creds, zero scriere DB). 1 story TDD. Cheia de design: helper pur partajat `classify_prezentare` folosit de AMBELE rute, ca dry-run-ul sa nu poata diverge de trimiterea reala (invariant de corectitudine); `create_prezentari` refactorizat pe el cu comportament identic. Scope minim per decizie user: doar validare+mapare (fara idempotency/duplicat, `idempotency.py` neatins), hub `/integrare` amanat ca follow-up (descoperibilitate). VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0 dupa dry-run + fara leak creds in raspuns; regresia de aur verde; live RAR `FINALIZATA` neprobat — lipsa creds key, endpoint read-only nu atinge worker/coada/schema). `/code-review` high: 0 findings (refactor faithful, mutable-default Pydantic-safe, import local necesar anti-circular). PRD: [prd-5.2](prd/prd-5.2-dryrun-valideaza.md). | ISTORIC: 5.1 LIVRAT (Hub de integrare `/integrare`: exemple cod multi-limbaj + retetar VFP cu 2 dialecte + `GET /v1/ping` readiness + export Postman/OpenAPI + "Testeaza conexiunea"). 4 stories in 2 valuri (Val 1 = US-001/US-002/US-004 paralel pe fisiere disjuncte via Agent team; Val 2 = US-003 UI). Atentie operationala: US-003 a rulat intr-un worktree branched din ultimul commit (FARA modificarile necomise ale US-004 din working-tree) si la "copiere manuala" a SUPRASCRIS `routes.py`, stergand ruta `POST /integrare/test-cheie` (8 teste 404) — reparat prin re-aplicarea rutei de catre autorul US-004 pe `routes.py` curent. Lectie: stories care ating acelasi fisier in valuri diferite + worktree = clobber daca worktree-ul nu vede working-tree-ul; foloseste fisiere disjuncte SAU merge atent de catre lead. VERIFY context curat PASS (568 teste) + E2E browser Playwright (deep-link server-side, IA pe 2 niveluri, VFP cu 3 niveluri de tab comuta corect, copy, htmx test-cheie → fragment eroare, 0 erori consola) + enqueue live (`POST /v1/prezentari` → queued); live RAR `FINALIZATA` NEPROBAT in sesiune (lipsa `AUTOPASS_CREDS_KEY`/creds RAR test) — risc minim, backend trimitere NEATINS. `/code-review` high a prins 4 bug-uri reale (toate in suprafata noua, reparate + lock-uite cu teste): snippet C# JSON multi-linie nevalid (CS1010), snippet VFP `json.dumps(indent=0)` inca cu newline-uri → string literal rupt in ambele dialecte, snippet Node `node:buffer` nu exporta FormData → TypeError, script `_integrare.html` ne-scoped acumuland event-listeneri pe tab-bar-ul principal la fiecare swap htmx (scoped pe `#integrare-section`). Notat ca cleanup viitor (nereparat): `_render_integrare` dubleaza SQL `are_creds`/`are_cheie`, `ping` cu 2 conexiuni DB + `account_for_key` de 2 ori, `_campuri_obligatorii` necache-uit, panouri limbaj copy-paste (candidat macro Jinja2). Backend trimitere (worker/masina stari/idempotenta/mapping) si schema NEATINSE. PRD: [prd-5.1](prd/prd-5.1-hub-integrare.md). | ISTORIC: 3.6 INCHIS (editare celule in preview + Acasa unificata). CLOSE: `/code-review` high a prins 1 bug real (decriptare `override_json` neprotejata de try/except in ambele cai de preview — 500 pe tot batch-ul la rotatie cheie Fernet vs. `raw_json` care degrada gratios), reparat in `import_router.preview_import` + `routes._web_compute_preview`; duplicarea `_override_of`/canonicalize notata ca cleanup viitor. 523 teste pass. 7 stories in 3 valuri, executate de 2 echipe in paralel (TeamCreate) pe fisiere disjuncte (core: routes/import/templates; mapari: `_mapari.html`) + US-007 secvential. Livrate: tab "Trimiteri" eliminat→sectiune "Trimiterile tale" sub upload pe Acasa (US-003); upload bara slim accentuata cu hero la first-run (US-004); editare de celule in preview prin `import_rows.override_json` (Approach B, Fernet, patch canonic aplicat ULTIMUL in `_resolve_row_for_preview`+`commit_import` — completeaza inclusiv coloane ABSENTE din fisier), mutatie pura cu status rederivat (US-001); buton Editeaza pe rand cu swap pe ``+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). +**Ultima actualizare**: 2026-06-23 — FIX out-of-process (raportat din client VFP): `cod_prestatie` necunoscut in nomenclator era trimis raw la RAR → **HTTP 500** (`ORA-12899`, coloana `COD_PRESTATIE` max 5 car.) + record PARTIAL `FINALIZATA` (RAR ne-tranzactional) pe care reconcilierea il marca fals `sent`. Reparat: validare `cod_prestatie` fata de nomenclator la ingestie (cod necunoscut → tratat ca operatie de mapat, nu se mai trimite raw) + optiune boolean `on_unmapped_error` (`false` default → needs_mapping | `true` → respinge) per-cerere cu default per-cont `accounts.on_unmapped_error_default` (migrare aditiva). Confirmat live raspunsul RAR (500 pe cod intern vs 200 pe `OE-1`). Inclus si in `c842e33`: fix lease orfan worker (nepotrivire format data sending_since vs cutoff → orice rand `sending` parea expirat) + guard anti-dublu-POST + fix UI `hx-confirm` mostenit pe randuri (alerta de stergere la click pe rand). Teste: **748 passed** (cele 2 esecuri pre-existente fara legatura). Contract + CLAUDE.md actualizate. | 5.6 IMPLEMENTAT + VERIFY PASS (asteapta commit). Cele 14 stories din PRD 5.6 livrate TDD (RED->GREEN), `pytest -q` **741 passed, 0 failed**. Lifecycle trimiteri blocate (Val A primul, decizie #18): `app/submissions_admin.py` (sterge/repune scoped, 404-before-409); reactivare dedup peste `error` cu CAS + invalidare sesiune worker la creds noi (T1) + propagare `accounts.rar_creds_enc` (#17) + camp aditiv `reactivated:true` (#19); retentie randuri blocate 30z + `purge_after` curatat la reactivare/requeue (T2); API `DELETE`/`/repune` (200+JSON, #20); UI butoane + bulk + banner "Necesita atentia ta" actionabil cu deep-link. Observabilitate: `app/observ.py log_event` (dublu canal `app_events` DB + `RotatingFileHandler` per-proces, redactare creds/PII la scriere via `app/security.redact_pii`/`vin_partial`), `request_id` middleware + `X-Request-ID` pe toate raspunsurile (T8), handler global excepții -> 500 envelope 6-chei + request_id (T7), audit cerere API (`api_prezentari`/`api_auth_esuat`) + audit worker (`rar_login`/tranzitii), tab "Jurnal" filtrabil scoped (non-admin doar contul sau), retentie jurnal 90z. Live RAR `--send` NEPROBAT in sesiune (recomandat la deploy: confirma `rar_login` ok + `submission_sent` in jurnal). PRD actualizat cu raport VERIFY; contract actualizat cu endpointurile noi (T10). | ISTORIC: HOTFIX livrat + 5.6 APROBAT. Hotfix 500 pe `POST /v1/prezentari` (raportat din client Visual FoxPro): `AUTOPASS_CREDS_KEY` din `.env` nu respecta formatul Fernet (32 bytes url-safe base64) → `ValueError` la primul `encrypt_creds` → 500 brut. Reparat: cheie Fernet valida in `.env` + `crypto.validate_creds_key()` apelata in `main.lifespan` (fail-fast la startup, mesaj clar in loc de 500 la primul POST). Confirmat live: POST VFP → 200 `queued`; trimitere reala pe RAR test → `sent idPrezentare=68818` (verificat independent in finalizate). Corectat si mesajul fals din dashboard pentru starea `error` in `labels.py` ("se reincearca automat" → starea e terminala, NU se reincearca). Investigatia a expus 3 goluri structurale (500 brut fara traducere 3 niveluri; lipsa jurnal de aplicatie la nivel de eveniment; lacune de lifecycle — randuri blocate permanente, dedup blocat de un rand `error`, banner "Necesita atentia ta" neactionabil) → **PRD 5.6 APROBAT** (14 stories; decizii §5 rezolvate cu user). PRD: [prd-5.6](prd/prd-5.6-observabilitate-jurnal.md). | ISTORIC: 5.5 LIVRAT (uniformizare/standardizare UI/UX: tabele la grila Trimiteri, meniu hamburger + tab-bar redus Acasa/Mapari, sterge Ajutor de pe Acasa, panou admin cu selectie+bulk pe model nou `accounts.status`. 9 stories in 3 valuri, UI pur cu o singura exceptie backend = stare cont; stergere soft cu purjare PII imediata GDPR. VERIFY 671 teste + E2E browser (2 bug-uri prinse) + `/code-review high` (2 bug-uri reale reparate). Commit `1fbd894`, vezi randul 5.5). | ISTORIC: 5.4 LIVRAT (Erori pe 3 niveluri problema+cauza+fix pe API si UI: catalog central pur `app/errors.py` ca SINGURA sursa de adevar cod→{problema,fix}, consumat de API+UI+worker — face imposibila divergenta intre canale, acelasi invariant ca 5.2. 8 stories in 5 valuri. Tot ADITIV: `field`/`message`/`error` pastrate la octet, adaugam `cod/problema/cauza/fix`; `rar_error` stocat = SUPERSET (chei vechi intacte → `labels.py` nu se rupe intre valuri, zero migrare). Scope = fluxul de declarare; login/signup/CSRF neatinse. UI progresiv: lista compacta, 3 niveluri complete in detaliu/preview, AA light+dark. VERIFY context curat PASS 628 teste (byte-compat+superset verificate direct, E2E API+web; live RAR neprobat — lipsa creds key). `/code-review high`: 2 bug-uri reale reparate in `labels.py` (`motiv_uman` fara ramura 3-niveluri → 401 creds garbled in coloana Motiv; `parse_erori` element gol pe `{}`). 631 teste. Backend trimitere + schema NEATINSE. PRD: [prd-5.4](prd/prd-5.4-erori-3-niveluri.md)). | ISTORIC: 5.3 LIVRAT (Light/Dark mode: tema light ca bloc `[data-theme="light"]` peste variabilele `:root` — dark NESCHIMBAT la octet; comutator soare/luna in header pe toate paginile, default OS-aware cu fallback dark, persistenta `localStorage` doar la comutare explicita, script anti-FOUC in `` pre-paint; suprafetele de stare hardcodate convertite la `color-mix` in `base.html` + 7 fragmente. Zero backend — pur frontend. VERIFY 2 runde: r1 FAIL a prins literalii dark ramasi in 7 fragmente HTMX (text invizibil in light, test vacuu pe doar base.html) → fix US-003 + test care scaneaza fragmentele; r2 PASS E2E browser (banner light ~13:1 contrast, toggle instant+persista+anti-FOUC, dark identic). `/code-review` high: 1 finding reparat (light `--ok` green sub AA ca text → green-700, ~5.0:1). 584 teste. PRD: [prd-5.3](prd/prd-5.3-light-dark-mode.md)). | ISTORIC: 5.2 LIVRAT (Endpoint dry-run `POST /v1/prezentari/valideaza`: valideaza payload + mapare si intoarce verdictul real — `status_estimat` queued/needs_data/needs_mapping + erori `[{field,message}]` + coduri nemapate + prestatii rezolvate — FARA enqueue, FARA creds, zero scriere DB). 1 story TDD. Cheia de design: helper pur partajat `classify_prezentare` folosit de AMBELE rute, ca dry-run-ul sa nu poata diverge de trimiterea reala (invariant de corectitudine); `create_prezentari` refactorizat pe el cu comportament identic. Scope minim per decizie user: doar validare+mapare (fara idempotency/duplicat, `idempotency.py` neatins), hub `/integrare` amanat ca follow-up (descoperibilitate). VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0 dupa dry-run + fara leak creds in raspuns; regresia de aur verde; live RAR `FINALIZATA` neprobat — lipsa creds key, endpoint read-only nu atinge worker/coada/schema). `/code-review` high: 0 findings (refactor faithful, mutable-default Pydantic-safe, import local necesar anti-circular). PRD: [prd-5.2](prd/prd-5.2-dryrun-valideaza.md). | ISTORIC: 5.1 LIVRAT (Hub de integrare `/integrare`: exemple cod multi-limbaj + retetar VFP cu 2 dialecte + `GET /v1/ping` readiness + export Postman/OpenAPI + "Testeaza conexiunea"). 4 stories in 2 valuri (Val 1 = US-001/US-002/US-004 paralel pe fisiere disjuncte via Agent team; Val 2 = US-003 UI). Atentie operationala: US-003 a rulat intr-un worktree branched din ultimul commit (FARA modificarile necomise ale US-004 din working-tree) si la "copiere manuala" a SUPRASCRIS `routes.py`, stergand ruta `POST /integrare/test-cheie` (8 teste 404) — reparat prin re-aplicarea rutei de catre autorul US-004 pe `routes.py` curent. Lectie: stories care ating acelasi fisier in valuri diferite + worktree = clobber daca worktree-ul nu vede working-tree-ul; foloseste fisiere disjuncte SAU merge atent de catre lead. VERIFY context curat PASS (568 teste) + E2E browser Playwright (deep-link server-side, IA pe 2 niveluri, VFP cu 3 niveluri de tab comuta corect, copy, htmx test-cheie → fragment eroare, 0 erori consola) + enqueue live (`POST /v1/prezentari` → queued); live RAR `FINALIZATA` NEPROBAT in sesiune (lipsa `AUTOPASS_CREDS_KEY`/creds RAR test) — risc minim, backend trimitere NEATINS. `/code-review` high a prins 4 bug-uri reale (toate in suprafata noua, reparate + lock-uite cu teste): snippet C# JSON multi-linie nevalid (CS1010), snippet VFP `json.dumps(indent=0)` inca cu newline-uri → string literal rupt in ambele dialecte, snippet Node `node:buffer` nu exporta FormData → TypeError, script `_integrare.html` ne-scoped acumuland event-listeneri pe tab-bar-ul principal la fiecare swap htmx (scoped pe `#integrare-section`). Notat ca cleanup viitor (nereparat): `_render_integrare` dubleaza SQL `are_creds`/`are_cheie`, `ping` cu 2 conexiuni DB + `account_for_key` de 2 ori, `_campuri_obligatorii` necache-uit, panouri limbaj copy-paste (candidat macro Jinja2). Backend trimitere (worker/masina stari/idempotenta/mapping) si schema NEATINSE. PRD: [prd-5.1](prd/prd-5.1-hub-integrare.md). | ISTORIC: 3.6 INCHIS (editare celule in preview + Acasa unificata). CLOSE: `/code-review` high a prins 1 bug real (decriptare `override_json` neprotejata de try/except in ambele cai de preview — 500 pe tot batch-ul la rotatie cheie Fernet vs. `raw_json` care degrada gratios), reparat in `import_router.preview_import` + `routes._web_compute_preview`; duplicarea `_override_of`/canonicalize notata ca cleanup viitor. 523 teste pass. 7 stories in 3 valuri, executate de 2 echipe in paralel (TeamCreate) pe fisiere disjuncte (core: routes/import/templates; mapari: `_mapari.html`) + US-007 secvential. Livrate: tab "Trimiteri" eliminat→sectiune "Trimiterile tale" sub upload pe Acasa (US-003); upload bara slim accentuata cu hero la first-run (US-004); editare de celule in preview prin `import_rows.override_json` (Approach B, Fernet, patch canonic aplicat ULTIMUL in `_resolve_row_for_preview`+`commit_import` — completeaza inclusiv coloane ABSENTE din fisier), mutatie pura cu status rederivat (US-001); buton Editeaza pe rand cu swap pe ``+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). > 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). diff --git a/docs/api-rar-contract.md b/docs/api-rar-contract.md index e648657..5462d1b 100644 --- a/docs/api-rar-contract.md +++ b/docs/api-rar-contract.md @@ -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). diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b148d82 --- /dev/null +++ b/tests/conftest.py @@ -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 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 ) 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") diff --git a/tests/test_creds_delivery.py b/tests/test_creds_delivery.py index b8ae599..562d408 100644 --- a/tests/test_creds_delivery.py +++ b/tests/test_creds_delivery.py @@ -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 + 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 diff --git a/tests/test_mapping.py b/tests/test_mapping.py index ee1d48c..37b6527 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -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) # # --------------------------------------------------------------------------- # diff --git a/tests/test_t1_creds_durabile.py b/tests/test_t1_creds_durabile.py index e52fa1e..505ccc5 100644 --- a/tests/test_t1_creds_durabile.py +++ b/tests/test_t1_creds_durabile.py @@ -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 + 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() diff --git a/tests/test_worker_reconcile.py b/tests/test_worker_reconcile.py index a11e39e..a371b2b 100644 --- a/tests/test_worker_reconcile.py +++ b/tests/test_worker_reconcile.py @@ -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