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 `