From 14e1c463f0aaa443f284aa2888ffdafd0d1eed89 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 23 Jun 2026 10:28:09 +0000 Subject: [PATCH] feat(errors): erori pe 3 niveluri (problema+cauza+fix) pe API si UI (PRD 5.4) Catalog central pur app/errors.py ca sursa unica cod->{problema,fix}, consumat de API+UI+worker. Aditiv (field/message pastrate la octet) + rar_error stocat superset. Scope: fluxul de declarare; login/signup/CSRF neatinse. labels.parse_erori degradeaza gratios; UI progresiv AA light+dark. 631 teste. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/import_router.py | 30 +- app/api/v1/router.py | 8 +- app/errors.py | 203 +++++++++++ app/mapping.py | 10 +- app/validation.py | 77 +++-- app/web/labels.py | 98 ++++++ app/web/routes.py | 71 +++- app/web/templates/_eroare.html | 36 ++ app/web/templates/_mapcoloane.html | 7 +- app/web/templates/_preview_rand.html | 24 +- app/web/templates/_trimitere_detaliu.html | 7 +- app/web/templates/_upload.html | 7 +- app/web/templates/base.html | 13 + app/worker/__main__.py | 13 +- docs/ROADMAP.md | 4 +- docs/api-rar-contract.md | 121 +++++++ docs/prd/prd-5.4-erori-3-niveluri.md | 395 ++++++++++++++++++++++ tests/test_errors.py | 77 +++++ tests/test_import_errors.py | 335 ++++++++++++++++++ tests/test_mapping.py | 86 +++++ tests/test_validare_dryrun.py | 41 +++ tests/test_validation.py | 111 ++++++ tests/test_web_erori.py | 285 ++++++++++++++++ tests/test_web_import_erori.py | 180 ++++++++++ tests/test_worker_rar_errors.py | 245 ++++++++++++++ 25 files changed, 2440 insertions(+), 44 deletions(-) create mode 100644 app/errors.py create mode 100644 app/web/templates/_eroare.html create mode 100644 docs/prd/prd-5.4-erori-3-niveluri.md create mode 100644 tests/test_errors.py create mode 100644 tests/test_import_errors.py create mode 100644 tests/test_web_erori.py create mode 100644 tests/test_web_import_erori.py create mode 100644 tests/test_worker_rar_errors.py diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index ff1a02e..1244450 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -30,6 +30,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field +from ... import errors from ...auth import resolve_account_id from ...crypto import decrypt_creds, encrypt_creds from ...db import get_connection @@ -326,7 +327,15 @@ def apply_row_override( dec = decrypt_creds(row["oj"]) if dec is None: # Decrypt fail (cheie schimbata / token corupt): no-op defensiv, NICIODATA scriere goala. - raise HTTPException(status_code=422, detail="override curent ilizibil; editare anulata") + _oi_msg = "override curent ilizibil; editare anulata" + raise HTTPException( + status_code=422, + detail={ + "error": "override_ilizibil", + "message": _oi_msg, + **errors.eroare("IMPORT_OVERRIDE_ILIZIBIL", cauza=_oi_msg), + }, + ) current = dec new_override = _merge_override(current, fields) @@ -406,6 +415,7 @@ async def upload_import( "error": "multiple_sheets", "message": str(ms), "sheets": ms.sheet_names, + **errors.eroare("IMPORT_MULTIPLE_SHEETS", cauza=str(ms)), }, ) except FileTooLarge as e: @@ -414,6 +424,7 @@ async def upload_import( detail={ "error": "file_too_large", "message": str(e), + **errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e)), }, ) except HeaderError as e: @@ -423,23 +434,28 @@ async def upload_import( "error": "header_error", "message": str(e), "found": e.found, + **errors.eroare("IMPORT_ANTET_NECLAR", cauza=str(e)), }, ) except UnicodeDecodeError as e: + _enc_msg = f"Encoding nesuportat: {e.reason}" raise HTTPException( status_code=422, detail={ "error": "encoding_error", - "message": f"Encoding nesuportat: {e.reason}", + "message": _enc_msg, + **errors.eroare("IMPORT_ENCODING", cauza=_enc_msg), }, ) except Exception as e: # Fisier corupt (BadZipFile, InvalidFileException, etc.) + _inv_msg = f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}" raise HTTPException( status_code=422, detail={ "error": "invalid_file", - "message": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", + "message": _inv_msg, + **errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=_inv_msg), }, ) @@ -713,11 +729,13 @@ def preview_import( ).fetchone() if not mapping_row: + _ncm_msg = "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea." raise HTTPException( status_code=422, detail={ "error": "no_column_mapping", - "message": "Maparea coloanelor nu a fost configurata. Configureaza mai intai maparea.", + "message": _ncm_msg, + **errors.eroare("IMPORT_FARA_MAPARE_COLOANE", cauza=_ncm_msg), }, ) @@ -971,12 +989,14 @@ def commit_import( # Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok n_total_ok = len(ok_rows) if req.n_confirmat != n_total_ok: + _cg_msg = f"Ai confirmat {req.n_confirmat} dar sunt {n_total_ok} randuri gata de trimis. Verifica preview-ul." raise HTTPException( status_code=422, detail={ "error": "confirmare_gresita", - "message": f"Ai confirmat {req.n_confirmat} dar sunt {n_total_ok} randuri gata de trimis. Verifica preview-ul.", + "message": _cg_msg, "n_ok": n_total_ok, + **errors.eroare("IMPORT_CONFIRMARE_GRESITA", cauza=_cg_msg), }, ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 9a51b5e..46a581d 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -22,6 +22,7 @@ from pydantic import BaseModel, Field from ...auth import resolve_account_id from ...crypto import encrypt_creds from ...db import get_connection +from ...errors import eroare as err_eroare from ...idempotency import build_key, canonicalize_row from ...mapping import ( account_or_default, @@ -133,12 +134,17 @@ def valideaza_prezentari( for i, prez in enumerate(req.prezentari): content = prez.model_dump() res = classify_prezentare(content, mapping, mapping_meta) + # 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")} + for u in res["unmapped"] + ] results.append(ValidareResult( index=i, valid=(res["status"] == "queued"), status_estimat=res["status"], erori=res["errors"], - nemapate=res["unmapped"], + nemapate=nemapate, prestatii_rezolvate=res["resolved"], )) finally: diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..587e9e6 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,203 @@ +"""Catalog central de erori AutoPass (PRD 5.4). + +Singura sursa de adevar care mapeaza fiecare cod de eroare la (problema, fix), +cu un helper care construieste obiectul de eroare pe 3 niveluri: + - nivel 1 (tehnic): `cod` + `cauza` — ce s-a intamplat exact + - nivel 2 (utilizator): `problema` — descriere scurta, inteligibila + - nivel 3 (actiune): `fix` — ce trebuie facut pentru a remedia + +Modul PUR — fara import DB sau HTTP. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# CATALOG +# cheie = cod (string), valoare = {"problema": str, "fix": str} +# --------------------------------------------------------------------------- + +CATALOG: dict[str, dict[str, str]] = { + "VIN_FORMAT": { + "problema": "VIN invalid", + "fix": ( + "Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere" + " majuscule, fara spatii si fara literele O, I, Q." + ), + }, + "NR_INMATRICULARE_FORMAT": { + "problema": "Numar de inmatriculare invalid", + "fix": ( + "Foloseste doar litere si cifre majuscule, maxim 10 caractere, fara spatii" + " sau cratima (ex. B123ABC)." + ), + }, + "DATA_FORMAT": { + "problema": "Data prestatiei in format gresit", + "fix": "Scrie data ca AAAA-LL-ZZ (ex. 2026-06-22).", + }, + "DATA_PREA_VECHE": { + "problema": "Data prestatiei prea veche", + "fix": ( + "RAR accepta prestatii doar incepand cu 01.12.2024;" + " verifica data prestatiei." + ), + }, + "DATA_VIITOR": { + "problema": "Data prestatiei in viitor", + "fix": "Data prestatiei nu poate fi dupa ziua de azi; corecteaza data.", + }, + "ODOMETRU_FINAL_FORMAT": { + "problema": "Odometru final invalid", + "fix": ( + "Scrie kilometrajul final ca numar intreg, fara zecimale sau text" + " (ex. 145000)." + ), + }, + "ODOMETRU_INITIAL_LIPSA": { + "problema": "Lipseste odometrul initial", + "fix": ( + "Prestatiile R-ODO / I-ODO cer kilometrajul initial; completeaza-l." + ), + }, + "ODOMETRU_INITIAL_FORMAT": { + "problema": "Odometru initial invalid", + "fix": ( + "Scrie kilometrajul initial ca numar intreg, fara zecimale sau text." + ), + }, + "ODOMETRU_INITIAL_ORDINE": { + "problema": "Odometru initial mai mare decat finalul", + "fix": ( + "Kilometrajul initial trebuie sa fie mai mic sau egal cu cel final;" + " verifica cele doua valori." + ), + }, + "PRESTATII_GOALE": { + "problema": "Nicio prestatie", + "fix": "Adauga cel putin o prestatie cu cod RAR valid.", + }, + "B64_INVALID": { + "problema": "Imaginea nu este base64 valid", + "fix": ( + "Trimite imaginea codata base64 corect, sau omite campul daca nu ai imagine." + ), + }, + "COD_NEMAPAT": { + "problema": "Lipseste codul RAR al operatiei", + "fix": ( + "Alege codul RAR pentru aceasta operatie in tab-ul Mapari" + " (ai sugestii automate)." + ), + }, + "AUTO_SEND_OPRIT": { + "problema": "Necesita confirmare manuala", + "fix": ( + "Codul e mapat cu trimitere automata oprita; verifica randul si" + " pune-l manual in coada." + ), + }, + "RAR_VALIDARE": { + "problema": "RAR a respins prezentarea", + "fix": ( + "Corecteaza campul semnalat de RAR (vezi cauza) si reincearca;" + " detaliile exacte sunt in mesajul tehnic RAR." + ), + }, + "RAR_CREDS_INVALIDE": { + "problema": "Credentiale RAR invalide", + "fix": ( + "Verifica email-ul si parola contului RAR in tab-ul Cont;" + " trimiterea nu se reincearca automat la credentiale gresite." + ), + }, + "IMPORT_FISIER_PREA_MARE": { + "problema": "Fisier prea mare", + "fix": ( + "Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand." + ), + }, + "IMPORT_ANTET_NECLAR": { + "problema": "Antet de coloane neclar", + "fix": ( + "Asigura-te ca primul rand contine numele coloanelor" + " (ex. VIN, Numar, Data)." + ), + }, + "IMPORT_ENCODING": { + "problema": "Codare de caractere nesuportata", + "fix": "Salveaza fisierul ca CSV UTF-8 (sau xlsx) si reincarca.", + }, + "IMPORT_FISIER_NERECUNOSCUT": { + "problema": "Fisier nerecunoscut", + "fix": "Incarca un fisier .xlsx sau .csv valid.", + }, + "IMPORT_MULTIPLE_SHEETS": { + "problema": "Mai multe foi in fisier", + "fix": "Pastreaza datele intr-o singura foaie sau alege foaia de import.", + }, + "IMPORT_FARA_MAPARE_COLOANE": { + "problema": "Coloanele nu sunt mapate", + "fix": ( + "Mapeaza intai coloanele fisierului la campurile cerute, apoi continua." + ), + }, + "IMPORT_CONFIRMARE_GRESITA": { + "problema": "Numar confirmat gresit", + "fix": ( + "Numarul confirmat difera de randurile gata de trimis;" + " verifica preview-ul si reconfirma." + ), + }, + "IMPORT_OVERRIDE_ILIZIBIL": { + "problema": "Editarea anterioara nu se poate citi", + "fix": ( + "Editarea salvata este ilizibila (probabil cheia s-a schimbat);" + " reediteaza randul." + ), + }, + "COLOANE_FORMAT_JSON": { + "problema": "Format de coloane (JSON) invalid", + "fix": ( + "Verifica sintaxa JSON a maparii de coloane" + " (ghilimele duble, acolade inchise corect)." + ), + }, +} + + +# --------------------------------------------------------------------------- +# eroare() +# --------------------------------------------------------------------------- + +def eroare( + cod: str, + *, + field: str | None = None, + cauza: str | None = None, +) -> dict: + """Construieste un obiect de eroare pe 3 niveluri din CATALOG. + + Parametri + --------- + cod: Codul de eroare (cheie in CATALOG). Ridica KeyError daca absent. + field: Campul care a generat eroarea (optional, pentru context). + cauza: Descrierea tehnica a erorii concrete (optional). + Daca lipseste, `cauza` si `message` preiau valoarea `problema` din catalog. + + Returneaza + ---------- + dict cu exact cheile: field, cod, problema, cauza, fix, message. + """ + entry = CATALOG[cod] # ridica KeyError daca cod absent + problema = entry["problema"] + fix = entry["fix"] + cauza_efectiva = cauza if cauza is not None else problema + message = cauza if cauza is not None else problema + return { + "field": field, + "cod": cod, + "problema": problema, + "cauza": cauza_efectiva, + "fix": fix, + "message": message, + } diff --git a/app/mapping.py b/app/mapping.py index 2e8eb30..6de1038 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -20,6 +20,7 @@ from typing import Any from rapidfuzz import fuzz, process +from . import errors as err_mod from .nomenclator_seed import FALLBACK_NOMENCLATOR from .validation import validate_prezentare @@ -245,7 +246,11 @@ def classify_prezentare( if unmapped: status = "needs_mapping" - rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False) + coduri = ", ".join((u.get("cod_op_service") or "") for u in unmapped) + rar_error = json.dumps( + {"unmapped": unmapped, **err_mod.eroare("COD_NEMAPAT", cauza=f"Coduri fara mapare RAR: {coduri}")}, + ensure_ascii=False, + ) errors: list[dict] = [] else: errors = validate_prezentare(c) @@ -254,8 +259,9 @@ def classify_prezentare( rar_error = json.dumps(errors, ensure_ascii=False) elif has_no_auto_send(resolved, mapping_meta): status = "needs_mapping" + mesaj = "cod mapat cu auto_send=0; review manual inainte de trimitere" rar_error = json.dumps( - {"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"}, + {"auto_send": mesaj, **err_mod.eroare("AUTO_SEND_OPRIT", cauza=mesaj)}, ensure_ascii=False, ) else: diff --git a/app/validation.py b/app/validation.py index 83da623..ff66aba 100644 --- a/app/validation.py +++ b/app/validation.py @@ -17,6 +17,8 @@ import re from datetime import date from zoneinfo import ZoneInfo +from app.errors import eroare as _eroare + # VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract). VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") # Numar inmatriculare: max 10, litere + cifre majuscule. @@ -64,36 +66,54 @@ def validate_prezentare(content: dict) -> list[dict]: # --- VIN --- vin = _norm(content.get("vin")) if not VIN_RE.match(vin): - errors.append({ - "field": "vin", - "message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.", - }) + errors.append(_eroare( + "VIN_FORMAT", + field="vin", + cauza="VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.", + )) # --- nrInmatriculare --- nrinm = _norm(content.get("nr_inmatriculare")) if not NRINM_RE.match(nrinm): - errors.append({ - "field": "nr_inmatriculare", - "message": "Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.", - }) + errors.append(_eroare( + "NR_INMATRICULARE_FORMAT", + field="nr_inmatriculare", + cauza="Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.", + )) # --- dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti --- raw_data = str(content.get("data_prestatie") or "").strip() try: d = date.fromisoformat(raw_data) except ValueError: - errors.append({"field": "data_prestatie", "message": "Format data invalid; foloseste YYYY-MM-DD."}) + errors.append(_eroare( + "DATA_FORMAT", + field="data_prestatie", + cauza="Format data invalid; foloseste YYYY-MM-DD.", + )) d = None if d is not None: if d < MIN_DATA_PRESTATIE: - errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024."}) + errors.append(_eroare( + "DATA_PREA_VECHE", + field="data_prestatie", + cauza="Data prestatiei nu poate fi anterioara datei de 01.12.2024.", + )) elif d > today_bucuresti(): - errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi in viitor."}) + errors.append(_eroare( + "DATA_VIITOR", + field="data_prestatie", + cauza="Data prestatiei nu poate fi in viitor.", + )) # --- odometruFinal (string numeric) --- odo_final = _parse_int(content.get("odometru_final")) if odo_final is None: - errors.append({"field": "odometru_final", "message": "odometruFinal trebuie sa fie un numar intreg (ca string)."}) + errors.append(_eroare( + "ODOMETRU_FINAL_FORMAT", + field="odometru_final", + cauza="odometruFinal trebuie sa fie un numar intreg (ca string).", + )) # --- odometruInitial: obligatoriu daca prestatii ∋ R-ODO/I-ODO; <= odometruFinal --- codes = _codes(content.get("prestatii")) @@ -101,26 +121,43 @@ def validate_prezentare(content: dict) -> list[dict]: raw_initial = content.get("odometru_initial") has_initial = str(raw_initial or "").strip() != "" if needs_initial and not has_initial: - errors.append({ - "field": "odometru_initial", - "message": "odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.", - }) + errors.append(_eroare( + "ODOMETRU_INITIAL_LIPSA", + field="odometru_initial", + cauza="odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.", + )) if has_initial: odo_initial = _parse_int(raw_initial) if odo_initial is None: - errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie un numar intreg."}) + errors.append(_eroare( + "ODOMETRU_INITIAL_FORMAT", + field="odometru_initial", + cauza="odometruInitial trebuie sa fie un numar intreg.", + )) elif odo_final is not None and odo_initial > odo_final: - errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie <= odometruFinal."}) + errors.append(_eroare( + "ODOMETRU_INITIAL_ORDINE", + field="odometru_initial", + cauza="odometruInitial trebuie sa fie <= odometruFinal.", + )) # --- prestatii nevide --- if not codes: - errors.append({"field": "prestatii", "message": "Lista de prestatii nu poate fi goala."}) + errors.append(_eroare( + "PRESTATII_GOALE", + field="prestatii", + cauza="Lista de prestatii nu poate fi goala.", + )) # --- b64Image: optional, dar daca e prezent trebuie base64 valid --- b64 = content.get("b64_image") if b64: if not _is_valid_base64(str(b64)): - errors.append({"field": "b64_image", "message": "b64Image nu este base64 valid."}) + errors.append(_eroare( + "B64_INVALID", + field="b64_image", + cauza="b64Image nu este base64 valid.", + )) return errors diff --git a/app/web/labels.py b/app/web/labels.py index 1172e7a..47ba290 100644 --- a/app/web/labels.py +++ b/app/web/labels.py @@ -178,6 +178,8 @@ def motiv_uman(status: str, rar_error: object) -> str: return f"Cod RAR lipsa pentru: {nume}" if nume else "Cod RAR lipsa" if "auto_send" in data: return "Necesita confirmare manuala (auto-send oprit pentru cod)" + if "problema" in data: + return str(data.get("problema") or "")[:200] parti = [f"{k}: {v}" for k, v in data.items()] return "; ".join(parti)[:200] @@ -195,6 +197,102 @@ def motiv_uman(status: str, rar_error: object) -> str: return str(data)[:160] +# --------------------------------------------------------------------------- +# parse_erori — transforma rar_error in lista 3-niveluri (US-006, PRD 5.4) +# --------------------------------------------------------------------------- + +def parse_erori(rar_error: object) -> list[dict]: + """Transforma `rar_error` (JSON stocat) intr-o lista de erori 3-niveluri. + + Fiecare element al listei are cheile: problema, cauza, fix, field (sau None). + Functie PURA — nu arunca niciodata exceptii; degradeaza gratios pe orice forma. + + Forme recunoscute: + - None / "" / falsy -> lista goala [] + - array imbogatit (au cod sau problema) -> un element per eroare + - dict cu cod specific -> 1 element cu cele 3 niveluri din dict + - dict fara cod (forma veche: unmapped / auto_send) -> 1 element cu problema din context + - lista cu {field, message} fara cod -> degradare: problema=message, cauza/fix="" + - string plain -> 1 element cu problema=text, cauza/fix="" + - JSON corupt -> 1 element cu problema=text brut, cauza/fix="" + """ + if not rar_error: + return [] + + raw = rar_error if isinstance(rar_error, str) else str(rar_error) + + # Incercare parsare JSON + try: + data = json.loads(raw) + except (ValueError, TypeError): + # String plain sau JSON corupt: degradare gratuoasa + return [{"problema": raw[:200], "cauza": "", "fix": "", "field": None}] + + # --- Forma: array de erori --- + if isinstance(data, list): + rezultat = [] + for e in data: + if not isinstance(e, dict): + rezultat.append({"problema": str(e)[:200], "cauza": "", "fix": "", "field": None}) + continue + # Eroare imbogatita (are cod sau problema) + if e.get("cod") or e.get("problema"): + rezultat.append({ + "problema": e.get("problema") or e.get("cod") or "", + "cauza": e.get("cauza") or e.get("message") or "", + "fix": e.get("fix") or "", + "field": e.get("field"), + }) + else: + # Forma veche: {field, message} fara cod + msg = str(e.get("message") or e.get("msg") or "; ".join(str(v) for v in e.values())) + elem = { + "problema": msg[:200], + "cauza": "", + "fix": "", + "field": e.get("field"), + } + # Filtreaza elementele complet goale (problema/cauza/fix toate vide) + if not ( + elem["problema"].strip() == "" + and elem["cauza"].strip() == "" + and elem["fix"].strip() == "" + ): + rezultat.append(elem) + return rezultat + + # --- Forma: dict --- + if isinstance(data, dict): + # Dict imbogatit cu cod explicit + if data.get("cod") or data.get("problema"): + return [{ + "problema": data.get("problema") or data.get("cod") or "", + "cauza": data.get("cauza") or "", + "fix": data.get("fix") or "", + "field": data.get("field"), + }] + # Dict vechi: unmapped + if "unmapped" in data: + ops = data.get("unmapped") or [] + coduri = ", ".join( + (o.get("cod_op_service") or "") for o in ops if isinstance(o, dict) + ).strip(", ") + problema = f"Cod RAR lipsa pentru: {coduri}" if coduri else "Cod RAR lipsa" + return [{"problema": problema, "cauza": "", "fix": "", "field": None}] + # Dict vechi: auto_send + if "auto_send" in data: + return [{"problema": "Necesita confirmare manuala (auto-send oprit pentru cod)", + "cauza": "", "fix": "", "field": None}] + # Dict generic necunoscut + parti = "; ".join(f"{k}: {v}" for k, v in data.items()) + if not parti.strip(): + return [] + return [{"problema": parti[:200], "cauza": "", "fix": "", "field": None}] + + # Scalar (nr, bool, etc.) + return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}] + + # --------------------------------------------------------------------------- # Constante auxiliare (microcopy fix, fara logica) # --------------------------------------------------------------------------- diff --git a/app/web/routes.py b/app/web/routes.py index 04303a7..0750061 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -23,6 +23,7 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from .. import __version__ +from .. import errors as _errors from ..auth import rotate_api_key from ..payload_view import prezentare_din_payload from ..web.csrf import get_csrf_token, verify_csrf @@ -33,6 +34,7 @@ from .labels import ( eticheta_worker, format_data_rar, motiv_uman, + parse_erori, ) from ..web.session import require_login from ..api.v1.import_router import ( @@ -71,6 +73,8 @@ _CANONICAL_FIELDS = [(k, v[0]) for k, v in _CANONICAL_SYNONYMS.items()] router = APIRouter(tags=["web"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) +# Expune parse_erori in toate template-urile (US-006, PRD 5.4) +templates.env.globals["parse_erori"] = parse_erori _BLOCKED = ("error", "needs_data", "needs_mapping") @@ -604,6 +608,7 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, "rar_status_code": row["rar_status_code"], "rar_error": row["rar_error"], "motiv": motiv_uman(row["status"], row["rar_error"]), + "erori_3n": parse_erori(row["rar_error"]), "retry_count": row["retry_count"], "created_at": format_data_rar(row["created_at"]), "updated_at": format_data_rar(row["updated_at"]), @@ -1279,13 +1284,25 @@ async def web_upload_import( except MultipleSheets as ms: return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names)) except FileTooLarge as e: - return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e))) + eroare_upload = _errors.eroare("IMPORT_FISIER_PREA_MARE", cauza=str(e)) + return templates.TemplateResponse("_upload.html", _ctx( + request, error=str(e), eroare_upload=eroare_upload + )) except HeaderError as e: - return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}")) + eroare_upload = _errors.eroare("IMPORT_ANTET_NECLAR", cauza=f"Antet neclar: {e}") + return templates.TemplateResponse("_upload.html", _ctx( + request, error=f"Antet neclar: {e}", eroare_upload=eroare_upload + )) except UnicodeDecodeError as e: - return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}")) + eroare_upload = _errors.eroare("IMPORT_ENCODING", cauza=f"Encoding nesuportat: {e.reason}") + return templates.TemplateResponse("_upload.html", _ctx( + request, error=f"Encoding nesuportat: {e.reason}", eroare_upload=eroare_upload + )) except Exception as e: - return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}")) + eroare_upload = _errors.eroare("IMPORT_FISIER_NERECUNOSCUT", cauza=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}") + return templates.TemplateResponse("_upload.html", _ctx( + request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}", eroare_upload=eroare_upload + )) conn = get_connection() try: @@ -1370,6 +1387,52 @@ async def web_save_mapare_coloane( account_id = require_login(request) acct = account_or_default(account_id) + # Detecta body JSON trimis eronat (Content-Type: application/json) → COLOANE_FORMAT_JSON + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + body = await request.body() + try: + json.loads(body) + # JSON valid dar trimis pe ruta de form — tot e format gresit pentru aceasta ruta + except (json.JSONDecodeError, ValueError): + pass + # Indiferent daca JSON-ul e valid sau nu, Content-Type application/json e gresit pentru ruta form + eroare_mapare = _errors.eroare( + "COLOANE_FORMAT_JSON", + cauza="Cererea a fost trimisa ca JSON (application/json) in loc de form data.", + ) + conn = get_connection() + try: + first_row = conn.execute( + "SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1", + (import_id,), + ).fetchone() + columns: list[str] = [] + if first_row: + try: + rd = decrypt_creds(first_row["raw_json"]) or {} + columns = list(rd.keys()) + except Exception: + pass + fuzzy: dict[str, list[dict]] = {} + for col in columns: + sugg = _fuzzy_suggest_column(col, limit=3) + if sugg: + fuzzy[col] = sugg + return templates.TemplateResponse("_mapcoloane.html", _ctx( + request, + import_id=import_id, + columns=columns, + sample_rows=[], + fuzzy_suggestions=fuzzy, + canonical_fields=_CANONICAL_FIELDS, + format_data=None, + eroare_mapare=eroare_mapare, + error=True, + )) + finally: + conn.close() + form = await request.form() # Colectare perechi coloana fisier → camp canonic din form diff --git a/app/web/templates/_eroare.html b/app/web/templates/_eroare.html new file mode 100644 index 0000000..9902147 --- /dev/null +++ b/app/web/templates/_eroare.html @@ -0,0 +1,36 @@ +{# + _eroare.html — macro card_erori(erori) (US-006, PRD 5.4). + + Primeste o lista de dict-uri cu cheile: problema, cauza, fix, field (sau None). + Afiseaza 3 niveluri intr-un bloc scannabil: + - "Problema" (bold, --err) + - "De ce" (doar daca ne-gol, --muted) + - "Cum repari" (accentuat, --accent) + + Nu hardcodeaza culori — foloseste variabilele CSS din paleta (base.html). + Suporta light + dark din box (variabilele se schimba prin [data-theme]). +#} + +{% macro card_erori(erori) %} +{% if erori %} +
+ {% for e in erori %} +
+
+ {% if e.field %}{{ e.field }} {% endif %}{{ e.problema }} +
+ {% if e.cauza %} +
+ De ce: {{ e.cauza }} +
+ {% endif %} + {% if e.fix %} +
+ Cum repari: {{ e.fix }} +
+ {% endif %} +
+ {% endfor %} +
+{% endif %} +{% endmacro %} diff --git a/app/web/templates/_mapcoloane.html b/app/web/templates/_mapcoloane.html index 7aedf83..4b89465 100644 --- a/app/web/templates/_mapcoloane.html +++ b/app/web/templates/_mapcoloane.html @@ -1,12 +1,17 @@
{% set pas = 2 %}{% include '_stepper.html' %} + {% from '_eroare.html' import card_erori %}

Mapare coloane — {{ filename or ("import #" ~ import_id) }}

- {% if message %} + {% if eroare_mapare %} +
+ {{ card_erori([eroare_mapare]) }} +
+ {% elif message %}
{{ message }} diff --git a/app/web/templates/_preview_rand.html b/app/web/templates/_preview_rand.html index 9fda196..32a7f36 100644 --- a/app/web/templates/_preview_rand.html +++ b/app/web/templates/_preview_rand.html @@ -13,7 +13,8 @@ {%- set op = (prestatii[0].get('cod_prestatie') or prestatii[0].get('cod_op_service', '')) if prestatii else '' -%} {% if editing %} {%- set err_map = {} -%} -{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- endif -%}{%- endfor -%} +{%- set fix_map = {} -%} +{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- if e.get('fix') -%}{%- set _ = fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endif -%}{%- endfor -%}
{{ err_map.get(nume) }}
{% endif %} + {% if fix_map.get(nume) %} + {{ fix_map.get(nume) }} + {% endif %}
{% endmacro %} @@ -83,13 +87,23 @@ })(); {% else %} +{%- set disp_fix_map = {} -%} +{%- for e in row.errors -%}{%- if e is mapping and e.get('field') and e.get('fix') -%}{%- set _ = disp_fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endfor -%} {{ row.row_index + 1 }} - {{ res.get('vin') or '' | safe }} - {{ res.get('nr_inmatriculare') or '' }} - {{ res.get('data_prestatie') or '' }} - {{ res.get('odometru_final') or '' }} + {{ res.get('vin') or '' | safe }} + {% if disp_fix_map.get('vin') %}{{ disp_fix_map.get('vin') }}{% endif %} + + {{ res.get('nr_inmatriculare') or '' }} + {% if disp_fix_map.get('nr_inmatriculare') %}{{ disp_fix_map.get('nr_inmatriculare') }}{% endif %} + + {{ res.get('data_prestatie') or '' }} + {% if disp_fix_map.get('data_prestatie') %}{{ disp_fix_map.get('data_prestatie') }}{% endif %} + + {{ res.get('odometru_final') or '' }} + {% if disp_fix_map.get('odometru_final') %}{{ disp_fix_map.get('odometru_final') }}{% endif %} + {{ op or '' | safe }} {{ status }} diff --git a/app/web/templates/_trimitere_detaliu.html b/app/web/templates/_trimitere_detaliu.html index 4e3adb7..a180a28 100644 --- a/app/web/templates/_trimitere_detaliu.html +++ b/app/web/templates/_trimitere_detaliu.html @@ -1,3 +1,4 @@ +{% from "_eroare.html" import card_erori %}

Detaliu trimitere #{{ id }}

@@ -27,7 +28,11 @@
Urmatoarea incercare
{{ next_attempt_at }}
- {% if motiv %} + {% if erori_3n %} +
+ {{ card_erori(erori_3n) }} +
+ {% elif motiv %}
Motiv
{{ motiv }}
diff --git a/app/web/templates/_upload.html b/app/web/templates/_upload.html index b97a358..2612eaa 100644 --- a/app/web/templates/_upload.html +++ b/app/web/templates/_upload.html @@ -2,13 +2,18 @@ {% set pas = 1 %}{% include '_stepper.html' %} {# US-004 (3.6): bara de upload accentuata (border de accent) ca sa ramana punctul de intrare evident chiar cu tabelul Trimiteri lung dedesubt (D-1.1/D-5.2). #} + {% from '_eroare.html' import card_erori %}
{% if message %}
{{ message }}
{% endif %} - {% if error %} + {% if eroare_upload %} +
+ {{ card_erori([eroare_upload]) }} +
+ {% elif error %} {% endif %} diff --git a/app/web/templates/base.html b/app/web/templates/base.html index cc559ad..d2e3c75 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -103,6 +103,19 @@ border-color:var(--line); border-bottom-color:var(--card); } .tab-panel { min-height:120px; } .status-bar { margin-bottom:12px; } + /* Eroare 3 niveluri (US-006, PRD 5.4) */ + .eroare-3n { margin-top:10px; } + .eroare-3n-item { padding:8px 10px; border-left:3px solid var(--err); + background:color-mix(in srgb, var(--err) 8%, var(--card)); + border-radius:0 6px 6px 0; } + .eroare-3n-sep { margin-top:6px; } + .eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; } + .eroare-3n-camp { font-family:monospace; font-size:12px; opacity:.85; } + .eroare-3n-cauza { color:var(--muted); font-size:12px; margin-top:3px; } + .eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; } + .eroare-3n-label { font-weight:500; } + /* Inline fix per camp in preview */ + .camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; } diff --git a/app/worker/__main__.py b/app/worker/__main__.py index 8a6111b..9a73aed 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -34,6 +34,7 @@ from datetime import datetime, timedelta, timezone import httpx +from .. import errors from ..config import Settings, get_settings, load_test_credentials from ..crypto import decrypt_creds from ..db import get_connection, init_db, write_heartbeat @@ -200,7 +201,14 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d return "sent" except RarError as exc: if exc.status_code == 400: - detail = json.dumps(exc.field_errors, ensure_ascii=False) if exc.field_errors else str(exc) + if exc.field_errors: + enriched = [ + errors.eroare("RAR_VALIDARE", field=fe.get("field"), cauza=fe.get("message")) + for fe in exc.field_errors + ] + else: + enriched = [errors.eroare("RAR_VALIDARE", cauza=str(exc))] + detail = json.dumps(enriched, ensure_ascii=False) mark(conn, sid, "needs_data", rar_status_code=400, rar_error=detail) print(f"[worker] submission {sid} -> needs_data: {detail}", flush=True) return "needs_data" @@ -406,7 +414,8 @@ def run() -> int: token = sessions.get_token(conn, account_id, creds) except RarAuthError as exc: # Creds gresite (login 401): NU se face retry (plan, failure registry). - mark(conn, sid, "error", rar_status_code=401, rar_error="credentiale RAR invalide") + mark(conn, sid, "error", rar_status_code=401, + rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False)) print(f"[worker] submission {sid} (cont {account_id}) -> error: {exc}", flush=True) continue diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index edf2377..5fe93ce 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-22 — 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-22 — 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). @@ -99,7 +99,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi | 5.1 | Hub de integrare (pagina `/integrare` autentificata): exemple cod multi-limbaj (curl/Python/PHP/C#/Node) + retetar **Visual FoxPro** (POST via `MSXML2.ServerXMLHTTP` + upload CSV) + export OpenAPI/Postman + buton "Testeaza conexiunea" | DONE | 2026-06-22 | 4 stories (2 valuri, 2 echipe paralel + restore dupa clobber de merge). US-001 `GET /v1/ping` readiness (`account_id/mediu/autentificat_cu_cheie/are_creds_rar/ts`) + `GET /v1/integrare/postman.json` (v2.1.0, allowlist 3 rute). US-002 `integrare_examples.py` pur (7 limbaje × 2 canale, drift-test `is_required()`). US-003 tab "Integrare" IA pe 2 niveluri (limbaj→canal, VFP cu dialecte MSXML2/WinHttp), copy din `
`, empty-state CTA, export `.cardlink`, doar tokens. US-004 `POST /integrare/test-cheie` (`account_for_key` direct, scoped sesiune, no-echo). VERIFY: 568 teste + E2E browser (Playwright: VFP 3 niveluri comuta, htmx test-cheie → fragment eroare, 0 erori consola) + enqueue live; live RAR neprobat (lipsa creds key). `/code-review` high: 4 bug-uri reale reparate (C#/VFP snippet JSON multi-linie nevalid, Node `node:buffer` fara FormData, script ne-scoped acumuland listeneri). Backend trimitere NEATINS. PRD: [prd-5.1](prd/prd-5.1-hub-integrare.md) |
 | 5.2 | Endpoint dry-run `POST /v1/prezentari/valideaza` — valideaza payload + mapare, intoarce erorile reale FARA enqueue | DONE | 2026-06-22 | 1 story (US-001), un worker TDD. Helper pur partajat `classify_prezentare` (mapping.py) folosit de AMBELE rute → garanteaza ca dry-run-ul da acelasi verdict ca trimiterea reala (invariant de corectitudine, nu doar DRY); `create_prezentari` refactorizat pe el (comportament identic, test_api.py verde). Ruta read-only: `{results:[{index,valid,status_estimat,erori,nemapate,prestatii_rezolvate}]}`, `rar_credentials` optional+ignorat, zero scriere DB, scope prin `resolve_account_id`. Doar validare+mapare (FARA idempotency/duplicat — decizie user; `idempotency.py` neatins) + hub `/integrare` amanat. VERIFY context curat PASS (577 teste; E2E API: queued/needs_data/needs_mapping + COUNT(*)=0 dupa dry-run, fara leak creds; regresia de aur `POST /v1/prezentari`→queued verde; live RAR neprobat — lipsa `AUTOPASS_CREDS_KEY`/creds test, endpoint read-only nu atinge worker/coada). `/code-review` high: 0 findings. PRD: [prd-5.2](prd/prd-5.2-dryrun-valideaza.md) |
 | 5.3 | Light/Dark mode — toggle in header, persistat (localStorage); CSS deja pe variabile `:root` | DONE | 2026-06-22 | 3 stories (US-001 paleta light + US-003 fix suprafete fragmente; US-002 comutator+persistenta+anti-FOUC), un worker TDD secvential (toate ating templates). Tema light = bloc `[data-theme="light"]` care suprascrie variabilele `:root` (dark NESCHIMBAT la octet, default pastrat); fundalurile de stare hardcodate (`#241a1a`/`#201c0f`/`#16241c`) convertite la `color-mix(... 12%, var(--card))` in `base.html` + 7 fragmente `_*.html`. Comutator soare/luna in header (aria-label, >=36px), pe toate paginile (login/signup/dashboard/admin). Default OS-aware (`prefers-color-scheme`, fallback dark); persistenta `localStorage` DOAR la click (init doar sincronizeaza iconita → OS-aware ramane viu pana la comutare explicita); script anti-FOUC in `` inainte de `