Compare commits

...

5 Commits

Author SHA1 Message Date
Claude Agent
36ec50d667 docs: 5.5 LIVRAT in ROADMAP (uniformizare UI/UX + lifecycle conturi)
Inchide randul 5.5 (DONE) + Ultima actualizare, dupa commit 1fbd894.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:56:55 +00:00
Claude Agent
1fbd894329 feat(web): uniformizare/standardizare UI/UX + lifecycle conturi (PRD 5.5)
Aduce toate suprafetele dashboard-ului la grila tabelului Trimiteri, muta
navigarea intr-un meniu de cont (hamburger) si da panoului admin actiuni
reale de ciclu de viata. 9 stories, 3 valuri. UI pur (reskin + reasezare)
cu O SINGURA exceptie backend: modelul de stare a contului.

- US-001 sectiunea "Ajutor" eliminata din Acasa (wayfinding redundant).
- US-002 Nomenclator la grila standard (_submissions.html ca referinta).
- US-003 macro autosend compact (Manual<->Auto). Semantica de PREZENTA
  `auto_send` (bifat->true, absent->false) NEALTERATA — compatibil cu ambele
  parsere (Form(bool) la /mapari, bool(form.get()) la import). Zero backend.
- US-004 accounts.status (pending/active/blocked/archived/deleted), migrare
  defensiva idempotenta derivata din `active`, gate worker claim_one pe
  status='active' (echivalenta active=1 <=> status='active' pastrata).
- US-005 tabel Mapari compact + panou Ajutor (<details>, proza o singura data),
  coloana "In coada".
- US-006 meniu hamburger dropdown (Cont/Integrare/Nomenclator/Admin/logout) +
  context is_authenticated/is_admin/csrf_token defensiv in base.html.
- US-007 tab-bar redus la Acasa+Mapari; rutele /_fragments/{cont,integrare,
  nomenclator} + deep-link ?tab= raman valide.
- US-008 rute admin block/archive/delete + bulk pe lista account_id,
  require_admin + CSRF + PRG, dev id=1 sarit in bulk.
- US-009 admin UI: selectie bife + master + bara bulk + kebab per-rand,
  grupare pe stare (bloc nou blocate/arhivate), nota "cont dev implicit" scoasa.

Stergere = SOFT: tombstone (status='deleted'), dar PII purjata IMEDIAT
(rar_creds_enc + chei API revocate + CUI eliberat pentru re-inregistrare),
GDPR/L.142.

VERIFY: 671 teste pass (+40). E2E browser (Playwright) a prins 2 bug-uri
invizibile la TestClient: bara bulk cu display:flex inline invingea [hidden]
(mutat in CSS .bulk-bar[hidden]); conturi arhivate cadeau sub "in asteptare"
(grupare pe status). /code-review high a prins 2 bug-uri reale: soft delete
pastra creds RAR + CUI la nesfarsit fara purjare accounts (GDPR neonorat);
apostrof in numele firmei rupea confirm() inline din kebab — ambele reparate,
plus cleanup boilerplate rute (_lifecycle_route).

Backend trimitere (worker masina stari/idempotenta/mapping) neatins, cu
exceptia gate-ului de cont. Design: docs/design/5.5-uniformizare-ui.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:56:05 +00:00
Claude Agent
14e1c463f0 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) <noreply@anthropic.com>
2026-06-23 10:28:09 +00:00
Claude Agent
b48501d8e4 feat(web): light/dark mode cu comutator persistat + anti-FOUC (PRD 5.3)
Tema light ca bloc [data-theme="light"] peste variabilele :root (dark
nemodificat la octet). Comutator soare/luna in header pe toate paginile,
default OS-aware (prefers-color-scheme, fallback dark), persistenta in
localStorage doar la comutare explicita, script anti-FOUC in <head>
pre-paint. Suprafetele de stare hardcodate convertite la color-mix in
base.html + 7 fragmente _*.html (light lizibil, contrast WCAG AA).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:39:12 +00:00
Claude Agent
ae7960294f feat(api): endpoint dry-run POST /v1/prezentari/valideaza (PRD 5.2)
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. "Magical
moment" pentru integratori (ROAAUTO / soft propriu / punte VFP).

Cheia de design: helper pur partajat classify_prezentare (mapping.py) 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 (test_api.py verde).

Scope minim (decizie user): doar validare+mapare, fara idempotency/duplicat
(idempotency.py neatins); descoperibilitate in hub /integrare amanata.

VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0
dupa dry-run). /code-review high: 0 findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:54:50 +00:00
55 changed files with 5091 additions and 287 deletions

View File

@@ -39,9 +39,10 @@ def create_account(
raise ValueError("name gol (un cont are nevoie de nume)")
cui = _norm_cui(cui)
try:
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
cur = conn.execute(
"INSERT INTO accounts (name, cui, active) VALUES (?, ?, ?)",
(name, cui, 1 if active else 0),
"INSERT INTO accounts (name, cui, active, status) VALUES (?, ?, ?, ?)",
(name, cui, 1 if active else 0, "active" if active else "pending"),
)
except sqlite3.IntegrityError:
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
@@ -55,16 +56,72 @@ def create_account(
def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
Cont inexistent -> ValueError."""
Cont inexistent -> ValueError.
Mentine invariantul 5.5 active=1 <=> status='active': activarea -> 'active',
dezactivarea -> 'pending' (legacy „in asteptare"). Pentru blocare/arhivare/stergere
foloseste `set_status`/`delete_account`.
"""
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not row:
raise ValueError(f"cont inexistent: {account_id}")
conn.execute("UPDATE accounts SET active=? WHERE id=?", (1 if active else 0, account_id))
conn.execute(
"UPDATE accounts SET active=?, status=? WHERE id=?",
(1 if active else 0, "active" if active else "pending", account_id),
)
# Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de
# retentie); restul sunt reversibile.
VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted")
# Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b).
_PROTECTED_ACCOUNT_ID = 1
def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
"""Seteaza `accounts.status` la una din `VALID_STATUSES`, mentinand mirror-ul `active`
(active=1 doar pentru 'active', altfel 0).
Contul de sistem id=1 NU poate fi mutat din 'active' (cont default) -> ValueError.
Status invalid sau cont inexistent -> ValueError.
"""
if status not in VALID_STATUSES:
raise ValueError(f"status invalid: {status}")
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not row:
raise ValueError(f"cont inexistent: {account_id}")
if account_id == _PROTECTED_ACCOUNT_ID and status != "active":
raise ValueError("Contul default (id=1) nu poate fi blocat/arhivat/sters (cont de sistem).")
conn.execute(
"UPDATE accounts SET active=?, status=? WHERE id=?",
(1 if status == "active" else 0, status, account_id),
)
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
revocate si CUI-ul eliberat (ca acelasi CUI sa se poata re-inregistra — altfel indexul unic
`ux_accounts_cui` l-ar tine blocat de un cont invizibil). Contul de sistem id=1 e protejat.
Nota: nu facem hard DELETE pe rand din cauza FK-urilor (submissions/api_keys/...); pastram
tombstone-ul pentru audit, dar fara PII. Jobul de retentie T16 purjeaza `submissions`/batches,
NU acest tombstone — de aceea purjam PII aici, la momentul stergerii."""
set_status(conn, account_id, "deleted") # valideaza existenta + protejeaza id=1; seteaza status+active=0
conn.execute(
"UPDATE accounts SET rar_creds_enc=NULL, cui=NULL WHERE id=?", (account_id,)
)
conn.execute(
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1",
(account_id,),
)
def list_accounts(conn: sqlite3.Connection) -> list[dict]:
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id."""
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
(stergere soft -> invizibile in panou)."""
rows = conn.execute(
"SELECT id, name, cui, active, created_at FROM accounts ORDER BY id"
"SELECT id, name, cui, active, status, created_at FROM accounts "
"WHERE status != 'deleted' ORDER BY id"
).fetchall()
return [dict(r) for r in rows]

View File

@@ -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),
},
)

View File

@@ -22,20 +22,26 @@ from pydantic import BaseModel, Field
from ...auth import resolve_account_id
from ...crypto import encrypt_creds
from ...db import get_connection
from ...idempotency import build_key, canonicalize_row, idempotency_key
from ...errors import eroare as err_eroare
from ...idempotency import build_key, canonicalize_row
from ...mapping import (
account_or_default,
account_scope_clause,
has_no_auto_send,
classify_prezentare,
load_mapping_meta,
pending_unmapped,
reresolve_account,
resolve_prestatii,
save_mapping,
)
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
from ...models import (
PrezentareRequest,
PrezentariResponse,
SubmissionResult,
ValidarePrezentariRequest,
ValidareResponse,
ValidareResult,
)
from ...payload_view import prezentare_din_payload
from ...validation import validate_prezentare
router = APIRouter(prefix="/v1", tags=["v1"])
@@ -94,43 +100,58 @@ def create_prezentari(
)
continue
# Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul
# intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite),
# apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci
# validarea T3 + payload builder + worker raman code-driven.
resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping)
content["prestatii"] = resolved
if unmapped:
status = "needs_mapping"
rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False)
else:
# T3: validare de continut -> queued daca e curat, altfel needs_data + motiv.
errors = validate_prezentare(content)
if errors:
status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False)
elif has_no_auto_send(resolved, mapping_meta):
# T6/OV-1: cod rezolvat cu auto_send=0 -> nu trimite automat.
# Randul ramane 'needs_mapping' pana userul confirma manual (sau comuta auto_send=1).
status = "needs_mapping"
rar_error = json.dumps(
{"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
ensure_ascii=False,
)
else:
status, rar_error = "queued", None
# 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)
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
"VALUES (?, ?, ?, ?, ?, ?)",
(key, acct, status, json.dumps(content, ensure_ascii=False), rar_error, creds_enc),
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
)
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status))
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=cl["status"]))
finally:
conn.close()
return PrezentariResponse(results=results)
@router.post("/prezentari/valideaza", response_model=ValidareResponse)
def valideaza_prezentari(
req: ValidarePrezentariRequest,
account_id: int = Depends(resolve_account_id),
) -> ValidareResponse:
"""Dry-run: valideaza payload exact ca POST /prezentari, fara enqueue si fara efecte secundare.
Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de
continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi
payload + aceeasi mapare de cont. rar_credentials ignorat complet (PRD 5.2).
"""
acct = account_or_default(account_id)
conn = get_connection()
results: list[ValidareResult] = []
try:
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
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=nemapate,
prestatii_rezolvate=res["resolved"],
))
finally:
conn.close()
return ValidareResponse(results=results)
@router.get("/prezentari")
def list_prezentari(
status: str | None = None,

View File

@@ -63,6 +63,18 @@ def _migrate(conn: sqlite3.Connection) -> None:
if "active" not in acc_cols:
# Conturi existente raman active (default 1). Lifecycle consumat de 3.3.
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
acc_cols.add("active")
if "status" not in acc_cols:
# Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b).
# Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`:
# active=0 -> 'pending'. Invariant: active=1 <=> status='active'.
conn.execute(
"ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' "
"CHECK (status IN ('pending','active','blocked','archived','deleted'))"
)
conn.execute(
"UPDATE accounts SET status='pending' WHERE active=0 AND status='active'"
)
# 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"

203
app/errors.py Normal file
View File

@@ -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,
}

View File

@@ -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
@@ -217,6 +218,66 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]:
}
def classify_prezentare(
content: dict,
mapping: dict[str, str],
mapping_meta: dict[str, dict],
) -> dict:
"""Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte.
Apelat de AMBELE rute (POST /v1/prezentari si POST /v1/prezentari/valideaza) pentru
a garanta acelasi verdict — invariantul de corectitudine dry-run (PRD 5.2).
Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}.
"content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate).
"""
from .idempotency import canonicalize_row # import local: evita circular (mapping <- idempotency)
c = dict(content)
canon = canonicalize_row(c)
c.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"odometru_final": canon["odometru_final"],
})
resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping)
c["prestatii"] = resolved
if unmapped:
status = "needs_mapping"
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)
if errors:
status = "needs_data"
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": mesaj, **err_mod.eroare("AUTO_SEND_OPRIT", cauza=mesaj)},
ensure_ascii=False,
)
else:
status = "queued"
rar_error = None
return {
"status": status,
"rar_error": rar_error,
"resolved": resolved,
"unmapped": unmapped,
"errors": errors,
"content": c,
}
def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool:
"""Verifica daca vreun item rezolvat via mapping are auto_send=0.

View File

@@ -95,3 +95,25 @@ class SubmissionResult(BaseModel):
class PrezentariResponse(BaseModel):
results: list[SubmissionResult]
class ValidarePrezentariRequest(BaseModel):
"""Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue (PRD 5.2)."""
rar_credentials: RarCredentials | None = None
prezentari: list[PrezentareIn] = Field(..., min_length=1)
class ValidareResult(BaseModel):
"""Verdictul dry-run per prezentare."""
index: int
valid: bool
status_estimat: str # "queued" | "needs_data" | "needs_mapping"
erori: list[dict] = []
nemapate: list[dict] = []
prestatii_rezolvate: list[dict] = []
class ValidareResponse(BaseModel):
results: list[ValidareResult]

View File

@@ -11,6 +11,13 @@ CREATE TABLE IF NOT EXISTS accounts (
name TEXT NOT NULL,
cui TEXT,
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
-- Stare de ciclu de viata explicita (5.5). Superset al lui `active`: mentinem invariantul
-- active=1 <=> status='active' (vezi accounts.set_status / set_active). Worker gate-uieste pe status.
-- pending=neactivat · active=operational · blocked=suspendat reversibil · archived=scos din liste,
-- date read-only · deleted=stergere soft (tombstone; PII/creds + CUI purjate imediat la stergere,
-- vezi accounts.delete_account — randul ramane doar pentru audit).
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)
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -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

View File

@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..accounts import list_accounts, set_active
from ..accounts import list_accounts, set_active, set_status, delete_account
from ..config import get_settings
from ..db import get_connection
from ..web.csrf import get_csrf_token, verify_csrf
@@ -49,16 +49,20 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
emails = _emails_by_account(conn)
for acct in accounts:
acct["email"] = emails.get(acct["id"])
pending = [a for a in accounts if not a["active"] and a["id"] != 1]
active = [a for a in accounts if a["active"] and a["id"] != 1]
default = next((a for a in accounts if a["id"] == 1), None)
# Grupare pe STARE (5.5), nu pe `active`: altfel conturile arhivate/blocate (active=0)
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
active = [a for a in accounts if a["status"] == "active" and a["id"] != 1]
suspended = [a for a in accounts if a["status"] in ("blocked", "archived") and a["id"] != 1]
return _TMPL.TemplateResponse(request, "admin.html", _ctx(
request,
csrf_token=get_csrf_token(request),
pending=pending,
active=active,
default_account=default,
suspended=suspended,
error=error,
is_authenticated=True,
is_admin=True,
), status_code=status_code)
@@ -74,28 +78,66 @@ async def admin_get(request: Request):
conn.close()
@router.post("/admin/activate", response_class=HTMLResponse)
async def admin_activate(
request: Request,
account_id: int = Form(...),
csrf_token: str = Form(default=""),
):
"""Activeaza un cont. PRG: redirect 303 la /admin dupa succes."""
def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
"""Aplica un verb de ciclu de viata (5.5) pe o lista de conturi. Conturile protejate
(id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
`action`: activate | block | archive | delete."""
for aid in ids:
try:
if action == "activate":
set_status(conn, aid, "active")
elif action == "block":
set_status(conn, aid, "blocked")
elif action == "archive":
set_status(conn, aid, "archived")
elif action == "delete":
delete_account(conn, aid)
except ValueError:
continue # cont de sistem / inexistent -> sarit
def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str):
"""Corp comun pentru rutele de ciclu de viata (5.5): auth + CSRF + aplica verbul (bulk) + PRG.
Evita 4 handlere copy-paste care difera doar prin verb."""
require_admin(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
try:
set_active(conn, account_id, True)
except ValueError as exc:
return _render_admin(request, conn, error=str(exc), status_code=422)
_apply_lifecycle(conn, account_id, action)
conn.commit()
finally:
conn.close()
return RedirectResponse("/admin", status_code=303)
@router.post("/admin/activate", response_class=HTMLResponse)
async def admin_activate(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Activeaza unul sau mai multe conturi (bulk). PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "activate")
@router.post("/admin/block", response_class=HTMLResponse)
async def admin_block(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Blocheaza (suspendare reversibila) unul sau mai multe conturi. PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "block")
@router.post("/admin/archive", response_class=HTMLResponse)
async def admin_archive(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Arhiveaza (scos din listele active, date read-only) unul sau mai multe conturi. PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "archive")
@router.post("/admin/delete", response_class=HTMLResponse)
async def admin_delete(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Stergere SOFT (tombstone + purjare PII imediata) a unuia sau mai multor conturi. PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "delete")
@router.post("/admin/deactivate", response_class=HTMLResponse)
async def admin_deactivate(
request: Request,

View File

@@ -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)
# ---------------------------------------------------------------------------

View File

@@ -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")
@@ -327,6 +331,7 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
"active_tab": active_tab,
"panel_html": panel_html,
"badges": badges,
"is_authenticated": True,
"is_admin": is_account_admin(conn, account_id),
"csrf_token": get_csrf_token(request),
}
@@ -604,6 +609,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 +1285,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 +1388,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

View File

@@ -44,15 +44,8 @@
</div>
{% endif %}
{# === Subordonat: ajutor rapid pe un rand discret ===
US-003 (3.6): linkul redundant "Trimiteri" a fost scos (Trimiterile sunt mai jos
pe aceeasi pagina). Wayfinding "Mapari"/"Coduri RAR" pastrat pentru operatori. #}
<div style="margin-top:10px; font-size:13px; color:var(--muted);
display:flex; gap:16px; flex-wrap:wrap; align-items:center;">
<span>Ajutor:</span>
<a href="/?tab=mapari">Mapari</a>
<a href="/?tab=nomenclator">Coduri RAR</a>
</div>
{# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea
traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #}
{# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003).
Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul,

View File

@@ -1,5 +1,5 @@
{% if not account_active %}
<div class="card banner" style="border-color:var(--warn); background:#201c0f;"
<div class="card banner" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card));"
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
<strong>Cont in asteptare de activare.</strong>
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.

View File

@@ -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 %}
<div class="eroare-3n">
{% for e in erori %}
<div class="eroare-3n-item{% if not loop.first %} eroare-3n-sep{% endif %}">
<div class="eroare-3n-problema">
{% if e.field %}<span class="eroare-3n-camp">{{ e.field }}</span> {% endif %}{{ e.problema }}
</div>
{% if e.cauza %}
<div class="eroare-3n-cauza">
<span class="eroare-3n-label">De ce:</span> {{ e.cauza }}
</div>
{% endif %}
{% if e.fix %}
<div class="eroare-3n-fix">
<span class="eroare-3n-label">Cum repari:</span> {{ e.fix }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endmacro %}

View File

@@ -1,26 +1,28 @@
{# Macro-uri partajate intre template-urile de import si mapari. #}
{# US-007: comutator pe COADA in loc de bifa "auto-send".
Framing pe punerea in coada, NU pe trimitere (poarta autoplan UC-A): etichetele
poarta singure sensul de send-safety. `name="auto_send" value="true"` pastrat cu
semantica de prezenta (bifat -> True, nebifat -> absent -> False) ca sa produca
bool corect cu AMBELE parsere backend (Form(bool) la /mapari, bool(form.get())
la /_import/.../mapare-operatie). Zero atingere backend.
{# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara
proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari).
Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de
tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport).
INVARIANT BACKEND (nealterat din 3.6): control = checkbox cu `name="auto_send" value="true"`
si SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())`
la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent
pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual
Manual<->Auto peste checkbox, NU doua radio-uri. Zero atingere backend.
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
- checked: starea initiala (H4 - reflecta valoarea STOCATA per mapare). #}
- checked: starea STOCATA per mapare (H4) — bifat = Auto. #}
{% macro autosend_toggle(form_id='', checked=True) -%}
<div class="autosend-toggle" style="display:flex; flex-direction:column; gap:4px;">
<span class="muted" style="font-size:12px;">La fisierele viitoare cu aceasta operatie:</span>
<label class="chk" style="display:inline-flex; align-items:center; gap:8px; min-height:44px;">
<input type="checkbox" name="auto_send" value="true"
{%- if form_id %} form="{{ form_id }}"{% endif %}
{%- if checked %} checked{% endif %}
aria-label="Pune automat in coada la fisierele viitoare cu aceasta operatie">
<span><strong>Pune automat in coada</strong></span>
</label>
<span class="muted" style="font-size:11px;">
Nebifat = "Tine pentru verificare". Doar pentru aceasta operatie;
nimic nu pleaca la RAR pana confirmi.
</span>
</div>
<label class="autosend-toggle"
title="Auto = pune automat in coada la fisierele viitoare cu aceasta operatie. Manual = tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap; min-height:36px; cursor:pointer; font-size:13px;">
<span class="muted">Manual</span>
<input type="checkbox" name="auto_send" value="true"
{%- if form_id %} form="{{ form_id }}"{% endif %}
{%- if checked %} checked{% endif %}
aria-label="In coada: Auto (bifat) sau Manual (nebifat), pentru aceasta operatie"
style="width:32px; height:18px; cursor:pointer; accent-color:var(--accent);">
<span><strong>Auto</strong></span>
</label>
{%- endmacro %}

View File

@@ -9,7 +9,22 @@
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">De rezolvat</h2>
{# US-005 (5.5): antet standard + link Ajutor ca <details> nativ (fara JS). Toata proza
care inainte se repeta inline (scopul maparilor, Auto/Manual) traieste acum AICI,
o singura data, ascunsa implicit. #}
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
<details class="ajutor-mapari" style="margin:0 0 12px;">
<summary class="cardlink" style="display:inline-flex; color:var(--accent); cursor:pointer; padding:4px 0;">Ajutor</summary>
<div class="muted" style="font-size:13px; margin-top:8px; max-width:680px;">
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
Operatiile necunoscute raman blocate in <span class="s-needs_mapping">needs_mapping</span>
si NU pleaca la RAR pana le mapezi. Sugestiile (%) vin din potrivire fuzzy pe denumire —
verifica-le inainte sa salvezi. <strong>In coada</strong>: <strong>Auto</strong> = la
urmatoarele fisiere cu aceasta operatie randurile intra automat in coada;
<strong>Manual</strong> = raman pentru verificare, nimic nu pleaca la RAR pana confirmi.
La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
</div>
</details>
{% if not pending %}
<div class="empty">
@@ -17,18 +32,13 @@
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.
Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat.
</p>
<div class="tablewrap">
<table>
<thead><tr>
<th>Operatie</th>
<th>Sugestii</th>
<th>Cod RAR</th>
<th>Punere in coada</th>
<th>In coada</th>
<th></th>
</tr></thead>
<tbody>
@@ -88,17 +98,14 @@
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau punerea in coada si salveaza;
la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
</p>
{# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
<div class="tablewrap">
<table>
<thead><tr>
<th>Operatie</th>
<th>Cod RAR</th>
<th>Punere in coada</th>
<th>In coada</th>
<th>Actiuni</th>
</tr></thead>
<tbody>
@@ -160,7 +167,7 @@
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat (nu suprascrie).
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
</p>
<div class="tablewrap">

View File

@@ -1,13 +1,18 @@
<div id="import-section">
{% set pas = 2 %}{% include '_stepper.html' %}
{% from '_eroare.html' import card_erori %}
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">
Mapare coloane —
<span class="muted" style="font-weight:400;">{{ filename or ("import #" ~ import_id) }}</span>
</h2>
{% if message %}
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;"
{% if eroare_mapare %}
<div style="margin-bottom:12px;">
{{ card_erori([eroare_mapare]) }}
</div>
{% elif message %}
<div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
{% if error %}role="alert"{% endif %}>
{{ message }}
</div>

View File

@@ -1,12 +1,20 @@
{# US-002 (5.5): aceeasi grila standard ca tabelul Trimiteri (_submissions.html):
.tablewrap > table, antet th standard (mostenit din base.html), cod in .pill,
denumire ca text normal (singura coloana care se poate rupe pe randuri inguste),
empty-state in .empty. Zero stiluri inline noi — totul vine din base.html. #}
{% if rows %}
<div class="tablewrap">
<table>
<thead><tr><th>Cod</th><th>Denumire</th><th>Actualizat</th></tr></thead>
<thead><tr>
<th>Cod</th>
<th>Denumire</th>
<th>Actualizat</th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td><span class="pill">{{ r.cod_prestatie }}</span></td>
<td>{{ r.nume_prestatie }}</td>
<td style="white-space:normal;">{{ r.nume_prestatie }}</td>
<td class="muted">{{ r.updated_at }}</td>
</tr>
{% endfor %}

View File

@@ -11,7 +11,7 @@
</div>
{% if message %}
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;"
<div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
{% if error %}role="alert"{% endif %}>
{{ message }}
</div>
@@ -56,7 +56,7 @@
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload) -->
{% if unmapped_ops %}
<div class="card" style="border-color:var(--err); background:#241a1a; margin-bottom:14px;">
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e

View File

@@ -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 -%}
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}" data-editing="1">
<td colspan="10" style="background:rgba(91,141,239,.06);">
<form class="rand-editare"
@@ -31,12 +32,12 @@
</div>
{% if message %}
<div class="flash" style="border-color:var(--err); background:#241a1a; margin-bottom:10px;"
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:10px;"
role="alert">{{ message }}</div>
{% endif %}
<div class="rand-eroare-banner" role="alert"
style="display:none; margin-bottom:10px; padding:8px 12px; border:1px solid var(--err);
background:#241a1a; border-radius:6px; font-size:13px;">
background:color-mix(in srgb, var(--err) 12%, var(--card)); border-radius:6px; font-size:13px;">
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
</div>
@@ -50,6 +51,9 @@
{% if err_map.get(nume) %}
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
{% endif %}
{% if fix_map.get(nume) %}
<span class="camp-fix">{{ fix_map.get(nume) }}</span>
{% endif %}
</div>
{% endmacro %}
@@ -83,13 +87,23 @@
})();
</script>
{% 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 -%}
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
<td class="muted">{{ row.row_index + 1 }}</td>
<td>{{ res.get('vin') or '<span class="muted"></span>' | safe }}</td>
<td>{{ res.get('nr_inmatriculare') or '' }}</td>
<td>{{ res.get('data_prestatie') or '' }}</td>
<td>{{ res.get('odometru_final') or '' }}</td>
<td>{{ res.get('vin') or '<span class="muted"></span>' | safe }}
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
</td>
<td>{{ res.get('nr_inmatriculare') or '' }}
{% if disp_fix_map.get('nr_inmatriculare') %}<span class="camp-fix">{{ disp_fix_map.get('nr_inmatriculare') }}</span>{% endif %}
</td>
<td>{{ res.get('data_prestatie') or '' }}
{% if disp_fix_map.get('data_prestatie') %}<span class="camp-fix">{{ disp_fix_map.get('data_prestatie') }}</span>{% endif %}
</td>
<td>{{ res.get('odometru_final') or '' }}
{% if disp_fix_map.get('odometru_final') %}<span class="camp-fix">{{ disp_fix_map.get('odometru_final') }}</span>{% endif %}
</td>
<td>{{ op or '<span class="muted"></span>' | safe }}</td>
<td><span class="pill s-{{ status }}">{{ status }}</span></td>
<td class="muted" style="font-size:12px; white-space:normal; max-width:220px;">

View File

@@ -6,7 +6,7 @@
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
{% if not account_active %}
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
background:#201c0f; border-radius:6px; font-size:13px;">
background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px; font-size:13px;">
<strong>Cont in asteptare de activare.</strong>
Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR
porneste automat dupa activare de catre administrator.

View File

@@ -1,3 +1,4 @@
{% from "_eroare.html" import card_erori %}
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--accent);">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
@@ -27,7 +28,11 @@
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</div></div>
</div>
{% if motiv %}
{% if erori_3n %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
{{ card_erori(erori_3n) }}
</div>
{% elif motiv %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div class="muted" style="font-size:12px;">Motiv</div>
<div>{{ motiv }}</div>
@@ -49,7 +54,7 @@
<h3 style="font-size:14px; margin:0 0 8px;">Corecteaza si re-trimite</h3>
{% if corectie_msg %}
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:10px;"
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:10px;"
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %}

View File

@@ -2,19 +2,24 @@
{% 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 %}
<div class="card" style="border-color:var(--accent);">
{% if message %}
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
{% endif %}
{% if error %}
<div class="flash" style="border-color:var(--err); background:#241a1a; margin-bottom:12px;"
{% if eroare_upload %}
<div style="margin-bottom:12px;">
{{ card_erori([eroare_upload]) }}
</div>
{% elif error %}
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:12px;"
role="alert">{{ error }}</div>
{% endif %}
{% if sheets %}
<div class="flash" style="border-color:var(--warn); background:#201c0f; margin-bottom:12px;">
<div class="flash" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); margin-bottom:12px;">
Fisierul are mai multe foi de lucru. Alege foaia de mai jos si incarca din nou.
</div>
{% endif %}

View File

@@ -1,6 +1,104 @@
{% extends "base.html" %}
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %}
{% block content %}
{# US-009 (5.5): metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
{% set VERBS = {
'activate': ('Activeaza', '/admin/activate', ''),
'block': ('Blocheaza', '/admin/block', ''),
'archive': ('Arhiveaza', '/admin/archive', ''),
'delete': ('Sterge', '/admin/delete', 'danger')
} %}
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
<div class="card">
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
{% if rows %}
{# Bara bulk: form propriu (id=bulk-<block>); checkbox-urile randurilor se leaga prin atributul
HTML5 form= (fara form-uri imbricate). Ascunsa pana exista o selectie (JS). #}
<form id="bulk-{{ block_id }}" method="post" class="bulk-form" data-block="{{ block_id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="bulk-bar" hidden>
<span class="bulk-count muted" style="font-size:13px;">0 selectate</span>
{% for v in bulk_verbs %}
{% set label, action, cls = VERBS[v] %}
<button type="submit" formaction="{{ action }}"
{% if v == 'delete' %}onclick="return confirm('Stergi conturile selectate? (stergere soft, datele se purjeaza)');"{% endif %}
style="{% if cls == 'danger' %}background:var(--card); color:var(--err); border-color:var(--err);{% endif %}">{{ label }}</button>
{% endfor %}
</div>
</form>
<div class="tablewrap">
<table>
<thead><tr>
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
aria-label="Selecteaza tot"></th>
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
</tr></thead>
<tbody>
{% for acct in rows %}
<tr>
<td><input type="checkbox" name="account_id" value="{{ acct.id }}" form="bulk-{{ block_id }}"
class="row-check" data-block="{{ block_id }}"
aria-label="Selecteaza contul {{ acct.name }}"></td>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td><span class="pill">{{ acct.status }}</span></td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td style="white-space:nowrap;">
<details class="kebab">
<summary class="cardlink" style="list-style:none; cursor:pointer; display:inline-flex;
padding:4px 10px;" aria-label="Actiuni pentru {{ acct.name }}">&#8943;</summary>
<div class="kebab-menu">
{% for v in row_verbs %}
{% set label, action, cls = VERBS[v] %}
{# Confirm fara nume interpolat: un apostrof in numele firmei (free-form) ar rupe
string-ul JS din atributul inline (entitatea &#39; e decodata inainte de parse). #}
<form method="post" action="{{ action }}"
{% if v == 'delete' %}onsubmit="return confirm('Stergi acest cont? (stergere soft)');"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit" {% if cls == 'danger' %}style="color:var(--err);"{% endif %}>{{ label }}</button>
</form>
{% endfor %}
</div>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont.</p>
{% endif %}
</div>
{% endmacro %}
<style>
/* Bara de actiuni bulk — ascunsa pana exista selectie. `[hidden]` trebuie sa invinga
display-ul, deci stilul sta in CSS (NU inline cu display:flex, care ar invinge [hidden]). */
.bulk-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px;
padding:8px 10px; border:1px solid var(--line); border-radius:8px;
background:color-mix(in srgb, var(--accent) 8%, var(--card)); }
.bulk-bar[hidden] { display:none; }
/* Kebab per-rand (reuseaza estetica meniului de cont) */
.kebab { position:relative; display:inline-block; }
.kebab > summary::-webkit-details-marker { display:none; }
.kebab-menu { position:absolute; right:0; top:calc(100% + 4px); min-width:140px; z-index:40;
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
.kebab[open] > summary { background:var(--line); }
.kebab-menu form { margin:0; }
.kebab-menu button { display:block; width:100%; text-align:left; background:transparent; border:none;
color:var(--ink); font:inherit; padding:7px 10px; border-radius:6px; cursor:pointer;
min-height:36px; }
.kebab-menu button:hover { background:var(--line); }
</style>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
<h2 style="margin:0;">Panou admin</h2>
<a href="/" class="cardlink muted">Inapoi la dashboard</a>
@@ -10,96 +108,45 @@
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
{% endif %}
<!-- Conturi in asteptare -->
<div class="card">
<h3 style="margin-top:0;">Conturi in asteptare ({{ pending|length }})</h3>
{% if pending %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in pending %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/activate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit">Activeaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont in asteptare.</p>
{% endif %}
</div>
{{ lifecycle_block("Conturi in asteptare", pending, "pending",
['activate', 'block', 'archive', 'delete'],
['activate', 'block', 'archive', 'delete']) }}
<!-- Conturi active -->
<div class="card">
<h3 style="margin-top:0;">Conturi active ({{ active|length }})</h3>
{% if active %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in active %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/deactivate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit" style="background:var(--err);border-color:var(--err);">Dezactiveaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont activ (in afara de contul dev).</p>
{% endif %}
</div>
{{ lifecycle_block("Conturi active", active, "active",
['block', 'archive', 'delete'],
['block', 'archive', 'delete']) }}
<!-- Contul dev default (id=1) -->
{% if default_account %}
<div class="card" style="border-color:var(--muted);">
<p class="muted" style="margin:0;font-size:13px;">
Cont dev implicit (id=1): <strong>{{ default_account.name }}</strong>
— activ={{ default_account.active }} — fara buton de activare/dezactivare (cont de sistem).
</p>
</div>
{% endif %}
{# Conturi suspendate (blocate/arhivate): reactivare sau stergere. Stare reala in pill. #}
{{ lifecycle_block("Conturi blocate / arhivate", suspended, "suspended",
['activate', 'delete'],
['activate', 'delete']) }}
<script>
(function() {
// Selectie + bara bulk, scoped pe fiecare bloc (pending/active) prin data-block.
document.querySelectorAll('.master-check').forEach(function(master) {
var block = master.getAttribute('data-block');
var rows = Array.prototype.slice.call(
document.querySelectorAll('.row-check[data-block="' + block + '"]'));
var form = document.getElementById('bulk-' + block);
var bar = form ? form.querySelector('.bulk-bar') : null;
var count = form ? form.querySelector('.bulk-count') : null;
function refresh() {
var n = rows.filter(function(r) { return r.checked; }).length;
if (bar) bar.hidden = (n === 0);
if (count) count.textContent = n + ' selectate';
master.checked = (n > 0 && n === rows.length);
master.indeterminate = (n > 0 && n < rows.length);
}
master.addEventListener('change', function() {
rows.forEach(function(r) { r.checked = master.checked; });
refresh();
});
rows.forEach(function(r) { r.addEventListener('change', refresh); });
refresh();
});
})();
</script>
{% endblock %}

View File

@@ -13,9 +13,26 @@
// useTemplateFragments parseaza tot intr-un <template>, pastrand rand + OOB impreuna.
htmx.config.useTemplateFragments = true;
</script>
<script>
// Anti-FOUC (US-001 PRD 5.3): citeste preferinta tema din localStorage inainte de
// primul paint; seteaza data-theme pe <html> sincron, fara blink dark->light.
(function() {
try {
var t = localStorage.getItem('theme');
if (!t) {
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', t);
} catch(e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<style>
:root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#3ecf8e; --warn:#e6b34a; --err:#e5605e; --accent:#5b8def; }
[data-theme="light"] { --bg:#f6f7f9; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#2563eb; }
* { box-sizing:border-box; }
body { margin:0; font:15px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
@@ -24,7 +41,7 @@
header .env { font-size:12px; color:var(--muted); border:1px solid var(--line); padding:2px 8px; border-radius:99px; }
main { padding:24px; max-width:1100px; margin:0 auto; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:16px 20px; margin-bottom:16px; }
.banner { border-left:3px solid var(--err); background:#241a1a; }
.banner { border-left:3px solid var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); }
.banner.hidden { display:none; }
/* Tabelele de date au multe coloane; pe ecrane inguste scroll IN card, nu
impinge layout-ul paginii (altfel toata pagina scrolleaza orizontal). */
@@ -46,7 +63,7 @@
text-align:center; transition:border-color .15s,background .15s; }
.drop-zone.drag-over { border-color:var(--accent); background:rgba(91,141,239,.05); }
/* Banner varianta warn (nu eroare) */
.banner.warn { border-left-color:var(--warn); background:#201c0f; }
.banner.warn { border-left-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); }
/* Bara confirmare sticky */
.sticky-bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--line);
padding:12px 16px; display:flex; align-items:flex-start; gap:16px;
@@ -59,7 +76,7 @@
.cardlink { font-size:13px; padding:7px 10px; border-radius:6px; display:inline-flex;
align-items:center; min-height:36px; white-space:nowrap; }
.cardlink:hover { background:var(--line); }
.flash { background:#16241c; border-left:3px solid var(--ok); padding:8px 12px; border-radius:6px;
.flash { background:color-mix(in srgb, var(--ok) 12%, var(--card)); border-left:3px solid var(--ok); padding:8px 12px; border-radius:6px;
margin:0 0 12px; font-size:13px; }
.maprow { display:flex; gap:16px; align-items:center; padding:12px 0; border-bottom:1px solid var(--line);
flex-wrap:wrap; }
@@ -86,14 +103,134 @@
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; }
/* Meniu hamburger cont (US-006 PRD 5.5) — dropdown ancorat dreapta-sus */
.cont-menu-wrap { position:relative; }
.icon-btn { background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer;
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
.icon-btn:hover { background:var(--line); }
.cont-menu { position:absolute; right:0; top:calc(100% + 8px); min-width:180px; z-index:50;
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
.cont-menu[hidden] { display:none; }
.cont-menu a, .cont-menu button { display:block; width:100%; text-align:left; background:transparent;
border:none; color:var(--ink); text-decoration:none; font:inherit; padding:8px 10px;
border-radius:6px; cursor:pointer; min-height:36px; }
.cont-menu a:hover, .cont-menu button:hover { background:var(--line); }
.cont-menu hr { border:none; border-top:1px solid var(--line); margin:4px 0; }
.cont-menu form { margin:0; }
</style>
</head>
<body>
<header>
<h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span>
<span class="muted" style="margin-left:auto; font-size:13px;">v{{ version }}</span>
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
<button id="tema-toggle" class="icon-btn"
aria-label="Comuta tema (luminos/intunecat)"
title="Comuta tema">&#9728;</button>
<span class="muted" style="font-size:13px;">v{{ version }}</span>
{% if is_authenticated|default(false) %}
{# Meniu cont (US-006 PRD 5.5): Cont/Integrare/Nomenclator + (admin) + logout.
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
<div class="cont-menu-wrap">
<button id="cont-menu-toggle" class="icon-btn"
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
<a role="menuitem" href="/?tab=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</a>
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Panou admin</a>{% endif %}
<hr>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('') }}">
<button role="menuitem" type="submit">Iesi din cont</button>
</form>
</div>
</div>
{% endif %}
</div>
</header>
<main>{% block content %}{% endblock %}</main>
<script>
// Handler comutator tema (US-002 PRD 5.3): click toggle light<->dark, persista in localStorage.
// Separare init (doar sincronizare iconita) de persistenta (doar la click explicit).
// Motivatie: scrierea in localStorage la init ar ingloba imediat preferinta OS-aware ca alegere
// explicita, impiedicand urmarirea ulterioara a modificarilor de tema ale sistemului de operare.
(function() {
var btn = document.getElementById('tema-toggle');
if (!btn) return;
// Sincronizeaza iconita si aria-label dupa tema curenta -- FARA efecte secundare in localStorage.
function _syncIcon(t) {
if (t === 'light') {
btn.innerHTML = '&#9790;';
btn.setAttribute('aria-label', 'Comuta tema (intunecat)');
btn.title = 'Comuta tema (intunecat)';
} else {
btn.innerHTML = '&#9728;';
btn.setAttribute('aria-label', 'Comuta tema (luminos)');
btn.title = 'Comuta tema (luminos)';
}
}
// Aplica o tema noua, seteaza data-theme si persista in localStorage -- apelat DOAR la click.
function _setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('theme', t); } catch(e) {}
_syncIcon(t);
}
// La init: sincronizeaza doar iconita din data-theme curent (setat deja de scriptul anti-FOUC).
_syncIcon(document.documentElement.getAttribute('data-theme') || 'dark');
btn.addEventListener('click', function() {
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
_setTheme(cur === 'dark' ? 'light' : 'dark');
});
})();
</script>
<script>
// Meniu cont (US-006 PRD 5.5): dropdown ancorat dreapta-sus. Deschide/inchide la click,
// inchide la Esc (focus readus pe buton) si la click in afara. Fara dependente.
(function() {
var toggle = document.getElementById('cont-menu-toggle');
var menu = document.getElementById('cont-menu');
if (!toggle || !menu) return;
function open() {
menu.hidden = false;
toggle.setAttribute('aria-expanded', 'true');
document.addEventListener('click', onDocClick, true);
document.addEventListener('keydown', onKey, true);
}
function close(refocus) {
menu.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', onDocClick, true);
document.removeEventListener('keydown', onKey, true);
if (refocus) toggle.focus();
}
function onDocClick(e) {
if (!menu.contains(e.target) && e.target !== toggle) close(false);
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); close(true); }
}
toggle.addEventListener('click', function(e) {
e.stopPropagation();
if (menu.hidden) open(); else close(false);
});
})();
</script>
</body>
</html>

View File

@@ -1,14 +1,8 @@
{% extends "base.html" %}
{% block content %}
<!-- Nav cont: link admin (doar pentru admini) + logout -->
<div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px; flex-wrap:wrap;">
{% if is_admin %}<a class="cardlink" href="/admin">Panou admin</a>{% endif %}
<form method="post" action="/logout" style="display:inline; margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--muted); border-color:var(--line);">Iesi din cont</button>
</form>
</div>
{# US-007 (5.5): nav-ul ad-hoc (Panou admin + logout) a fost mutat in meniul de cont (☰)
din header (base.html). Aici raman doar bara de status + tab-bar-ul de lucru zilnic. #}
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
<div id="status-bar" class="status-bar card"
@@ -20,14 +14,12 @@
<!-- Tab-bar: navigare intre sectiuni -->
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
{# US-003 (3.6): tab-ul "Trimiteri" (coada) a fost eliminat — Trimiterile traiesc
ca sectiune permanenta pe Acasa. Raman: Acasa·Mapari·Cont·Nomenclator·Integrare. #}
{# US-007 (5.5): tab-bar redus la suprafetele de LUCRU ZILNIC (Acasa·Mapari).
Cont/Integrare/Nomenclator s-au mutat in meniul de cont (☰) din header — rutele
`/_fragments/{cont,integrare,nomenclator}` + deep-link `?tab=` raman valide. #}
{% set tabs = [
("acasa", "Acasa", "tab-acasa"),
("mapari", "Mapari", "tab-mapari"),
("cont", "Cont", "tab-cont"),
("nomenclator", "Nomenclator", "tab-nomenclator"),
("integrare", "Integrare", "tab-integrare")
("mapari", "Mapari", "tab-mapari")
] %}
{% for tab_id, tab_label, tab_elem_id in tabs %}
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}

View File

@@ -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
@@ -144,7 +145,9 @@ def claim_one(conn) -> dict | None:
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
"WHERE s.status='queued' "
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
"AND COALESCE(a.active, 1) = 1 "
# Gate pe stare de cont (5.5): doar 'active' trimite. Derivam defensiv din `active`
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
"AND COALESCE(a.status, CASE WHEN COALESCE(a.active,1)=1 THEN 'active' ELSE 'pending' END) = 'active' "
"ORDER BY s.id LIMIT 1",
(_iso(_now()),),
).fetchone()
@@ -200,7 +203,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 +416,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

File diff suppressed because one or more lines are too long

View File

@@ -175,6 +175,127 @@ Aplicate deja pe ambele medii (test + producție):
Acestea devin reguli Pydantic exacte în `app/api`. Validează la gateway înainte de enqueue
(stare `needs_data`) ca nu primești 4xx de la RAR.
## Envelope de eroare imbogatit (PRD 5.4)
### Forma unui obiect de eroare
Incepand cu PRD 5.4, fiecare obiect de eroare returnat de gateway contine **6 chei**:
| Cheie | Tip | Rol | Back-compat |
|---|---|---|---|
| `field` | string \| null | Campul care a generat eroarea (null daca eroarea e globala) | DA existent anterior |
| `message` | string | Mesajul scurt (identic cu `cauza` cand e disponibila, altfel `problema`) | DA existent anterior |
| `cod` | string | Identificator stabil de tip eroare (ex. `VIN_FORMAT`). Camp nou. | NU adaugat 5.4 |
| `problema` | string | Ce s-a intamplat descriere scurta, inteligibila pentru utilizator | NU adaugat 5.4 |
| `cauza` | string | De ce a aparut eroarea concret; pentru erorile RAR 400, mesajul exact de la RAR (passthrough) | NU adaugat 5.4 |
| `fix` | string | Ce trebuie facut pentru remediere | NU adaugat 5.4 |
**Exemplu JSON concret** (eroare VIN invalid, returnat de `POST /v1/prezentari/valideaza`):
```json
{
"field": "vin",
"message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
"cod": "VIN_FORMAT",
"problema": "VIN invalid",
"cauza": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.",
"fix": "Verifica VIN-ul pe talon (pozitia E) sau pe caroserie: exact 17 caractere majuscule, fara spatii si fara literele O, I, Q."
}
```
### Nota de back-compat
Cheile `field` si `message` sunt **pastrate neschimbate** pe toate raspunsurile. Cheile `cod`, `problema`, `cauza`, `fix` sunt **aditive** (camp nou in plus). Clientii care citesc doar `field`/`message` (sau `error`/`message` la import) continua sa functioneze fara modificare.
### Unde apare envelope-ul imbogatit
**1. `POST /v1/prezentari/valideaza` (dry-run)**
Campul `erori` (array) si campul `nemapate` (array) din raspuns contin obiecte cu toate cele 6 chei.
**2. `submissions.rar_error` (stocat in DB, vizibil prin `GET /v1/prezentari/{id}` si in dashboard)**
Campul `rar_error` e superset al formei de mai sus si variaza cu starea submission-ului:
- `needs_data` array de obiecte `{field, message, cod, problema, cauza, fix}`:
```json
[
{
"field": "dataPrestatie",
"message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
"cod": "RAR_VALIDARE",
"problema": "RAR a respins prezentarea",
"cauza": "Data prestatiei nu poate fi anterioara datei de 01.12.2024.",
"fix": "Corecteaza campul semnalat de RAR (vezi cauza) si reincearca; detaliile exacte sunt in mesajul tehnic RAR."
}
]
```
- `needs_mapping` (cod nemapat): obiect cu cheile `unmapped` (array), `cod`, `problema`, `cauza`, `fix`:
```json
{
"unmapped": ["SCHIMB_ULEI_COMPLET"],
"cod": "COD_NEMAPAT",
"problema": "Lipseste codul RAR al operatiei",
"cauza": "Operatia SCHIMB_ULEI_COMPLET nu are un cod RAR mapat.",
"fix": "Alege codul RAR pentru aceasta operatie in tab-ul Mapari (ai sugestii automate)."
}
```
- `needs_mapping` cu `auto_send` oprit: obiect cu `auto_send`, `cod: "AUTO_SEND_OPRIT"`, `problema`, `cauza`, `fix`.
- Eroare RAR 400: array imbogatit cu `cod: "RAR_VALIDARE"` pe fiecare element.
- Eroare RAR 401 (creds invalide): obiect cu `cod: "RAR_CREDS_INVALIDE"`, `problema`, `cauza`, `fix`.
**3. Erori de import (`POST /v1/import`, preview, commit)**
Campul `detail` din raspunsurile de eroare este superset: contine cheile vechi `error`/`message` plus `cod`, `problema`, `cauza`, `fix`.
**Exceptii din scope 5.4**: erorile de login/signup si CSRF raman mesaje plate (fara envelope imbogatit).
### Tabel cod → problema / fix (toate codurile din `app/errors.CATALOG`)
#### Validare date prestatie
| Cod | Problema | Fix |
|---|---|---|
| `VIN_FORMAT` | VIN invalid | 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` | Numar de inmatriculare invalid | Foloseste doar litere si cifre majuscule, maxim 10 caractere, fara spatii sau cratima (ex. B123ABC). |
| `DATA_FORMAT` | Data prestatiei in format gresit | Scrie data ca AAAA-LL-ZZ (ex. 2026-06-22). |
| `DATA_PREA_VECHE` | Data prestatiei prea veche | RAR accepta prestatii doar incepand cu 01.12.2024; verifica data prestatiei. |
| `DATA_VIITOR` | Data prestatiei in viitor | Data prestatiei nu poate fi dupa ziua de azi; corecteaza data. |
| `ODOMETRU_FINAL_FORMAT` | Odometru final invalid | Scrie kilometrajul final ca numar intreg, fara zecimale sau text (ex. 145000). |
| `ODOMETRU_INITIAL_LIPSA` | Lipseste odometrul initial | Prestatiile R-ODO / I-ODO cer kilometrajul initial; completeaza-l. |
| `ODOMETRU_INITIAL_FORMAT` | Odometru initial invalid | Scrie kilometrajul initial ca numar intreg, fara zecimale sau text. |
| `ODOMETRU_INITIAL_ORDINE` | Odometru initial mai mare decat finalul | Kilometrajul initial trebuie sa fie mai mic sau egal cu cel final; verifica cele doua valori. |
| `PRESTATII_GOALE` | Nicio prestatie | Adauga cel putin o prestatie cu cod RAR valid. |
| `B64_INVALID` | Imaginea nu este base64 valid | Trimite imaginea codata base64 corect, sau omite campul daca nu ai imagine. |
#### Mapare operatie
| Cod | Problema | Fix |
|---|---|---|
| `COD_NEMAPAT` | Lipseste codul RAR al operatiei | Alege codul RAR pentru aceasta operatie in tab-ul Mapari (ai sugestii automate). |
| `AUTO_SEND_OPRIT` | Necesita confirmare manuala | Codul e mapat cu trimitere automata oprita; verifica randul si pune-l manual in coada. |
#### Erori RAR (raspuns live de la RAR)
| 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_CREDS_INVALIDE` | Credentiale RAR invalide | Verifica email-ul si parola contului RAR in tab-ul Cont; trimiterea nu se reincearca automat la credentiale gresite. |
#### Import fisier
| Cod | Problema | Fix |
|---|---|---|
| `IMPORT_FISIER_PREA_MARE` | Fisier prea mare | Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand. |
| `IMPORT_ANTET_NECLAR` | Antet de coloane neclar | Asigura-te ca primul rand contine numele coloanelor (ex. VIN, Numar, Data). |
| `IMPORT_ENCODING` | Codare de caractere nesuportata | Salveaza fisierul ca CSV UTF-8 (sau xlsx) si reincarca. |
| `IMPORT_FISIER_NERECUNOSCUT` | Fisier nerecunoscut | Incarca un fisier .xlsx sau .csv valid. |
| `IMPORT_MULTIPLE_SHEETS` | Mai multe foi in fisier | Pastreaza datele intr-o singura foaie sau alege foaia de import. |
| `IMPORT_FARA_MAPARE_COLOANE` | Coloanele nu sunt mapate | Mapeaza intai coloanele fisierului la campurile cerute, apoi continua. |
| `IMPORT_CONFIRMARE_GRESITA` | Numar confirmat gresit | Numarul confirmat difera de randurile gata de trimis; verifica preview-ul si reconfirma. |
| `IMPORT_OVERRIDE_ILIZIBIL` | Editarea anterioara nu se poate citi | Editarea salvata este ilizibila (probabil cheia s-a schimbat); reediteaza randul. |
| `COLOANE_FORMAT_JSON` | Format de coloane (JSON) invalid | Verifica sintaxa JSON a maparii de coloane (ghilimele duble, acolade inchise corect). |
## Nomenclator prestații (18 coduri, verificat live 2026-06-15)
| cod | nume |

View File

@@ -0,0 +1,277 @@
# Design 5.5 — Uniformizare & standardizare UI/UX
**Stare**: aprobat (decizii utilizator 2026-06-23, vezi §10)
**Context**: dashboard web HTMX (`app/web/templates/`), paleta dark/light deja livrata (5.3),
erori 3-niveluri (5.4). Acest document = sursa de adevar **vizuala** pentru PRD 5.5. Unde PRD-ul
descrie *ce* livram pe stories, aici descriem *cum arata* si *de ce*.
> Nu reinventam estetica. Paleta, tipografia si tokenii din `base.html` (5.3) raman **NESCHIMBATI
> la octet**. Standardizarea = aducem toate tabelele si paginile la acelasi vocabular de componente
> care exista deja in tabelul Trimiteri (`_submissions.html`), tabelul considerat corect de referinta.
---
## 1. Problema (audit pe codul real)
Inventar al neuniformitatii curente:
| Suprafata | Simptom | Referinta corecta |
|-----------|---------|-------------------|
| Tabel **Mapari** (`_mapari.html`) | Labartat: coloana "Punere in coada" injecteaza prin macro `autosend_toggle` 3 randuri de text explicativ pe **fiecare** linie → randuri inalte, butoanele **Salveaza/Sterge** ies din viewport, trebuie scroll orizontal | grila Trimiteri |
| Tabel **Nomenclator** (`_nomenclator.html`) | Functional dar minim; nu imparte exact acelasi aspect/hover/aliniere cu Trimiteri | grila Trimiteri |
| **Acasa** (`_acasa.html`) | Sectiune "Ajutor: Mapari / Coduri RAR" redundanta (wayfinding repetat) | — (se elimina) |
| **Navigare** | Cont, Integrare, Nomenclator stau ca tab-uri amestecate cu lucrul zilnic; logout + link admin sunt agatate ad-hoc in coltul dreapta-sus al dashboard-ului, absente pe alte pagini | meniu de cont dedicat |
| **Panou admin** (`admin.html`) | Conturile in asteptare au doar "Activeaza" per-rand; lipsesc selectie multipla si actiunile blocare/arhivare/stergere. Nota "Cont dev implicit" e jargon intern nederivabil | tabel cu selectie + bara bulk |
Principiu de standardizare: **un singur tabel, o singura componenta de antet de sectiune, un singur
loc pentru ajutor** (link/disclosure, nu text inline repetat pe randuri).
---
## 2. Design tokens (existenti — se reutilizeaza, nu se modifica)
Din `base.html` (`:root` dark + `[data-theme="light"]`). Citat aici doar ca referinta; **nicio
valoare noua de culoare**. Orice suprafata noua foloseste `color-mix(... var(--card))` pentru stari
(lectia 5.3: zero literali hardcodati, altfel se sparge light mode).
```
--bg --card --ink --muted --line
--ok (verde) --warn (chihlimbar) --err (rosu) --accent (albastru)
```
Spacing: cardurile 16-20px padding; celule tabel `8px 10px`; gap-uri 6/8/12/16px (scara existenta).
Radius: 6px controale, 10px carduri, 99px pill-uri. Tipografie: tabel 14px `tabular-nums`,
antet `th` 12px uppercase `--muted`. **Nu introducem fonturi sau marimi noi.**
---
## 3. Componenta canonica: Tabelul standard
Tabelul Trimiteri defineste contractul. Orice tabel din aplicatie il respecta:
```
.tablewrap > table
thead th -> 12px uppercase, color --muted, font-weight 500, white-space nowrap
tbody td -> 14px, padding 8px 10px, border-bottom 1px var(--line), nowrap implicit
stare -> <span class="pill {s-*}">{text uman}</span> (glifa+text, nu doar culoare)
coloana lunga (motiv) -> white-space:normal; max-width:280px (singura exceptie de la nowrap)
empty state -> .empty (centrat, --muted, cu CTA contextual)
```
Reguli care fac diferenta vizibila fata de "labartat":
1. **Coloanele de control sunt inguste si nowrap.** Niciun text explicativ in celule. Explicatiile
traiesc o singura data, in antetul cardului (link "Ajutor") sau intr-un `<details>`.
2. **Actiunile incap fara scroll orizontal.** Coloana "Actiuni" la dreapta, `white-space:nowrap`,
butoane scurte. Pe ecrane inguste scroll-ul ramane IN card (`.tablewrap`), nu in pagina.
3. **Densitate constanta.** Inaltimea randului = o linie de text + padding. Sub-text (ex. "2 blocate",
"acum: COD") merge in `<div class="muted" style="font-size:12px">` sub valoarea principala, nu
pe coloana separata.
### 3.1 Antet de sectiune standard (cu Ajutor)
```
+--------------------------------------------------------------+
| De rezolvat [ Ajutor ] | <- h2 15px la stanga, link la dreapta
+--------------------------------------------------------------+
```
`Ajutor` = link discret `.cardlink` care comuta un `<details>`/panou de text (vezi §5). Mutam acolo
toata proza care azi se repeta pe randuri. Un singur loc, citit la nevoie.
---
## 4. Tabelul Mapari — inainte / dupa
### Inainte (labartat)
Fiecare rand din "De rezolvat" si "Mapari salvate" poarta `autosend_toggle`, care randeaza:
- "La fisierele viitoare cu aceasta operatie:" (12px)
- checkbox + **"Pune automat in coada"**
- "Nebifat = «Tine pentru verificare». Doar pentru aceasta operatie; nimic nu pleaca la RAR..." (11px)
x N randuri. Coloana e mai lata decat selectul de cod; Salveaza/Sterge sunt impinse afara.
### Dupa (compact, ca Trimiteri)
```
De rezolvat [ Ajutor ]
-----------------------------------------------------------------
OPERATIE SUGESTII COD RAR IN COADA ACTIUNI
RevTehBP A012 (88%) [ A012 v ] (o) Auto [Salveaza]
2 blocate ( ) Manual
-----------------------------------------------------------------
```
- Coloana **IN COADA** = comutator scurt cu doua stari etichetate **Auto** / **Manual** (radio sau
switch), fara nicio propozitie. Tooltip pe control: "Auto = pune automat in coada la fisierele
viitoare cu aceasta operatie; Manual = tine pentru verificare."
- Explicatia completa (de ce exista maparile, ce inseamna Auto vs Manual, ce e blocat) → in panoul
**Ajutor** din antet, scris o singura data.
- **Invariant backend pastrat**: controlul emite tot `name="auto_send" value="true"` cu semantica de
prezenta (bifat→true, absent→false), exact ca azi. Zero atingere backend (lectia 5.3/3.6:
reskin la nivel de macro, parserele `/mapari` si `/_import/.../mapare-operatie` raman valide).
- "Mapari salvate" si "Formate de coloane" → aceeasi grila; sub-textul ("acum: COD — nume",
"N coloane", maparea coloana→camp) ramane `muted` 12px sub valoare, nu pe coloane separate verbose.
Macro-ul `autosend_toggle` se rescrie compact (acelasi `name`/`form`/`checked`), deci se schimba
intr-un singur loc si se propaga si in fluxul de import (mapcoloane) unde e refolosit.
---
## 5. Panoul Ajutor (mapari)
Un `<details>` nativ in antetul cardului "De rezolvat" (sau link care expandeaza acelasi `<details>`),
inchis implicit, fara JS:
```
Ajutor (v)
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
- Operatii necunoscute raman blocate in needs_mapping si NU pleaca la RAR pana le mapezi.
- Sugestiile (%) vin din potrivire fuzzy pe denumire — verifica-le inainte sa salvezi.
- In coada: Auto = la urmatoarele fisiere cu aceasta operatie, randurile intra automat in coada.
Manual = raman pentru verificare; nimic nu pleaca la RAR pana confirmi tu.
- La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
```
Avantaj: text scris o data, accesibil, fara cost de inaltime pe fiecare rand. `<details>` =
accesibil din tastatura nativ, fara dependente.
---
## 6. Navigare: meniu hamburger (decizie: dropdown ancorat dreapta-sus)
### 6.1 Header
```
[Gateway RAR AUTOPASS] [test] [☀] v1.0 [☰]
|
+------------------+
| Cont |
| Integrare |
| Nomenclator |
| Panou admin | <- doar admin
|------------------|
| Iesi din cont | <- form POST /logout
+------------------+
```
- Iconita `☰` (`min 36x36`, `aria-label="Meniu cont"`, `aria-expanded`, `aria-controls`) langa toggle-ul
de tema. Dropdown ancorat sub iconita, aliniat la dreapta. **Fara overlay** pe pagina.
- Inchidere: click in afara, `Esc`, sau selectarea unui element. Focus trap minimal: `Esc` readuce
focusul pe `☰`. Navigare cu sageti optionala (consistent cu pattern-ul tab existent), dar `Tab`
natural e suficient.
- Continut **dependent de autentificare** (vezi §6.3).
### 6.2 Tab-bar dupa mutare (decizie: doar Acasa · Mapari)
```
[ Acasa ] [ Mapari ]
```
Cont, Integrare, Nomenclator parasesc tab-bar-ul → meniul `☰`. Raman doar cele doua suprafete de
**lucru zilnic**. Badge-urile de contoare (Mapari) raman pe tab. Deep-link `?tab=` si rutele
`/_fragments/{cont,integrare,nomenclator}` raman valide (accesate acum din meniu, nu din tab-bar) —
deci zero rute moarte, doar punctul de intrare se muta.
### 6.3 Stare de autentificare in header
`base.html` e partajat de `login.html`, `signup.html`, `dashboard.html`, `admin.html`. Meniul trebuie
sa stie daca esti logat:
- **Autentificat**: arata Cont, Integrare, Nomenclator, (Panou admin daca `is_admin`), separator,
"Iesi din cont" (form `POST /logout` cu `csrf_token`).
- **Neautentificat** (login/signup): meniul arata doar "Autentificare" / "Inregistrare" (sau iconita
`☰` ascunsa pe aceste pagini — vezi PRD US). Niciun link de cont, niciun logout.
Necesita ca `base.html` sa primeasca `is_authenticated`, `is_admin`, `csrf_token` in context. Se
adauga ca un helper de context partajat (un singur loc), nu duplicat in fiecare render. Acesta e
singurul "backend touch" din zona de navigare si trebuie sa fie aditiv si defensiv (lipsa cheilor →
meniu in stare neautentificata, nu eroare).
---
## 7. Panou admin: selectie + actiuni bulk
### 7.1 Tabel conturi in asteptare (si analog conturi active)
```
[v] Selecteaza tot 2 selectate:
[Activeaza] [Blocheaza] [Arhiveaza] [Sterge] <- bara bulk, apare la selectie
-----------------------------------------------------------------------
[v] ID COMPANIE CUI EMAIL INREGISTRAT ACTIUNI
[v] 7 Auto SRL RO123 a@b.ro 12.06.2026 [ ... ]
[ ] 8 Moto SA RO456 c@d.ro 13.06.2026 [ ... ]
-----------------------------------------------------------------------
```
- Coloana de **checkbox** la stanga + un master "selecteaza tot" in antet.
- **Bara de actiuni bulk** ascunsa pana exista o selectie; afiseaza numarul selectat si butoanele
contextuale. Actioneaza pe toate randurile bifate (POST cu lista de `account_id`).
- **Actiuni per-rand** in meniul `[ ... ]` (kebab): aceleasi verbe, pentru o singura tinta.
- Verbele, ca stari de cont distincte (vezi §7.2): **Activeaza, Blocheaza, Arhiveaza, Sterge**.
- `Sterge` = actiune distructiva → `hx-confirm` / dialog de confirmare obligatoriu.
- `Blocheaza`/`Arhiveaza` reversibile → confirmare doar pe bulk (cantitate).
- Stari vizuale ale verbelor: distructiv (`Sterge`) cu `color:var(--err)`; restul neutre `.cardlink`.
### 7.2 Model de stare cont (impact backend — vezi PRD riscuri)
Azi: `accounts.active` (0/1); "pending" = inregistrat dar `active=0`. Cele 4 verbe cer stari
distincte care nu incap intr-un bool. Propunere (PRD o ratifica): coloana `accounts.status`
TEXT, migrare defensiva, derivata din `active` la prima rulare:
```
pending -> inregistrat, neactivat inca (active=0, status nesetat istoric)
active -> operational (active=1)
blocked -> suspendat reversibil (nu logheaza, worker nu trimite)
archived -> ascuns din liste, date pastrate (read-only)
deleted -> stergere (GDPR/L.142) (hard delete SAU soft cu purge)
```
- Worker `claim_one` gate-uieste pe **status='active'** (azi pe `COALESCE(active,1)=1`) — schimbare
semantica de pastrat compatibila: `active=1 ⇔ status='active'`.
- **Contul dev id=1 e protejat** de Blocheaza/Arhiveaza/Sterge (cont de sistem), exact ca azi la
activate/deactivate. Daca e selectat in bulk, e sarit, nu eroare.
- Nota "Cont dev implicit (id=1)" din pagina **se elimina** (jargon intern, nederivabil de operator).
Protectia ramane in cod, nu o explicam in UI.
> Aceasta e singura zona cu schema/backend real. Restul livrabilei e UI pur (reskin + reasezare).
---
## 8. Accesibilitate & paritate tema
- **AA pe light+dark** pentru orice text nou (lectia 5.3: verzi/rosii hardcodate cad sub AA). Stari
doar prin `color-mix(... var(--card))`, niciun literal.
- Stare = **glifa + text**, nu doar culoare (pill-urile existente respecta deja).
- Meniul `☰`: `aria-expanded`, `aria-controls`, inchidere pe `Esc`, focus readus pe trigger.
- `<details>` Ajutor: accesibil nativ din tastatura.
- Checkbox-uri admin: `aria-label` per rand ("Selecteaza contul {companie}"); master = "Selecteaza tot".
- Toate controalele >=36px zona de atins (consistent cu toggle tema / `.cardlink`).
## 9. Motiune
Minima, consistenta cu existentul: dropdown `☰` fade/translate scurt (~120ms, ca `.tab-link`
transition). `<details>` = comportament nativ. Fara animatii noi de amploare. Respecta
`prefers-reduced-motion` daca adaugam tranzitii (omitere la cerere).
## 10. Decizii utilizator (2026-06-23)
1. Meniu hamburger = **dropdown ancorat dreapta-sus** (nu drawer).
2. Tab-bar = **Acasa · Mapari**; Nomenclator + Cont + Integrare + Panou admin → meniul `☰`.
3. Mapari = **grila compacta ca Trimiteri**, toggle scurt **Auto/Manual**, link **Ajutor** in antet;
textul repetat de pe randuri se elimina.
4. Admin = **selectie cu bife + bara de actiuni bulk** (Activeaza/Blocheaza/Arhiveaza/Sterge) +
actiuni per-rand; nota "cont dev implicit" **eliminata**.
5. Sectiunea "Ajutor" de pe Acasa se **elimina**.
6. Nomenclator capata exact aspectul tabelului Trimiteri.
## 11. Componente atinse (harta pentru PRD)
| Componenta | Fisier | Tip schimbare |
|------------|--------|---------------|
| Header + meniu `☰` + context auth | `base.html` (+ context render rute) | reasezare + mic backend context |
| Tab-bar redus | `dashboard.html` | reasezare |
| Acasa fara Ajutor | `_acasa.html` | stergere |
| Mapari standard + toggle compact + Ajutor | `_mapari.html`, `_macros.html` | reskin UI (zero backend) |
| Nomenclator ca Trimiteri | `_nomenclator.html` | reskin UI |
| Admin selectie + bulk + verbe noi | `admin.html` + rute `/admin/*` | UI + **backend (status)** |
| Model stare cont | `schema.sql`, `users.py`, worker gate | **backend + migrare** |

View File

@@ -0,0 +1,159 @@
# PRD 5.2 — Endpoint dry-run `POST /v1/prezentari/valideaza`
**Stare**: inchis
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
## 1. Obiectiv
Un endpoint care valideaza un payload de prezentari **exact ca** `POST /v1/prezentari`, dar
**fara enqueue si fara efecte secundare**: intoarce verdictul (queued / needs_data / needs_mapping),
erorile reale `[{field, message}]` si codurile nemapate, ca integratorul (ROAAUTO / soft propriu /
punte VFP) sa-si verifice integrarea inainte sa trimita ceva real. "Magical moment" de onboarding
(Etapa 5 — ergonomie & integrare).
**Invariant de corectitudine (motivul cheie de design):** dry-run-ul trebuie sa produca **acelasi
verdict** pe care l-ar produce `POST /v1/prezentari` pe acelasi payload. Daca cele doua cai diverg,
dry-run-ul minte — mai rau decat sa nu existe. De aceea logica de clasificare se **extrage intr-un
helper pur partajat** folosit de AMBELE endpoint-uri (nu se duplica).
## 2. Non-Goals (anti scope-creep)
- **NU atinge coada `submissions`** — zero INSERT/UPDATE/DELETE; endpoint read-only (citeste doar
`operations_mapping` pentru rezolvarea codurilor, scoped pe cont).
- **NU detectie duplicat / idempotency** — nu raporteaza `idempotency_key` si nu cauta submission-uri
identice existente (decizie utilizator 2026-06-22: doar validare + mapare). `idempotency.py` neatins.
- **NU stocheaza si NU foloseste creds RAR** — `rar_credentials` devine optional si, daca e prezent,
e ignorat (nu se cripteaza, nu se logheaza, nu se trimite nicaieri).
- **NU UI / hub** — documentarea in `/integrare` (5.1) + snippet-uri ramane follow-up (decizie
utilizator 2026-06-22). Acest PRD = strict endpoint + teste.
- **NU atinge worker, masina de stari, schema, validation.py (regulile), nomenclator.**
- **NU apel live la RAR** — validarea e 100% locala (replica regulilor RAR din `validation.py`).
## 3. Stories atomice
### US-001: Endpoint dry-run `POST /v1/prezentari/valideaza`
**Ca** integrator (ROAAUTO / soft propriu / VFP) **vreau** sa trimit un payload de prezentari si sa
primesc inapoi exact erorile + verdictul pe care le-ar produce trimiterea reala, **fara** sa enqueue
ceva sau sa am nevoie de creds RAR, **pentru ca** sa-mi validez integrarea sigur, repetabil, inainte
de prima trimitere reala.
- **Depinde de**: —
- **Fisiere**: `app/mapping.py` (helper pur nou `classify_prezentare`), `app/api/v1/router.py`
(ruta + refactor `create_prezentari` sa foloseasca helper-ul), `app/models.py` (modele request/response),
`tests/test_validare_dryrun.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_validare_dryrun.py`
- `test_payload_valid_returneaza_queued` (valid → `status_estimat="queued"`, `valid=True`, `erori=[]`)
- `test_vin_invalid_returneaza_needs_data` (VIN cu O/I/Q → `needs_data` + eroare pe `field="vin"`)
- `test_data_viitoare_needs_data` (data in viitor → `needs_data`)
- `test_cod_op_nemapat_returneaza_needs_mapping` (cod_op_service necunoscut → `needs_mapping` +
`nemapate=[{cod_op_service, denumire}]`)
- `test_mapare_existenta_rezolva_codul` (cu mapare salvata pe cont → cod_op_service rezolvat in
`prestatii_rezolvate`, `status_estimat="queued"`)
- `test_fara_creds_merge` (body FARA `rar_credentials` → 200)
- `test_nu_scrie_in_coada` (`COUNT(*)` submissions inainte == dupa; zero efecte secundare)
- `test_multi_prezentari_rezultate_per_index` (lista [valid, invalid] → 2 rezultate, `index` 0/1,
statusuri diferite)
- `test_shape_invalid_422` (prestatie fara `cod_prestatie` SI fara `cod_op_service` → 422 de shape,
ca endpoint-ul real; raspunsul NU contine echo de `input`/parola)
- **Acceptance criteria**:
- [ ] `POST /v1/prezentari/valideaza` exista, accepta `{prezentari:[...], rar_credentials?}`,
intoarce `{results:[{index, valid, status_estimat, erori, nemapate, prestatii_rezolvate}]}`.
- [ ] `status_estimat` ∈ {`queued`, `needs_data`, `needs_mapping`} si coincide cu statusul pe care
`POST /v1/prezentari` l-ar atribui pe acelasi payload + aceeasi mapare de cont (verificat prin
helper partajat `classify_prezentare`, folosit de ambele rute).
- [ ] `valid == (status_estimat == "queued")`.
- [ ] `erori` = exact lista `validate_prezentare` (forma `[{field, message}]`), goala cand e curat.
- [ ] `nemapate` = `[{cod_op_service, denumire}]` cand exista coduri interne nerezolvate; altfel `[]`.
- [ ] `prestatii_rezolvate` arata codurile RAR rezolvate (cod_op_service mapat → cod_prestatie umplut).
- [ ] `rar_credentials` optional; daca lipseste → 200; daca e prezent → ignorat (necriptat, nelogat).
- [ ] Zero scriere in DB: `COUNT(*) FROM submissions` neschimbat dupa apel (read-only pe mapping).
- [ ] Scope pe cont prin `resolve_account_id` (ca endpoint-ul real): maparea folosita la rezolvare e
a contului cheii API (dev fara cheie → cont 1; prod → cheie obligatorie).
- [ ] `create_prezentari` (endpoint-ul real) ramane cu comportament identic — toate testele
existente `tests/test_api.py` raman verzi dupa refactor-ul catre helper-ul partajat.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: canal API — `POST /v1/prezentari/valideaza` pe instanta locala cu (a) payload
valid → `queued`, (b) VIN invalid → `needs_data` + mesaj real, (c) cod_op nemapat → `needs_mapping`;
apoi `COUNT(*)` submissions neschimbat. **Regresia de aur:** `POST /v1/prezentari` real → worker →
`FINALIZATA` la RAR test (neatins de endpoint-ul nou).
## 4. Riscuri
- **Divergenta dry-run vs. real** (risc principal) → mitigat prin helper pur partajat `classify_prezentare`
folosit de ambele rute + AC explicit + testele `test_api.py` care lock-uiesc calea reala dupa refactor.
- **Refactor al caii reale** (`create_prezentari` adopta helper-ul) ar putea schimba subtil comportamentul
→ mitigat: testele existente `test_api.py` sunt contractul; raman verzi = comportament identic. Helper-ul
intoarce exact aceleasi `status` + `rar_error` ca azi (queued / needs_data + erori JSON / needs_mapping +
unmapped JSON / needs_mapping + auto_send note).
- **Scurgere de creds** prin noul model → mitigat: `rar_credentials` optional, ignorat, `repr=False`
pastrat; handler-ul global 422 din `main.py` deja dropeaza `input`/`ctx` (no-echo parola) — acoperit de
`test_shape_invalid_422`.
- **Asteptare gresita: "valideaza" = duplicat-check** → documentat ca Non-Goal in raspuns/docstring;
fara `idempotency_key` in raspuns ca sa nu sugereze dedup.
## 5. Intrebari deschise
> Rezolvate cu utilizatorul la poarta de aprobare PRD (2026-06-22).
- **Continut raspuns** — REZOLVAT: doar validare + mapare (fara idempotency/duplicat). [user 2026-06-22]
- **Hub /integrare** — REZOLVAT: amanat; 5.2 = endpoint + teste. [user 2026-06-22]
- `auto_send=0` pe un cod mapat: pe calea reala devine `needs_mapping` cu nota "review manual". Dry-run-ul
raporteaza acelasi `status_estimat="needs_mapping"` (consistenta cu real). Confirmat ca acceptabil —
fara camp separat pentru cazul auto_send (ar fi scope creep). [decizie de plan, acceptata implicit]
## 6. Valuri de executie (graful de dependente)
```
Val 1: [US-001] ← singur story, fara dependente. Un worker (sau lead direct, livrabila mica — ROADMAP §5.5).
```
Livrabila mica: poate rula fara `TeamCreate` (un singur worker Sonnet TDD), dar VERIFY in context curat
+ writeback raman obligatorii (ROADMAP §5.5).
## 7. Review-uri de plan (aplicate inainte de cod — ROADMAP §5.3)
**CEO (valoare/scope) — PASS.** Problema corecta, calea cea mai directa (reuse pur
`validation.py`+`mapping.py`, zero logica de domeniu noua). Inversiune ("ce-l face sa esueze?"):
divergenta dry-run vs. real — neutralizata prin helper-ul partajat `classify_prezentare` +
lock-ul `test_api.py`. Scope minim corect; singura "expansiune" (extragerea clasificatorului) e
ceruta de corectitudine, nu scope creep. **Risc flagat (deferare constienta):** descoperibilitate
— un endpoint pe care integratorii nu-l gasesc nu-si livreaza valoarea pana cand un follow-up il
expune in `/integrare`. Acceptat de utilizator (amanat); valoarea 5.2 se realizeaza la follow-up.
**Eng (fezabilitate/teste) — PASS.** Fezabilitate triviala, fara atingere schema/worker/idempotenta.
Lista de teste e completa (happy path + fiecare ramura de eroare + fara-creds + multi-prezentare cu
index + shape-422 fara echo + aserctia critica zero-efecte `COUNT(*)` inainte==dupa). Singurul risc
pe calea de aur = refactor-ul `create_prezentari` pe helper-ul partajat; contractul de blocare =
suita `test_api.py` existenta (ramane verde = comportament identic). Read-only → fara logging nou
(consistent cu GET-urile existente); in prod cere cheie prin `resolve_account_id` (fara suprafata noua de abuz).
---
## Raport VERIFY
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E pe canal API). Lipseste pana la VERIFY.
Verificator independent (context curat, rol qa-only), 2026-06-22. **VERDICT GLOBAL: PASS.**
**1. Suita — PASS.** `python3 -m pytest -q` → 577 passed (226 warnings preexistente Starlette/templating).
`pytest tests/test_validare_dryrun.py tests/test_api.py -q` → 14 passed (9 dry-run TDD + 5 regresia caii reale).
**2. Acceptance criteria US-001 — toate PASS.** Endpoint exista cu raspunsul `{results:[{index,valid,
status_estimat,erori,nemapate,prestatii_rezolvate}]}`. `status_estimat` coincide cu `POST /v1/prezentari`
prin helper-ul partajat `classify_prezentare` apelat de AMBELE rute (`router.py` create + valideaza, acelasi
`load_mapping_meta`). `valid == (status=="queued")`. `erori`=lista `validate_prezentare`. `nemapate`=
`[{cod_op_service,denumire}]`. `rar_credentials` optional + ignorat (`encrypt_creds` absent din ruta;
parola din body NU apare in raspuns). Zero scriere DB (COUNT(*) submissions=0 dupa 3 apeluri). Scope prin
`resolve_account_id`. `create_prezentari` identic (test_api.py verde).
**3. E2E canal API (TestClient + DB temp, verificare directa SQLite) — PASS.** (a) valid+creds → queued,
valid=true, erori=[], fara leak creds; (b) VIN cu O/I/Q → needs_data + eroare reala pe `vin`; (c) cod_op
nemapat → needs_mapping + nemapate populat. Dupa toate: `GET /v1/prezentari`=0, DB COUNT(*)=0.
**4. Regresia de aur — PASS (live neprobat, conform asteptarii).** `POST /v1/prezentari` enqueue-aza corect
(200, queued, COUNT(*)=1) + test_api.py verde. Flux live RAR (worker→FINALIZATA pe RAR test) NEPROBAT —
lipsesc secretele in mediu (`AUTOPASS_CREDS_KEY` nesetat, fara creds RAR test/`--send`). NU e FAIL al 5.2:
endpoint-ul nou e read-only, nu atinge worker/coada/schema. Documentat, nu inventat.

View File

@@ -0,0 +1,256 @@
# PRD 5.3 — Light/Dark mode (comutator tema persistat)
**Stare**: inchis
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
## 1. Obiectiv
Dashboard-ul web e azi **doar dark** (paleta fixa in `:root`). Adaugam o tema **light** si un
**comutator in header** care persista alegerea utilizatorului. Service-urile care vin din Visual
FoxPro / soft propriu lucreaza des in birouri luminoase si pe monitoare unde dark-mode obositor sau
greu de citit la videoproiector — un toggle light/dark e o cerinta de ergonomie de baza (Etapa 5).
CSS-ul **e deja pe variabile** (`--bg`, `--card`, `--ink`, `--muted`, `--line`, `--ok`, `--warn`,
`--err`, `--accent` in `base.html`). Tema light = un bloc `[data-theme="light"]` care **suprascrie
aceleasi variabile** cu o paleta deschisa. Efort mic, zero logica de domeniu, zero backend.
**Invariant de design (motivul cheie):** comutarea nu trebuie sa **palpaie** (FOUC — flash of
unstyled / wrong-theme content). Tema se aplica **inainte de primul paint** printr-un script inline
mic in `<head>`, care citeste preferinta din `localStorage` si seteaza `data-theme` pe `<html>`
sincron, inainte ca `<body>` sa randeze. Fara asta, fiecare incarcare de pagina ar clipi dark→light.
## 2. Non-Goals (anti scope-creep)
- **NU backend / cookie / ruta noua.** Persistenta = `localStorage` pur client-side (roadmap zice
"cookie/localStorage" — alegem localStorage: zero suprafata server + anti-FOUC prin scriptul din
`<head>`). Nu se atinge `routes.py`, `auth.py`, sesiunea, baza de date.
- **NU redesign de paleta dark.** Tema dark ramane **identica la octet** cu cea de azi (default
pastrat); adaugam doar varianta light + un toggle. Nicio culoare dark existenta nu se schimba.
- **NU teme multiple / personalizate / culoare de accent reglabila.** Doar doua: `dark` (default)
si `light`.
- **NU atinge worker, masina de stari, idempotenta, mapping, schema, validation.py, API.** Strict
`app/web/templates/base.html` (+ eventual un test de template).
- **NU restilizeaza fragmentele HTMX.** Toate fragmentele (`_*.html`) mostenesc variabilele din
`base.html` — comuta automat cu tema. (Conditie: zero culori hardcodate care sa nu adapteze —
vezi US-001 pentru suprafetele care azi au fundal hardcodat.)
## 3. Stories atomice
> Ambele stories ating **acelasi fisier** (`base.html`) → **secventiale**, un singur worker (sau
> lead direct, livrabila mica — ROADMAP §5.5). NU se paralelizeaza (regula fisier-comun §5.5).
### US-001: Tema light (paleta + suprafete theme-aware)
**Ca** utilizator al dashboard-ului **vreau** o paleta light corecta si lizibila **pentru ca** sa pot
folosi gateway-ul confortabil in birou luminos / la videoproiector, fara contrast slab sau zone care
raman intunecate.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/base.html` (bloc CSS `[data-theme="light"]` + conversia
suprafetelor cu fundal hardcodat la variabile), `tests/test_tema.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_tema.py`
- `test_paleta_light_definita` — HTML-ul `GET /login` (sau dashboard) contine un selector
`[data-theme="light"]` care redefineste `--bg`, `--card`, `--ink`, `--muted`, `--line`.
- `test_dark_ramane_default``:root` contine inca paleta dark exacta (`--bg:#0f1115`,
`--card:#181b22`, `--ink:#e6e9ef`) → default neschimbat.
- `test_suprafete_fara_fundal_hardcodat` — fundalurile de stare (banner eroare/warn, flash) NU mai
folosesc literal hex dark fix (`#241a1a`, `#201c0f`, `#16241c`) ci variabile/`color-mix` ce
adapteaza la tema (asertie pe absenta literalilor in `<style>`).
- **Acceptance criteria**:
- [ ] Exista un bloc `[data-theme="light"]` in `<style>` care suprascrie cel putin
`--bg`, `--card`, `--ink`, `--muted`, `--line` cu o paleta deschisa (fundal deschis, text
inchis). Contrastul text/fundal ≥ WCAG AA (4.5:1 pentru `--ink` pe `--bg` si pe `--card`).
- [ ] Paleta **dark** din `:root` ramane neschimbata la octet (default) — comportament identic cu azi
cand nu exista preferinta salvata si OS-ul nu cere light.
- [ ] Suprafetele cu fundal azi hardcodat dark (`.banner`, `.banner.warn`, `.flash`, eventual
`.drop-zone.drag-over`) sunt facute theme-aware (variabile sau `color-mix` peste paleta), astfel
incat in light arata corect (fundal deschis colorat, nu pata intunecata).
- [ ] Culorile semantice (`--ok`/`--warn`/`--err`/`--accent`) raman lizibile pe fundal light
(ajustate daca e nevoie pentru contrast ≥ AA pe text mic).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: browser pe `/` cu `document.documentElement.dataset.theme="light"` setat manual →
fundal deschis, text inchis lizibil, banner/flash/pill citibile, tabele cu linii vizibile, niciun
text "invizibil" (acelasi ton ca fundalul).
### US-003: Suprafete theme-aware si in fragmentele HTMX (fix VERIFY r1)
**Ca** utilizator pe light mode **vreau** ca bannerele de eroare/warn/flash din fragmentele HTMX sa
fie lizibile **pentru ca** azi raman pete intunecate cu text invizibil (defect prins la VERIFY r1).
- **Depinde de**: US-001 (extinde aceeasi conversie theme-aware dincolo de `base.html`)
- **Motiv**: US-001 a convertit la `color-mix` DOAR `base.html`; aceleasi fundaluri hardcodate dark
(`#241a1a` err, `#201c0f` warn) traiesc ca **inline-style** in 7 fragmente `_*.html` (10 aparitii)
— randate in dashboard, deci vizibile in light ca text invizibil. Testul US-001 scana doar `<style>`
din base.html → trecea vacuu.
- **Fisiere**: `_status.html`, `_banner.html`, `_upload.html`, `_preview_import.html`,
`_preview_rand.html`, `_trimitere_detaliu.html`, `_mapcoloane.html` (toate `app/web/templates/`),
`tests/test_tema.py` (extins)
- **Test intai (RED)**: `tests/test_tema.py`
- `test_fragmente_fara_fundal_hardcodat` — scaneaza TOATE fisierele `app/web/templates/_*.html`
(continutul brut) si asigura ca niciunul nu contine literalii `#241a1a`, `#201c0f`, `#16241c`.
- **Acceptance criteria**:
- [ ] Niciun fisier din `app/web/templates/` (inclusiv fragmentele) nu mai contine literalii hex
dark-fix `#241a1a`/`#201c0f`/`#16241c`; fundalurile folosesc `color-mix` peste paleta
(`var(--err)`/`var(--warn)`/`var(--ok)` 12% peste `var(--card)`), exact ca in `base.html`.
- [ ] In light mode bannerele/flash-urile de stare au fundal deschis colorat cu text lizibil
(verificat E2E pe dashboard: banner "Cont in asteptare de activare" nu mai e cutie neagra).
- [ ] In dark mode aspectul ramane practic identic cu azi (color-mix peste `--card` dark dă aproape
aceeasi nuanta).
- [ ] Testul de protectie scaneaza fragmentele, nu doar `base.html` (lacuna r1 inchisa).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: dashboard pe light mode → banner pending-account + orice flash de eroare/warn
lizibile (fundal deschis); comuta la dark → aspect neschimbat.
### US-002: Comutator tema in header + persistenta + anti-FOUC
**Ca** utilizator **vreau** un buton in header care comuta light/dark si imi tine minte alegerea
**pentru ca** sa nu re-comut la fiecare incarcare si sa nu vad un flash de tema gresita.
- **Depinde de**: US-001 (vizual; tehnic ating acelasi fisier → oricum secvential)
- **Fisiere**: `app/web/templates/base.html` (script inline anti-FOUC in `<head>` + buton toggle in
`<header>` + handler de comutare/persistenta), `tests/test_tema.py` (extins, acelasi fisier ca US-001)
- **Test intai (RED)**: `tests/test_tema.py`
- `test_script_antifouc_in_head_inainte_de_style``<head>` contine un `<script>` care citeste
`localStorage` cheia `theme` si seteaza `document.documentElement` `data-theme` **inainte** de
tag-ul `<style>` (pozitie in HTML: index script < index `<style>`).
- `test_buton_toggle_in_header_cu_eticheta` `<header>` contine un control de comutare cu
`aria-label`/`title` descriptiv (ex. "Comuta tema") si un `id`/atribut stabil pentru handler.
- `test_toggle_pe_login_si_dashboard` butonul apare si pe `/login` (neautentificat) si pe dashboard
(ambele extind `base.html`).
- **Acceptance criteria**:
- [ ] Script inline in `<head>`, plasat **inaintea** `<style>`, citeste preferinta:
`localStorage.theme` daca exista, altfel `prefers-color-scheme` (fallback final: `dark`), si
seteaza `data-theme` pe `<html>` sincron **fara FOUC** la incarcare/reload.
- [ ] Buton de comutare in `<header>` (langa `env`/`version`), cu `aria-label` descriptiv, atins
usor (≥ 36px zona de atins), care comuta `data-theme` lightdark **fara reload**.
- [ ] La comutare se scrie `localStorage.theme` alegerea persista peste reload si peste navigari
(deep-link `?tab=`), inclusiv pe paginile neautentificate (login/signup).
- [ ] Butonul reflecta starea curenta (eticheta/iconita arata ce face: "→ light" cand e dark si
invers), accesibil la tastatura (e un `<button>`).
- [ ] Functioneaza pe toate cele 4 pagini top-level (login, signup, dashboard, admin) toate extind
`base.html`, deci o singura implementare le acopera.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E** (Playwright MCP / `/browse`): pe `/login` apoi pe dashboard (a) click toggle
paleta comuta instant (fundal/text), (b) reload pagina tema aleasa **persista** (citeste din
localStorage, fara flash de tema veche), (c) comuta inapoi persista invers, (d) zero erori in
consola, (e) fara FOUC vizibil la reload (tema corecta din primul frame).
## 4. Riscuri
- **FOUC** (risc principal) mitigat prin scriptul inline din `<head>` ASAMBLAT INAINTE de `<style>`,
care seteaza `data-theme` sincron, pre-paint. Verificat E2E (reload nu clipeste).
- **Suprafete hardcodate raman dark in light** (banner/flash cu hex fix) mitigat de US-001 (asertie
de test pe absenta literalilor + theme-aware via variabile/`color-mix`).
- **Contrast slab in light** (text gri pe alb, accent palid) mitigat: AC explicit WCAG AA pe
`--ink`/`--muted` peste `--bg`/`--card`; verificare E2E vizuala (text lizibil).
- **Regresie pe dark** (refactor accidental al paletei existente) mitigat: `test_dark_ramane_default`
lock-uieste hex-urile dark exacte in `:root`; Non-Goal explicit "dark identic la octet".
- **localStorage indisponibil** (mod privat strict / dezactivat) script defensiv: `try/catch` in
jurul citirii/scrierii; cade pe default (dark/OS) fara sa arunce. (AC implicit: zero erori consola.)
- **Acelasi fisier in 2 stories** (`base.html`) NU se paralelizeaza; un singur worker secvential
(US-001 apoi US-002). Notat in §6.
## 5. Intrebari deschise
> Rezolvate cu utilizatorul la poarta de aprobare PRD (2026-06-22).
- **Default pentru utilizator nou (fara preferinta salvata)** REZOLVAT: **OS-aware cu fallback dark**
(onoreaza `prefers-color-scheme`; cand OS-ul nu cere light `dark`, look-ul actual). [user 2026-06-22]
- **Persistenta** REZOLVAT: **`localStorage`** (client-only, anti-FOUC prin script in `<head>`, zero
backend; nu atinge `routes.py`/sesiune). [user 2026-06-22]
- **Aspect comutator** REZOLVAT: **iconita soare/luna + `aria-label`** descriptiv, compact in header.
[user 2026-06-22]
## 6. Valuri de executie (graful de dependente)
```
Val 1: [US-001] → [US-002] ← SECVENTIAL (acelasi fisier base.html). Un singur worker (sau lead direct).
```
Livrabila mica, un singur fisier de productie atins (`base.html`): poate rula fara `TeamCreate`
(un worker Sonnet TDD, ambele stories secvential). VERIFY in context curat + writeback raman
obligatorii (ROADMAP §5.5).
## 7. Review-uri de plan (aplicate inainte de cod — ROADMAP §5.3)
> Se completeaza la PLAN inainte de aprobare. CEO + Eng obligatorii; Design — DA (atinge UI).
**CEO (valoare/scope) — PASS.** Cerinta directa de ergonomie din Etapa 5 (decizie utilizator
2026-06-22), efort mic peste o fundatie deja pregatita (CSS pe variabile). Calea cea mai scurta:
reuse `:root` + override `[data-theme]`, zero backend. Inversiune ("ce-l face inutil?"): FOUC la
incarcare (face produsul sa para buggy) neutralizat prin scriptul anti-FOUC din `<head>`; si
suprafetele hardcodate care raman dark in light (arata stricat) neutralizate prin theme-aware in
US-001. Scope minim corect (doua teme, fara personalizare). Niciun scope creep.
**Eng (fezabilitate/teste) — PASS.** Fezabilitate triviala, un singur fisier de productie, zero
atingere de backend/schema/worker. Testele acopera contractul de template (paleta light prezenta,
dark neschimbat, suprafete theme-aware, script anti-FOUC pozitionat corect, toggle prezent +
accesibil + pe paginile neautentificate); comportamentul vizual + persistenta + anti-FOUC raman pe
E2E browser (corect nu se pot prinde la TestClient). Risc unic real = FOUC, prins doar in browser
E2E explicit cu reload. `localStorage` defensiv (try/catch) acoperit ca AC zero-erori.
**Design — PASS (cu note).** Atinge UI direct. Paleta light trebuie sa respecte contrast WCAG AA
(AC explicit). Comutatorul: iconita soare/luna + `aria-label`, plasat in header langa env/version,
zona de atins 36px (consistent cu `.cardlink` existent). Suprafetele semantice (ok/warn/err) sa
ramana distincte si lizibile pe light, nu doar inversate. Tranzitia de comutare poate fi instant
(fara animatie) ca sa nu para lenta; daca se adauga `transition` pe culori, scurta (≤ 150ms, ca
restul UI-ului). De confirmat vizual la VERIFY E2E.
---
## Raport VERIFY
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
### Runda 1 (2026-06-22) — FAIL
Verificator independent (context curat, rol qa-only). **VERDICT: FAIL** (un blocker, US-001).
- **Suita PASS.** `python3 -m pytest -q` 583 passed; `tests/test_tema.py` 6 passed.
- **US-002 (toggle + persistenta + anti-FOUC) PASS integral (E2E browser).** Toggle pe
login/signup/dashboard; comutare instant (bg `#f6f7f9``#0f1115`, aria-label + iconita comuta);
persistenta `localStorage.theme` doar la click; reload pastreaza tema fara FOUC; OS-aware fara
scriere (load simplu `localStorage.theme`=null); zero erori consola (doar 404 favicon preexistent).
- **US-001 (paleta light + suprafete theme-aware) FAIL.** Paleta light + dark-neschimbat + `base.html`
color-mix = PASS, DAR suprafetele de stare raman pete intunecate cu **text invizibil** in light:
literalii dark `#241a1a`/`#201c0f` NU au fost convertiti in **fragmentele HTMX** (10 aparitii, 7
fisiere: `_status.html`, `_banner.html`, `_upload.html`, `_preview_import.html`, `_preview_rand.html`,
`_trimitere_detaliu.html`, `_mapcoloane.html`) inline-style, randate in dashboard. Dovada: banner
"Cont in asteptare de activare" = cutie `rgb(32,28,15)` pe fundal `#f6f7f9`. Testul `test_suprafete_
fara_fundal_hardcodat` scana DOAR `<style>` din base.html verde inselator (vacuu).
- **Regresie de aur PASS.** Pur frontend; worker/coada/API/schema neatinse (worker a procesat o
orfana `sent idPrezentare=68801` la pornire pipeline send functional, neafectat de 5.3).
**Remediu (US-003, adaugat in §3):** muta literalii la `color-mix` in cele 7 fragmente + extinde testul
sa scaneze fragmentele. Re-VERIFY cu subagent NOU dupa fix.
### Runda 2 (2026-06-22) — PASS
Verificator independent NOU (context curat, rol qa-only), dupa fix US-003. **VERDICT GLOBAL: PASS.**
- **Suita PASS.** `python3 -m pytest -q` 584 passed; `tests/test_tema.py` 7 passed (incl. noul
`test_fragmente_fara_fundal_hardcodat`).
- **Anti-regresie protectie PASS.** `grep -rn -E '#201c0f|#241a1a|#16241c' app/web/templates/`
GOL. Testul scaneaza fisierele `_*.html` de pe disc (`Path.glob`), nu doar base.html lacuna r1 inchisa.
- **US-001/US-003 (light lizibil) PASS (E2E, dovada cheie).** Cont inactive fortat banner
"Cont in asteptare de activare" din `_status.html` in light: `background` `rgb(246,234,225)` (crema
deschis), text `rgb(26,29,36)` (contrast ~13:1 AA), border amber. Cutia neagra `rgb(32,28,15)` din
r1 a DISPARUT. Screenshot confirma caseta peach cu text negru lizibil. Scan al tuturor suprafetelor
vizibile in light: zero zone genuin intunecate. Dark mode: aspect practic neschimbat.
- **US-002 PASS (re-confirmat).** Toggle instant; `localStorage.theme` doar la click; aria-label se
inverseaza; persista peste reload in ambele sensuri; anti-FOUC (script in `<head>` inainte de `<style>`);
toggle pe `/login` + dashboard; zero erori JS de tema in consola.
- **Regresie de aur PASS.** Diff strict frontend (`base.html` + 7 fragmente + `tests/test_tema.py`);
worker/coada/API/schema/mapping NEATINSE. Flux LIVE RAR catre RAR test NEPROBAT (creds key efemera;
send neatins de 5.3) NEPROBAT, nu FAIL.
- **Nota cleanup:** verificatorul a lasat un cont de test inactive (id=5, verifyr2@test.com) in DB-ul
de test efemer folosit pentru a forta bannerul inofensiv (date de test, mediu test).
### CLOSE — `/code-review` high (2026-06-22)
1 finding real reparat: in paleta light `--ok:#16a34a` (green-600) folosit ca text (`.s-sent`/`.s-ok`,
bife verzi din bara de stare) pe `--card:#ffffff` dadea contrast ~3.3:1 sub WCAG AA 4.5:1 pentru text
mic, incalcand AC-ul US-001. Reparat `--ok:#15803d` (green-700, ~5.0:1, trece AA); paleta dark
neatinsa. Restul semanticelor light trec deja AA (--err 6.2:1, --warn 5.0:1, --accent 5.1:1). Schimbare
de valoare CSS (fara comportament) fara re-VERIFY. Refutate: suport `color-mix` (universal pe browsere
moderne, audienta B2B); duplicarea inline a `color-mix` in fragmente (precede acest diff, candidat de
cleanup viitor macro/clase `.flash.err`/`.flash.warn`). 584 teste pass.

View File

@@ -0,0 +1,395 @@
# PRD 5.4 — Erori pe 3 niveluri (problema + cauza + fix) pe API si UI
**Stare**: inchis
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
## 1. Obiectiv
Fiecare eroare pe care o vede un integrator (canal API) sau un service-auto (UI web) sa raspunda la
trei intrebari, in loc de una singura:
1. **Problema** — ce s-a intamplat (categorie umana, scurta). *"VIN invalid."*
2. **Cauza** — de ce, specific. *"VIN-ul are 16 caractere; RAR cere exact 17."*
3. **Fix** — ce sa faci acum. *"Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule, fara O/I/Q."*
Motivul (lentila DX, Etapa 5): erorile plate ("Fisier nerecunoscut", "cheie API invalida", "VIN invalid")
**transfera incertitudinea catre utilizator** — care fie ghiceste, fie deschide un tichet de suport. Cele
trei niveluri inchid bucla la sursa: mai putine tichete, integrare self-service.
**Invariant de corectitudine (motivul cheie de design):** cele trei niveluri pentru un anumit cod de
eroare se definesc **o singura data**, intr-un **catalog central pur** (`app/errors.py`), consumat de
**toate** suprafetele (API + UI + worker). Daca textul s-ar duplica pe canale, API si UI ar putea
diverge — un cod ar spune un lucru in JSON si altul in dashboard. Catalogul unic face imposibila
divergenta (acelasi pattern care a facut 5.2 corect: o singura sursa partajata, nu doua copii).
## 2. Non-Goals (anti scope-creep)
- **NU acoperim login / signup / CSRF / auth 401** (decizie utilizator 2026-06-22: focus pe fluxul de
declarare). Aceste suprafete sunt edge / dev si raman mesaje plate. `auth_routes.py`, `csrf.py`,
handler-ele `LoginRequired`/`AdminRequired`/`CsrfError` din `main.py` — NEATINSE.
- **NU breaking change pe API** (decizie utilizator 2026-06-22: aditiv). Pastram campurile existente
(`field`, `message`, `error`, `type`/`loc`/`msg`) si **ADAUGAM** `cod`, `problema`, `cauza`, `fix`.
Clientii vechi (ROAAUTO / soft propriu integrat la 5.1/5.2) nu se strica; cei noi pot afisa 3 niveluri.
- **NU schema noua** — `submissions.rar_error` e deja TEXT si stocheaza JSON; doar imbogatim continutul.
Zero migrare.
- **NU apel live nou la RAR** — pentru erorile RAR 400 imbracam mesajul RAR existent (passthrough ca
`cauza`) intr-un invelis 3-niveluri; nu schimbam clasificarea transient/terminal a worker-ului.
- **NU schimbam regulile de validare** — `validate_prezentare` valideaza exact aceleasi conditii;
doar ataseaza `cod` + nivelele la fiecare eroare existenta.
- **NU schimbam masina de stari / idempotenta / mapping-rezolvarea / nomenclatorul.**
- **NU traducere i18n** — romana, ca tot proiectul.
## 3. Stories atomice
### US-001: Catalog central de erori (`app/errors.py`) — DONE (588 teste)
**Ca** dezvoltator al gateway-ului **vreau** o singura sursa de adevar care mapeaza fiecare cod de
eroare la (problema, fix), cu un helper care construieste obiectul de eroare 3-niveluri, **pentru ca**
API-ul, UI-ul si worker-ul sa nu poata diverge in ce explica utilizatorului.
- **Depinde de**: —
- **Fisiere**: `app/errors.py` (modul pur nou), `tests/test_errors.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_errors.py`
- `test_catalog_complet` — fiecare intrare are `problema` + `fix` ne-goale (string).
- `test_eroare_construieste_3niveluri``eroare("VIN_FORMAT", field="vin", cauza="...")` intoarce
dict cu cheile `{field, cod, problema, cauza, fix, message}`, `cod=="VIN_FORMAT"`, `problema`/`fix`
luate din catalog, `cauza` cea data.
- `test_message_back_compat` — cand `cauza` e dat, `message == cauza` (alias pentru clientii vechi).
- `test_cod_necunoscut_ridica``eroare("INEXISTENT")` ridica `KeyError`/`ValueError` (nu inventeaza
text gol — drift prins la dezvoltare).
- **Acceptance criteria**:
- [ ] `app/errors.py` pur (fara import DB/HTTP), expune `CATALOG: dict[str, dict]` (cod → {problema, fix})
si `eroare(cod, *, field=None, cauza=None) -> dict`.
- [ ] Obiectul de eroare are exact cheile `{field, cod, problema, cauza, fix, message}`; `message`
(back-compat) `== cauza` cand `cauza` e dat, altfel `== problema`.
- [ ] CATALOG contine codurile pentru toate suprafetele in scop (validare continut, mapare op→cod,
RAR, import) — vezi lista din §4. Fiecare intrare are `problema` + `fix` ne-goale.
- [ ] **`fix` e specific si actionabil** (finding CEO): numeste un loc/o actiune concreta (ex. "talon,
pozitia E", "tab-ul Mapari", "salveaza ca .csv UTF-8"), NU boilerplate generic ("verifica datele").
Un fix generic = eroare plata mai lunga; testul de calitate al livrabilei. (Verificat la review uman.)
- [ ] `eroare` cu cod absent din CATALOG ridica eroare (nu intoarce text gol).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: unitar (modul pur) — fara canal.
### US-002: Validarea de continut emite 3 niveluri (`validation.py`) — DONE
**Ca** integrator API **vreau** ca fiecare eroare de validare (VIN/nr/data/odometru/prestatii/b64) sa
spuna problema + cauza + fix, **pentru ca** sa corectez payload-ul fara sa ghicesc formatul cerut.
- **Depinde de**: US-001
- **Fisiere**: `app/validation.py`, `tests/test_validation.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_validation.py`
- `test_vin_invalid_are_3niveluri` — VIN cu O/I/Q → eroare cu `cod=="VIN_FORMAT"`, `problema`,
`fix` ne-goale, `field=="vin"`, `message` pastrat (back-compat).
- `test_data_prea_veche_cod` / `test_data_viitor_cod` / `test_data_format_cod` — coduri distincte.
- `test_odometru_initial_lipsa_cod` / `test_odometru_ordine_cod` — coduri distincte.
- `test_prestatii_goale_cod`, `test_b64_invalid_cod`.
- `test_back_compat_field_message` — fiecare eroare are inca `field` + `message` (forma veche
pastrata pentru clientii existenti).
- `test_toate_codurile_in_catalog` — fiecare `cod` emis de `validate_prezentare` exista in `CATALOG`.
- **Acceptance criteria**:
- [ ] `validate_prezentare` intoarce erori cu `{field, message, cod, problema, cauza, fix}` (aditiv —
`field` + `message` neschimbate la octet fata de azi).
- [ ] Fiecare regula are un `cod` stabil (vezi lista §4); textul (problema/fix) vine din `errors.eroare`.
- [ ] **Byte-compat** (finding Eng): `cauza` = mesajul existent verbatim (eventual + context specific
precum lungimea VIN gasita); `message` ramane EXACT string-ul de azi → testele existente care
compara `message` raman verzi fara modificari.
- [ ] Toate testele existente (`test_validation.py`, `test_api.py`, `test_validare_dryrun.py`) raman
verzi (forma veche `field`/`message` intacta).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: canal API — `POST /v1/prezentari/valideaza` cu VIN invalid → `erori[0]` are cele
3 niveluri + `field`/`message` vechi.
### US-003: Propagare 3 niveluri prin mapare + raspuns API (`mapping.py`, `router.py`) — DONE
**Ca** integrator API **vreau** ca raspunsul `/valideaza` si motivul stocat (`rar_error`) sa transporte
cele 3 niveluri pentru validare SI pentru coduri nemapate / auto-send oprit, **pentru ca** verdictul sa
fie la fel de explicit indiferent de ramura (needs_data / needs_mapping).
- **Depinde de**: US-001, US-002
- **Fisiere**: `app/mapping.py`, `app/api/v1/router.py`, `app/models.py`, `tests/test_mapping.py`,
`tests/test_validare_dryrun.py` (~5 fisiere)
- **Test intai (RED)**:
- `test_mapping.py::test_unmapped_are_3niveluri` — cod_op_service necunoscut → `classify_prezentare`
produce `needs_mapping` cu `cod=="COD_NEMAPAT"` + problema/cauza (codul concret)/fix in structura.
- `test_mapping.py::test_auto_send_oprit_3niveluri` — mapare cu `auto_send=0``cod=="AUTO_SEND_OPRIT"`
+ 3 niveluri.
- `test_mapping.py::test_needs_data_pass_through` — erorile de validare imbogatite trec neatinse prin
`classify_prezentare` in `rar_error`.
- `test_validare_dryrun.py::test_erori_au_3niveluri``/valideaza` cu VIN invalid → `erori[i]` are
`cod/problema/cauza/fix`; cu cod_op nemapat → `nemapate` carry 3 niveluri.
- **Acceptance criteria**:
- [ ] `classify_prezentare` pastreaza erorile de validare imbogatite (pass-through) in `rar_error`.
- [ ] Ramura `needs_mapping` (cod nemapat) si nota `auto_send=0` se construiesc prin `errors.eroare`
(3 niveluri), nu string-uri ad-hoc.
- [ ] **`rar_error` stocat = SUPERSET al formei de azi** (finding Eng critic): pastreaza structura
veche (`needs_data` → array `[{field,message,...}]`; `needs_mapping``{unmapped:[...], ...}`),
ADAUGA cheile 3-niveluri. Asa `labels.motiv_uman` actual ramane functional intre Val 3 si Val 4
(nu se strica pana e actualizat in US-006) si nu e nevoie de migrare. Aplica principiul aditiv si
la datele stocate, nu doar la API.
- [ ] Raspunsul `/valideaza` (`erori`, `nemapate`) include `cod/problema/cauza/fix` aditiv; modelele
din `models.py` accepta cheile noi fara a respinge (verifica: tipul `erori`/`nemapate` e permisiv
sau extins; nu schema stricta care respinge chei in plus).
- [ ] **Teste subset, nu egalitate exacta** (finding Eng): testele existente care comparau `==` un dict
de eroare se actualizeaza la asertii de subset (comportament identic, doar chei aditive).
- [ ] `POST /v1/prezentari` (calea reala) ramane cu comportament identic — `test_api.py` verde;
`rar_error` stocat e JSON 3-niveluri pentru needs_data/needs_mapping.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: canal API — `/valideaza` cu (a) VIN invalid → needs_data + 3 niveluri, (b) cod_op
nemapat → needs_mapping + 3 niveluri. Regresia de aur: `POST /v1/prezentari` enqueue neschimbat.
### US-004: Erorile RAR (400/401) imbracate pe 3 niveluri in worker (`worker`, `rar_client.py`) — DONE
**Ca** service-auto **vreau** ca o respingere de la RAR sa fie tradusa in problema + cauza (mesajul RAR
exact) + fix, in loc de un JSON brut, **pentru ca** sa inteleg ce a respins RAR fara sa citesc JSON.
- **Depinde de**: US-001
- **Fisiere**: `app/worker/__main__.py`, `app/rar_client.py` (eventual), `tests/test_worker_*.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_worker_rar_errors.py` (nou) —
- `test_rar_400_stocheaza_3niveluri``RarError(status=400, field_errors=[{field,message}])`
worker stocheaza `rar_error` JSON cu `cod=="RAR_VALIDARE"`, `problema`, `cauza` continand mesajul
RAR exact (passthrough), `fix` cu indrumare; pastreaza si `field_errors` originale.
- `test_rar_401_creds_3niveluri``RarAuthError``cod=="RAR_CREDS_INVALIDE"` + 3 niveluri, stare
`error` (fara retry, neschimbat).
- `test_clasificare_transient_neschimbata` — 5xx/timeout raman transient (retry), comportament identic.
- **Acceptance criteria**:
- [ ] La RAR 400, `rar_error` stocat = SUPERSET (finding Eng): pastreaza array-ul `field_errors`
original `[{field,message}]` (ca `labels.py` actual sa-l randeze per-camp) + ADAUGA invelisul
3-niveluri (`cod=RAR_VALIDARE`, `cauza`=mesajul RAR exact passthrough, `fix`=indrumare).
- [ ] La RAR 401, `rar_error` = 3-niveluri (`cod=RAR_CREDS_INVALIDE`), stare `error` (fara retry).
- [ ] Clasificarea transient vs terminal NESCHIMBATA (5xx/408/429 retry; 4xx terminal); reconcilierea
anti-duplicat neatinsa.
- [ ] Fara echo de creds in `rar_error` (mesajul RAR nu contine parola; verificat in test).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: worker pe RAR test (daca exista creds) — prezentare cu VIN invalid → RAR 400 →
`needs_data` cu `rar_error` 3-niveluri vizibil in dashboard. (Live optional — vezi riscuri.)
### US-005: Erorile de import imbracate pe 3 niveluri (`import_router.py`) — DONE
**Ca** service-auto care incarca un fisier **vreau** ca erorile de upload / mapare coloane / commit sa
spuna ce e gresit, de ce si cum repar, **pentru ca** sa pot incarca singur fara suport.
- **Depinde de**: US-001
- **Fisiere**: `app/api/v1/import_router.py`, `tests/test_import_*.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_import_errors.py` (nou) —
- `test_fisier_prea_mare_3niveluri` — 413 → detail contine `cod=="IMPORT_FISIER_PREA_MARE"` +
problema/cauza (nr randuri vs max)/fix; pastreaza `error`/`message` vechi.
- `test_antet_neclar_3niveluri` — HeaderError → `cod=="IMPORT_ANTET_NECLAR"` + 3 niveluri + `found`.
- `test_encoding_3niveluri`, `test_fisier_nerecunoscut_3niveluri`, `test_multiple_sheets_3niveluri`.
- `test_fara_mapare_coloane_3niveluri` — preview fara mapare → `IMPORT_FARA_MAPARE_COLOANE`.
- `test_confirmare_gresita_3niveluri` — commit cu n gresit → `IMPORT_CONFIRMARE_GRESITA` + `n_ok`.
- `test_override_ilizibil_3niveluri` — editare cu override corupt → `IMPORT_OVERRIDE_ILIZIBIL`.
- **Acceptance criteria**:
- [ ] Fiecare `HTTPException` de import in scop are `detail` SUPERSET: pastreaza `error`/`message`/
campurile contextuale existente (`sheets`/`found`/`n_ok`) si ADAUGA `cod/problema/cauza/fix`.
- [ ] Codurile vin din `errors.eroare` (catalog), nu string-uri ad-hoc.
- [ ] Toate testele de import existente raman verzi (forma veche `error`/`message` intacta; asertii de
subset unde comparau exact — finding Eng).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: canal API — `POST /v1/import` cu fisier prea mare / antet neclar → detail 3-niveluri.
### US-006: Componenta UI de eroare pe 3 niveluri + stari submission (`labels.py`, templates core) — DONE
**Ca** service-auto **vreau** ca dashboard-ul sa afiseze problema (bold) + cauza + fix (linie de actiune)
pentru randurile needs_data / needs_mapping / error si per-camp in preview, **pentru ca** sa stiu ce sa
fac fara sa deschid un tichet.
- **Depinde de**: US-001, US-002, US-003, US-004 (codurile/structura trebuie sa existe)
- **Fisiere**: `app/web/labels.py`, `app/web/templates/_eroare.html` (macro nou), `app/web/templates/base.html`
(CSS), `app/web/templates/_trimitere_detaliu.html`, `app/web/templates/_status.html`,
`app/web/templates/_preview_rand.html`, `tests/test_web_*.py` (~7 fisiere)
- **Test intai (RED)**: `tests/test_web_erori.py` (nou) —
- `test_motiv_uman_3niveluri``labels.motiv_uman` pe `rar_error` 3-niveluri intoarce
problema/cauza/fix (nu doar un string plat); fallback gratios la rar_error vechi/string/corupt.
- `test_detaliu_afiseaza_fix``/_fragments/...` pe submission needs_data → HTML contine textul `fix`.
- `test_preview_rand_per_camp_fix` — preview rand needs_data → fiecare camp invalid arata `fix`-ul.
- **Acceptance criteria**:
- [ ] **Progresiv, dashboard compact pastrat** (finding Design — nu regresa 3.5/3.6): in lista/rand
se vede problema (eticheta umana existenta) + fix-ul ca o singura linie de actiune; cauza +
mesajul tehnic RAR integral stau in detaliu / `<details>` (nu 3 linii per rand → zid de text).
Cele 3 niveluri complete apar in panoul de detaliu si in preview-ul de rand, nu in fiecare rand din lista.
- [ ] **Scannabil** (finding Design): nivelele au tratament vizual / etichete care le fac parcurgibile
fara citire integrala (ex. "Problema" / "De ce" / "Cum repari", sau ierarhie vizuala clara).
- [ ] Macro Jinja `_eroare.html` randeaza consistent; mesajul tehnic RAR integral ramane in `<details>`
(pattern existent in `_trimitere_detaliu.html`).
- [ ] `labels.py` citeste catalogul / parseaza `rar_error` 3-niveluri; degradeaza gratios pe forma
veche (string / `[{field,message}]` / JSON corupt) — fara 500 (lectia 3.6 cu decriptarea).
- [ ] Reutilizeaza, NU inlocuieste, pattern-ul bun din `_status.html` (problema + subtext-hint deja
~ problema + fix); `_trimitere_detaliu.html`, `_preview_rand.html` folosesc macro-ul.
- [ ] CSS in paleta light+dark din 5.3 — fara culori hardcodate; accentul de "fix/actiune" trece AA
in AMBELE teme (lectia 5.3: `--ok` pica AA ca text); distinct de rosul de eroare.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: browser HTMX pe `/` — rand needs_data arata problema+cauza+fix; preview rand
invalid arata fix per-camp; dark + light (5.3) ambele lizibile.
### US-007: 3 niveluri in import / upload / preview UI + rute web (`routes.py`, templates import) — DONE
**Ca** service-auto **vreau** ca erorile de la upload, mapare coloane si preview import sa apara cu cele
3 niveluri in interfata, **pentru ca** sa rezolv singur problemele de fisier.
- **Depinde de**: US-006 (macro `_eroare.html`), US-005 (codurile de import)
- **Fisiere**: `app/web/routes.py`, `app/web/templates/_upload.html`, `app/web/templates/_mapcoloane.html`,
`app/web/templates/_preview_import.html`, `tests/test_web_*.py` (~5 fisiere)
- **Test intai (RED)**: `tests/test_web_import_erori.py` (nou) —
- `test_upload_eroare_3niveluri` — upload fisier invalid prin ruta web → fragment contine problema+fix.
- `test_mapcoloane_format_json_3niveluri` — format coloane JSON invalid → `COLOANE_FORMAT_JSON` 3 niveluri.
- `test_cod_rar_necunoscut_3niveluri` — mapare operatie cu cod RAR inexistent → 3 niveluri + sugestie.
- **Acceptance criteria**:
- [ ] Caile web de eroare din `routes.py` (upload, mapare coloane, format JSON, cod RAR necunoscut,
corectie) trec context 3-niveluri catre template (din catalog), nu string plat.
- [ ] `_upload.html`, `_mapcoloane.html`, `_preview_import.html` folosesc macro-ul `_eroare.html`.
- [ ] Forma veche (mesaj plat) inca functioneaza unde nu exista cod (fara regresie); toate testele web
existente verzi.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: browser HTMX — upload fisier prea mare → 3 niveluri; mapare coloane JSON invalid → fix.
### US-008: Documentare envelope de eroare imbogatit (`api-rar-contract.md`) — DONE
**Ca** integrator nou **vreau** sa stiu forma exacta a erorilor (campuri vechi + cele 3 niveluri noi) si
lista de coduri, **pentru ca** sa-mi construiesc gestionarea de erori fara reverse-engineering.
- **Depinde de**: US-001, US-002, US-003, US-005 (codurile finale)
- **Fisiere**: `docs/api-rar-contract.md` (~1 fisier)
- **Test intai (RED)**: — (doc; verificare manuala). Optional `tests/test_errors.py::test_doc_acopera_codurile`
daca e fezabil ieftin (verifica ca fiecare cod din CATALOG apare in doc).
- **Acceptance criteria**:
- [ ] Sectiune noua in `api-rar-contract.md`: forma erorii (`{field, message, cod, problema, cauza, fix}`),
nota de back-compat (campurile vechi raman), tabel cod → problema/fix.
- [ ] Mentioneaza ca `/valideaza` si `rar_error` stocat folosesc aceeasi forma.
- **Verificare E2E**: review uman al documentului.
## 4. Catalog de coduri (referinta — definit in US-001)
| Domeniu | Cod | problema (nivel 1) | unde |
|---|---|---|---|
| Validare | `VIN_FORMAT` | VIN invalid | US-002 |
| Validare | `NR_INMATRICULARE_FORMAT` | Numar de inmatriculare invalid | US-002 |
| Validare | `DATA_FORMAT` | Data prestatiei in format gresit | US-002 |
| Validare | `DATA_PREA_VECHE` | Data prestatiei prea veche | US-002 |
| Validare | `DATA_VIITOR` | Data prestatiei in viitor | US-002 |
| Validare | `ODOMETRU_FINAL_FORMAT` | Odometru final invalid | US-002 |
| Validare | `ODOMETRU_INITIAL_LIPSA` | Lipseste odometrul initial | US-002 |
| Validare | `ODOMETRU_INITIAL_FORMAT` | Odometru initial invalid | US-002 |
| Validare | `ODOMETRU_INITIAL_ORDINE` | Odometru initial > final | US-002 |
| Validare | `PRESTATII_GOALE` | Nicio prestatie | US-002 |
| Validare | `B64_INVALID` | Imaginea nu e base64 valid | US-002 |
| Mapare | `COD_NEMAPAT` | Lipseste codul RAR al operatiei | US-003 |
| Mapare | `AUTO_SEND_OPRIT` | Necesita confirmare manuala | US-003 |
| RAR | `RAR_VALIDARE` | RAR a respins prezentarea | US-004 |
| RAR | `RAR_CREDS_INVALIDE` | Credentiale RAR invalide | US-004 |
| Import | `IMPORT_FISIER_PREA_MARE` | Fisier prea mare | US-005 |
| Import | `IMPORT_ANTET_NECLAR` | Antet de coloane neclar | US-005 |
| Import | `IMPORT_ENCODING` | Codare de caractere nesuportata | US-005 |
| Import | `IMPORT_FISIER_NERECUNOSCUT` | Fisier nerecunoscut | US-005 |
| Import | `IMPORT_MULTIPLE_SHEETS` | Mai multe foi in fisier | US-005 |
| Import | `IMPORT_FARA_MAPARE_COLOANE` | Coloanele nu sunt mapate | US-005 |
| Import | `IMPORT_CONFIRMARE_GRESITA` | Numar confirmat gresit | US-005 |
| Import | `IMPORT_OVERRIDE_ILIZIBIL` | Editarea anterioara nu se poate citi | US-005 |
| Coloane | `COLOANE_FORMAT_JSON` | Format de coloane (JSON) invalid | US-007 |
> Lista finala se fixeaza in US-001 (catalog) + drift-test (`test_toate_codurile_in_catalog`). Codurile
> nu sunt parte din contractul de back-compat (campuri noi); mesajele RAR exacte raman in `cauza`.
## 4b. Intrebari deschise (rezolvate inainte de executie)
- **Latimea scope-ului** — REZOLVAT: focus pe fluxul de declarare (validare continut, RAR 400, import,
mapare op→cod); login/signup/CSRF/auth raman plate. [user 2026-06-22]
- **Compatibilitate API** — REZOLVAT: aditiv, fara breaking change (campuri vechi pastrate, adaugam
`cod/problema/cauza/fix`); documentat in `api-rar-contract.md`. [user 2026-06-22]
## 5. Riscuri
- **Drift catalog** (cod folosit dar absent din CATALOG, sau text gol) → `errors.eroare` ridica pe cod
necunoscut (US-001) + `test_toate_codurile_in_catalog` (US-002) — drift prins la dezvoltare, nu in prod.
- **`fix` generic, fara valoare** (finding CEO) → criteriu de calitate explicit (US-001): fiecare `fix`
numeste loc/actiune concreta; verificat la review uman + in design-review-ul UI.
- **`rar_error` stocat schimbat rupe `labels.py` intre valuri** (finding Eng) → stocam SUPERSET (old keys
intacte) in US-003/US-004; `labels.py` actual ramane functional pana la US-006; zero migrare.
- **Teste cu egalitate exacta de dict** (finding Eng) → se trec la asertii de subset (acelasi comportament,
chei aditive); contractul de back-compat e pe campurile vechi, nu pe absenta celor noi.
- **Breaking change accidental pe API** → aditiv prin constructie + testele existente (`test_api.py`,
`test_validare_dryrun.py`, `test_import_*.py`) sunt contractul de back-compat: raman verzi = forma
veche `field`/`message`/`error` intacta. AC explicit in fiecare story backend.
- **500 la afisare UI pe `rar_error` vechi/corupt** (lectia 3.6: decriptare neprotejata) → `labels.py`
degradeaza gratios pe forma veche (string / `[{field,message}]` / JSON corupt), test dedicat (US-006).
- **Scurgere de creds prin `cauza`** (mesaj RAR passthrough) → mesajele RAR de validare nu contin parola
(field/message pe campuri de prezentare); test no-echo (US-004). Handler-ul 422 din `main.py` deja
dropeaza `input`/`ctx`.
- **Suprafata mare** → 8 stories pe valuri cu fisiere disjuncte (vezi §6); backend (US-002/004/005)
paralel, UI secvential dupa backend, docs paralel.
- **Verbozitate UI** (3 niveluri = zgomot) → progresiv: problema + fix vizibile, mesajul tehnic RAR
integral ramane in `<details>` (pattern existent in `_trimitere_detaliu.html`).
- **Conflict pe fisiere comune** (lectia 5.1: clobber la worktree/merge) → mapping.py atins doar de
US-003; routes.py doar de US-007; templates partitionate intre US-006 (detaliu/status/preview_rand) si
US-007 (upload/mapcoloane/preview_import); macro `_eroare.html` creat in US-006, consumat in US-007
(dependenta de val, nu paralel).
## 6. Valuri de executie (graful de dependente)
```
Val 1: [US-001] backbone catalog (singur — toti depind de el)
Val 2: [US-002] [US-004] [US-005] backend paralel, fisiere disjuncte
validation.py worker/rar import_router.py
Val 3: [US-003] mapping.py + router.py + models.py (depinde US-002)
Val 4: [US-006] [US-008] UI core (labels+templates) || docs (api-rar-contract.md) — disjuncte
Val 5: [US-007] UI import/web (depinde US-006 pt macro)
```
- **Val 2**: max 2-3 teammates simultan (ROADMAP §5.5). validation.py / worker+rar_client / import_router.py
sunt disjuncte → 3 teammates paraleli OK.
- **Val 4**: US-006 (templates+labels) si US-008 (doc) ating fisiere disjuncte → paralel.
- Dupa fiecare val: lead-ul ruleaza `python3 -m pytest -q` (regresie) si bifeaza stories in PRD.
## 7. Review-uri de plan (aplicate inainte de cod — ROADMAP §5.3)
> Obligatorii: `/plan-ceo-review` (valoare/scope) + `/plan-eng-review` (fezabilitate/teste).
> `/plan-design-review` — DA (atinge UI: US-006, US-007). Rezultatele se aplica IN acest PRD inainte de cod.
**CEO (valoare/scope) — PASS.** Problema corecta (DX: erorile plate transfera incertitudinea la user →
tichete de suport), aliniata cu directia Etapa 5. Scope-ul (declaration flow) e cel mai direct la valoare;
login/signup/CSRF taiate corect (decizie user). **Inversiune ("ce-l face sa esueze?"):** un `fix` generic
("verifica datele") face dintr-o eroare 3-niveluri doar o eroare plata mai lunga — valoarea traieste sau
moare in specificitatea fix-ului. → Aplicat ca criteriu de calitate explicit (US-001 AC + risc): fiecare
`fix` numeste loc/actiune concreta. **Deferare constienta:** `POST /v1/prezentari` real intoarce doar
`status`, nu `erori` inline — integratorul afla "de ce" printr-un GET sau prin `/valideaza`; a adauga
`erori` inline pe ruta reala ar fi aditiv + util, dar e scope creep peste 5.4 → notat ca oportunitate
viitoare, nu in scope acum.
**Eng (fezabilitate/teste) — PASS cu 3 conditii (aplicate in PRD).** Catalogul pur + helper = backbone
fezabil, drift prins la dev. **(1) Critic — `rar_error` stocat trebuie SUPERSET** (old keys intacte): altfel
`labels.motiv_uman` se strica intre Val 3 (backend schimba forma) si Val 4 (UI o citeste); superset =
zero migrare + degradare gratioasa (lectia 3.6). Aplicat in US-003/US-004 AC + risc. **(2) Byte-compat**:
validarea pune mesajul existent verbatim ca `cauza`, `message` ramane identic → testele pe `message`
raman verzi (US-002). **(3) Teste subset, nu egalitate exacta** de dict (chei aditive) — aplicat in
US-002/003/005. `models.py` trebuie sa accepte chei in plus pe `erori`/`nemapate` (verificat in US-003).
Worker testabil cu `rar_client` mock-uit (fara live RAR).
**Design (UI — US-006/US-007) — PASS cu 3 conditii (aplicate in PRD).** **(1) Progresiv, nu regresa
dashboard-ul compact din 3.5/3.6**: in lista/rand → problema + fix pe o linie; cauza + tehnic RAR in
detaliu/`<details>`; cele 3 niveluri complete doar in panoul de detaliu + preview-ul de rand. **(2)
Scannabil**: etichete/ierarhie vizuala ("Problema"/"De ce"/"Cum repari") ca user-ul sa parcurga fara
citire integrala. **(3) AA in ambele teme** (lectia 5.3): accentul de "fix/actiune" trece AA light+dark,
fara culori hardcodate, distinct de rosul de eroare. Reutilizeaza pattern-ul bun din `_status.html`
(problema + subtext = ~ problema + fix), nu il inlocui.
---
## Raport VERIFY
Verificator independent (context curat, rol qa-only), 2026-06-22. **VERDICT GLOBAL: PASS.**
**1. Suita — PASS.** `python3 -m pytest -q` → 628 passed, 234 warnings.
**2. Acceptance criteria US-001..US-008 — toate PASS.** Verificate direct pe cod + probe live (TestClient + SQLite temp):
- US-001: catalog pur, 24 coduri (`MISSING:[]`, `EMPTY:[]`), `eroare('INEXISTENT')`→KeyError, chei `{field,cod,problema,cauza,fix,message}`, `message==cauza`/`==problema` corect.
- US-002: **byte-compat confirmat**`message` VIN identic la octet cu `git show HEAD:app/validation.py` (textul vechi pus ca `cauza`); erori complete pe 6 chei.
- US-003: **`rar_error` SUPERSET confirmat** — needs_mapping pastreaza `unmapped`; auto_send pastreaza `auto_send`; needs_data ramane array cu `field`+`message`; `nemapate` din `/valideaza` poarta 3 niveluri; `models.py:113-114` permisiv (`list[dict]`).
- US-004: RAR 400→`RAR_VALIDARE` (`field`/`message` pastrate, cauza=mesaj RAR passthrough), RAR 401→`RAR_CREDS_INVALIDE` fara retry fara echo creds; `_is_transient` neschimbat.
- US-005: detalii import superset (`error`/`message`/`sheets`/`found`/`n_ok` + 3 niveluri).
- US-006: `parse_erori` degradeaza gratios (string plat / `[{field,message}]` fara cod / JSON invalid / None — fara exceptie); detaliu randeaza Problema/De ce/Cum repari + `<details>` tehnic; CSS doar variabile paleta, AA in ambele teme (accent 5.17/5.33, err 4.83/5.06), accent ≠ rosu.
- US-007: upload/mapcoloane prin macro; per-camp `camp-fix`.
- US-008: contract documentat (forma 6 chei, back-compat, tabel cod→problema/fix complet).
- **Calitate `fix` (finding CEO) — PASS**: specifice/actionabile ("talon pozitia E", "tab-ul Mapari", "CSV UTF-8", "maxim 5000 randuri"); niciun fix generic.
- **Non-Goal — PASS**: `auth_routes.py`/`csrf.py`/`main.py` NEATINSE (confirmat `git status`).
**3. E2E canal API — PASS.** `/valideaza`: (a) VIN invalid → `erori[0]` cu `cod/problema/cauza/fix` + `field`/`message` vechi; (b) cod_op nemapat → `nemapate[0]` cu `cod_op_service`/`denumire` + 3 niveluri. `POST /v1/prezentari` real → `200 {status:queued}`.
**4. E2E canal web (UI, TestClient pe fragmente) — PASS.** Upload invalid → `_upload.html` cu `eroare-3n` + "Cum repari" + fix; submission needs_data → `_trimitere_detaliu.html` cu 3 niveluri + `<details>` tehnic. (Browser Playwright neutilizat — fragmentele TestClient acopera criteriile.)
**5. Regresia de aur — PASS (live neprobat, conform asteptarii).** `POST /v1/prezentari`→queued + `test_api.py` verde. Flux LIVE RAR (worker→FINALIZATA pe RAR test) NEPROBAT — lipsesc `AUTOPASS_CREDS_KEY`+creds test+`--send` in mediu; NU e FAIL al 5.4 (endpoint-urile/UI noi nu ating trimiterea; worker-ul doar imbogateste `rar_error`).
**Observatie minora (ne-blocanta), REPARATA la CLOSE:** exemplul JSON din `api-rar-contract.md` avea `message`/`cauza` cu un text VIN usor diferit de cel real emis de `validation.py:72`. Corectat (ambele linii) sa coincida verbatim cu codul.

View File

@@ -0,0 +1,229 @@
# PRD 5.5 — Uniformizare & standardizare UI/UX
**Stare**: aprobat (2026-06-23)
> Proces: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> **Design vizual (sursa de adevar pentru *cum arata*)**: `docs/design/5.5-uniformizare-ui.md`.
> Stare: `draft → aprobat → in-executie → verify-pass → inchis`.
## 1. Obiectiv
Aducem toate suprafetele dashboard-ului la acelasi vocabular de componente ca tabelul **Trimiteri**
(referinta corecta), reasezam navigarea intr-un **meniu de cont** (hamburger) si dam panoului admin
actiuni reale de ciclu de viata pe conturi. Tinta: aplicatia arata si se comporta uniform, fara
tabele labartate, fara wayfinding redundant, fara scroll orizontal pentru actiuni. Detaliile vizuale
si deciziile utilizatorului: `docs/design/5.5-uniformizare-ui.md` (§10).
## 2. Non-Goals (anti scope-creep)
- **Fara redesign de estetica**: paleta/tipografia/tokenii din `base.html` (5.3) raman NESCHIMBATI la octet.
- **Fara atingere a fluxului de trimitere**: worker (masina stari submissions, idempotenta, mapping-rezolvare)
NEATINS, cu o singura exceptie controlata — gate-ul `claim_one` pe noua stare de cont (US-004), pastrand
echivalenta `active=1 ⇔ status='active'`.
- **Fara schimbare a semanticii `auto_send`**: comutatorul Auto/Manual ramane reskin la nivel de macro
(`name="auto_send"`, semantica de prezenta). Zero atingere a parserelor `/mapari` si `/_import/...`.
- **Fara rute noi de date / fara HTTP nou pe chei API**: lifecycle-ul conturilor e admin-only, sub
`require_admin` + CSRF, exact ca rutele admin existente.
- **Fara responsive/mobile nou** dincolo de ce ofera deja `.tablewrap` (scroll in card).
- **Tabelul Trimiteri ramane neatins** — e referinta, nu tinta.
## 3. Stories atomice
### US-001: Elimina sectiunea "Ajutor" din Acasa
**Ca** operator **vreau** o pagina Acasa fara wayfinding redundant **pentru ca** linkurile Mapari/Coduri RAR
sunt deja in navigare.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_acasa.html`, `tests/test_web_acasa.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_acasa.py``test_acasa_fara_sectiune_ajutor` (randul "Ajutor:" + linkurile
inline lipsesc din HTML-ul Acasa)
- **Acceptance criteria**:
- [ ] Blocul `Ajutor: <a>Mapari</a> <a>Coduri RAR</a>` (liniile ~47-55) eliminat din `_acasa.html`.
- [ ] Restul Acasa (upload, "Primii pasi", sectiunea Trimiteri) neschimbat.
- [ ] `python3 -m pytest tests/test_web_acasa.py -q` verde.
- **Verificare E2E**: browser HTMX pe `/` — Acasa nu mai afiseaza randul Ajutor; upload + Trimiteri intacte.
### US-002: Tabel Nomenclator cu aspectul tabelului Trimiteri
**Ca** operator **vreau** ca nomenclatorul sa arate identic cu Trimiteri **pentru ca** consistenta reduce
sarcina cognitiva.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_nomenclator.html`, `tests/test_web_nomenclator.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_nomenclator.py``test_nomenclator_grila_standard` (`.tablewrap` + `table`
+ antet `th` standard + `.pill` pe cod; empty-state `.empty`)
- **Acceptance criteria**:
- [ ] Foloseste aceeasi structura `.tablewrap > table` cu antet `th` standard ca `_submissions.html`.
- [ ] Codul prestatie ramane in `.pill`; coloanele aliniate, hover/aspect identice cu Trimiteri.
- [ ] Empty-state pastrat (`Nomenclator gol...`), in `.empty`.
- [ ] `python3 -m pytest tests/test_web_nomenclator.py -q` verde; AA light+dark (zero literali de culoare).
- **Verificare E2E**: browser — Nomenclator si Trimiteri arata din aceeasi familie vizuala in dark si light.
### US-003: Macro `autosend_toggle` compact (Auto / Manual)
**Ca** operator **vreau** un comutator scurt In coada, fara text repetat pe randuri **pentru ca** proza
inline ingrasa randurile si impinge actiunile afara din ecran.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_macros.html`, `tests/test_web_macros.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_macros.py``test_autosend_compact` (macro-ul randeaza control Auto/Manual,
pastreaza `name="auto_send" value="true"` + `form=` + starea `checked`, si NU mai contine propozitiile
explicative "La fisierele viitoare..."/"Nebifat = ...")
- **Acceptance criteria**:
- [ ] `autosend_toggle(form_id, checked)` randeaza un comutator compact etichetat **Auto** / **Manual**
(radio sau switch), nowrap, fara propozitii inline.
- [ ] Pastreaza EXACT `name="auto_send"`, `value="true"`, semantica de prezenta (bifat→true / absent→false),
`form="{{form_id}}"`, `checked` reflecta `checked`.
- [ ] Explicatia detaliata NU mai e in macro (se muta in panoul Ajutor, US-005). Tooltip scurt admis pe control.
- [ ] `python3 -m pytest tests/test_web_macros.py tests/test_import_e2e.py -q` verde (parserele backend nealterate).
- **Verificare E2E**: in fluxul import (mapcoloane) si in Mapari, comutatorul produce acelasi `auto_send`
bool ca azi (queued vs needs_review neschimbat).
### US-004: Model de stare a contului (`accounts.status`) + gate worker
**Ca** sistem **vreau** stari de cont distincte (pending/active/blocked/archived/deleted) **pentru ca**
adminul are nevoie de blocare/arhivare/stergere, nu doar activ/inactiv.
- **Depinde de**: —
- **Fisiere**: `app/schema.sql`, `app/db.py` (migrare defensiva), `app/users.py`, `app/worker/...` (gate `claim_one`),
`tests/test_account_status.py`, `tests/test_worker_*.py` (~5 fisiere)
- **Test intai (RED)**: `tests/test_account_status.py``test_migrare_deriva_status_din_active`,
`test_blocked_nu_e_claimuit`, `test_archived_nu_e_claimuit`, `test_dev_id1_protejat`
- **Acceptance criteria**:
- [ ] Coloana `accounts.status` TEXT cu CHECK pe `{pending,active,blocked,archived,deleted}` (stergere = soft,
`status='deleted'` + purjare de catre jobul de retentie T16); migrare **defensiva si idempotenta** (pattern
`_migrate` ca la `is_admin`), derivata din `active` la prima rulare: `active=1→active`, altfel `pending`.
- [ ] Helperi puri in `users.py`: `set_account_status(id, status)`, `delete_account(id)`, cu protectia
contului dev `id=1` (ridica/ignora, nu corupe).
- [ ] Worker `claim_one` gate-uieste pe `status='active'`, pastrand echivalenta cu `COALESCE(active,1)=1`
de azi (conturile blocked/archived NU sunt claimuite).
- [ ] `active` ramane consistent (`active=1 ⇔ status='active'`) cat timp coexista, fara regresie pe testele worker.
- [ ] `python3 -m pytest -q` verde (suita completa).
- **Verificare E2E**: marcheaza un cont `blocked` → submission-urile lui nu pleaca la RAR; `active` → pleaca.
### US-005: Tabel Mapari standardizat + panou Ajutor
**Ca** operator **vreau** tabelele Mapari compacte ca Trimiteri, cu actiunile vizibile fara scroll si ajutor
intr-un singur loc **pentru ca** acum sunt labartate si butoanele Salveaza/Sterge ies din ecran.
- **Depinde de**: US-003
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_mapari.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari.py``test_mapari_grila_compacta` (coloane inguste nowrap, actiuni
la dreapta), `test_mapari_ajutor_disclosure` (un singur `<details>`/link Ajutor in antet, fara proza pe randuri)
- **Acceptance criteria**:
- [ ] Cele 3 sectiuni (De rezolvat / Mapari salvate / Formate coloane) folosesc grila standard ca Trimiteri;
coloana "In coada" foloseste macro-ul compact din US-003.
- [ ] Butoanele **Salveaza**/**Sterge** vizibile fara scroll orizontal pe latime de dashboard normala
(coloana Actiuni la dreapta, nowrap); sub-text (`N blocate`, `acum: COD`) ca `muted` 12px sub valoare.
- [ ] Antetul "De rezolvat" contine un link/`<details>` **Ajutor** cu explicatia maparilor + Auto/Manual,
scrisa O SINGURA DATA; proza inline de pe randuri eliminata.
- [ ] CSRF, `hx-post`, `hx-target="#mapari-section"`, formularele si re-rezolvarea la edit cod — neschimbate.
- [ ] `python3 -m pytest tests/test_web_mapari.py -q` verde; AA light+dark.
- **Verificare E2E**: browser — mapezi o operatie (Salveaza vizibil fara scroll), comuti Auto/Manual, deschizi Ajutor;
submission blocat se deblocheaza la salvarea codului (comportament neschimbat).
### US-006: Meniu hamburger in header + context de autentificare
**Ca** utilizator **vreau** un meniu de cont in dreapta-sus cu Cont/Integrare/Nomenclator/Panou admin/logout
**pentru ca** acestea nu sunt lucru zilnic si aglomereaza tab-bar-ul.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/base.html`, `app/web/routes.py` (helper context partajat),
`tests/test_web_header_menu.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_header_menu.py``test_meniu_autentificat_are_linkuri_cont`,
`test_meniu_admin_doar_pentru_admin`, `test_meniu_neautentificat_fara_logout` (login/signup → fara linkuri de cont)
- **Acceptance criteria**:
- [ ] Iconita `☰` in header (langa toggle tema), `aria-label`, `aria-expanded`, `aria-controls`; dropdown ancorat
dreapta-sus; inchidere la click-afara + `Esc` (focus readus pe `☰`). Fara overlay.
- [ ] Continut autentificat: Cont, Integrare, Nomenclator, **Panou admin** (doar `is_admin`), separator,
**Iesi din cont** (form `POST /logout` cu `csrf_token`).
- [ ] `base.html` primeste `is_authenticated`/`is_admin`/`csrf_token` printr-un helper de context partajat (un
singur loc); **defensiv**: lipsa cheilor → meniu in stare neautentificata, nu eroare.
- [ ] Pe login/signup meniul nu arata linkuri de cont/logout.
- [ ] `python3 -m pytest tests/test_web_header_menu.py -q` verde.
- **Verificare E2E**: browser — `☰` deschide/inchide (Esc + click-afara), linkurile navigheaza corect, logout iese;
pe login meniul nu expune cont.
### US-007: Tab-bar redus la Acasa · Mapari
**Ca** operator **vreau** un tab-bar doar cu suprafetele de lucru zilnic **pentru ca** Cont/Integrare/Nomenclator
traiesc acum in meniul de cont.
- **Depinde de**: US-006
- **Fisiere**: `app/web/templates/dashboard.html`, `tests/test_web_dashboard_tabs.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_dashboard_tabs.py``test_tabbar_doar_acasa_mapari`,
`test_fragmente_mutate_inca_accesibile` (`/_fragments/{cont,integrare,nomenclator}` raman 200 + deep-link `?tab=`)
- **Acceptance criteria**:
- [ ] `tabs` in `dashboard.html` = doar `acasa`, `mapari`; badge-urile de contoare raman pe Mapari.
- [ ] Logout + link admin ad-hoc din coltul dreapta-sus al dashboard-ului eliminate (mutate in meniul US-006).
- [ ] Rutele `/_fragments/cont|integrare|nomenclator` + `?tab=` raman valide (accesate din meniu); zero rute moarte,
zero 404 pe deep-link existent.
- [ ] Navigarea ARIA cu sageti pe tab-bar ramane corecta cu 2 tab-uri.
- [ ] `python3 -m pytest tests/test_web_dashboard_tabs.py -q` verde.
- **Verificare E2E**: browser — tab-bar arata doar Acasa/Mapari; deschizi Nomenclator/Cont/Integrare din `☰`,
deep-link `/?tab=integrare` inca functioneaza.
### US-008: Rute admin pentru ciclul de viata al conturilor (block/archive/delete + bulk)
**Ca** admin **vreau** endpointuri care blocheaza/arhiveaza/sterg conturi, individual si in bulk **pentru ca**
panoul are nevoie sa actioneze pe selectie.
- **Depinde de**: US-004
- **Fisiere**: `app/web/routes.py` (rute `/admin/*`), `tests/test_admin_lifecycle.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_admin_lifecycle.py``test_block_archive_delete_single`,
`test_bulk_pe_lista_account_id`, `test_bulk_sare_contul_dev`, `test_non_admin_403`, `test_csrf_obligatoriu`
- **Acceptance criteria**:
- [ ] Rute `POST /admin/block`, `/admin/archive`, `/admin/delete` (+ pastreaza `activate`) sub `require_admin` + CSRF,
cu PRG (redirect inapoi la `/admin`), folosind helperii din US-004.
- [ ] Accepta o LISTA de `account_id` (bulk) si o singura tinta (per-rand) prin acelasi handler.
- [ ] Contul dev `id=1` e sarit in bulk (nu eroare) si refuzat individual; `delete` cere confirmare la nivel UI
(US-009) si purjeaza datele conform retentiei (GDPR/L.142).
- [ ] Non-admin → 403; lipsa CSRF → respins.
- [ ] `python3 -m pytest tests/test_admin_lifecycle.py -q` verde.
- **Verificare E2E**: POST autentificat ca admin pe fiecare verb (single + bulk) muta starea corect; contul dev neatins.
### US-009: Panou admin — selectie cu bife + bara bulk + actiuni per-rand
**Ca** admin **vreau** sa selectez conturi si sa aplic actiuni pe selectie **pentru ca** activarea/blocarea una
cate una e lenta.
- **Depinde de**: US-008
- **Fisiere**: `app/web/templates/admin.html`, `tests/test_web_admin.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_admin.py``test_admin_coloana_selectie_si_master`,
`test_bara_bulk_cu_cele_4_verbe`, `test_actiuni_per_rand`, `test_fara_nota_cont_dev`
- **Acceptance criteria**:
- [ ] Tabel conturi in asteptare (si analog active): coloana checkbox + master "Selecteaza tot"
(`aria-label` per rand + master).
- [ ] Bara de actiuni bulk (ascunsa pana la selectie) cu **Activeaza / Blocheaza / Arhiveaza / Sterge**;
`Sterge` cu `hx-confirm`/dialog; trimite lista de `account_id` la rutele US-008.
- [ ] Actiuni per-rand (kebab `...`) cu aceleasi verbe; `Sterge` cu `color:var(--err)` + confirmare.
- [ ] Nota "Cont dev implicit (id=1)" **eliminata** din pagina (protectia ramane in cod, US-004/US-008).
- [ ] `python3 -m pytest tests/test_web_admin.py -q` verde; AA light+dark; tabel in grila standard.
- **Verificare E2E**: browser ca admin — bifezi 2 conturi, bara bulk apare cu numarul selectat, Arhiveaza muta randurile;
Sterge cere confirmare; contul dev nu poate fi selectat-distrus.
## 4. Riscuri
- **Schema `accounts.status` (US-004)** = singura schimbare de date. Mitigare: migrare defensiva idempotenta (pattern
`_migrate` deja folosit la `accounts.active`/`users.is_admin`), derivata din `active`, cu echivalenta `active=1 ⇔
status='active'` pana cand `active` poate fi retras intr-o livrabila viitoare. Testele worker existente sunt plasa.
- **`base.html` partajat (US-006)**: e folosit de login/signup/admin/dashboard. Risc de context lipsa → meniu rupt.
Mitigare: helper de context partajat + defaulturi defensive (lipsa → neautentificat), test pe toate cele 4 pagini.
- **Coliziune pe fisiere intre stories** (lectia 5.1 clobber): US-006 si US-007 ating ambele zona de navigare
(`base.html` vs `dashboard.html`, plus scoaterea logout-ului ad-hoc din `dashboard.html`). Mitigare: US-007 depinde
de US-006 si ruleaza secvential (acelasi teammate recomandat), nu in worktree-uri paralele.
- **`delete` cont (US-008)**: actiune distructiva ireversibila. Mitigare: confirmare UI obligatorie, contul dev protejat,
purjare aliniata la retentia existenta (T16), nu stergere ad-hoc de date conexe fara plan.
- **Macro autosend (US-003)**: orice schimbare de `name`/semantica ar rupe tacit clasificarea queued/needs_review.
Mitigare: test care asereaza `name="auto_send"` + prezenta, plus `test_import_e2e` ramane verde.
## 5. Intrebari deschise — REZOLVATE (aprobare utilizator 2026-06-23)
- **Stergere cont** → **soft delete**: `status='deleted'`, scos imediat din toate listele, date purjate de jobul
de retentie existent (T16, GDPR/L.142). NU hard DELETE imediat (auditabil + fereastra de revenire).
- **Blocheaza vs Arhiveaza** → `blocked` = suspendare **reversibila**, contul ramane in liste, marcat vizibil,
worker nu trimite; `archived` = **scos din listele active**, date pastrate read-only. Etichete confirmate.
- **Comutatorul In coada** → **radio etichetat Auto / Manual** (explicit), nu switch on/off.
## 6. Valuri de executie (graful de dependente)
```
Val 1: [US-001] [US-002] [US-003] [US-004] ← fara dependente, fisiere disjuncte → paralel
Val 2: [US-005] ← dep US-003 (macro)
[US-006] ← fara dep (navigare/header)
[US-008] ← dep US-004 (rute admin pe model stare)
Val 3: [US-007] ← dep US-006 (acelasi fisier de navigare → secvential, NU worktree paralel)
[US-009] ← dep US-008 (UI admin pe rutele de lifecycle)
```

View File

@@ -102,12 +102,13 @@ def test_acasa_fara_linkuri_ajutor(client):
assert 'href="/?tab=coada"' not in r.text
def test_acasa_pastreaza_wayfinding_mapari_coduri(client):
"""Wayfinding-ul pastreaza 'Mapari' si 'Coduri RAR'."""
def test_acasa_fara_wayfinding_ajutor(client):
"""US-001 (5.5): randul 'Ajutor' (wayfinding Mapari/Coduri RAR) eliminat din Acasa —
navigarea traieste in tab-bar si in meniul de cont."""
r = client.get("/?tab=acasa")
html = r.text
assert 'href="/?tab=mapari"' in html
assert "Coduri RAR" in html
assert "Ajutor:" not in html
assert "Coduri RAR" not in html
def test_badge_trimiteri_scoped_pe_acasa(client):

View File

@@ -0,0 +1,160 @@
"""Teste US-004 (PRD 5.5): model de stare a contului `accounts.status` + helperi.
Acopera: derivarea status din active la migrare, invariantul active=1 <=> status='active',
helperii set_status/delete_account, protectia contului de sistem id=1, excluderea 'deleted'
din listare.
"""
from __future__ import annotations
import os
import tempfile
import pytest
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_status.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _status(conn, acct_id):
return conn.execute("SELECT status, active FROM accounts WHERE id=?", (acct_id,)).fetchone()
def test_create_account_activ_status_active(conn):
from app.accounts import create_account
acct_id = create_account(conn, "Service X", active=True)
row = _status(conn, acct_id)
assert row["status"] == "active" and row["active"] == 1
def test_create_account_inactiv_status_pending(conn):
from app.accounts import create_account
acct_id = create_account(conn, "Service Y", active=False)
row = _status(conn, acct_id)
assert row["status"] == "pending" and row["active"] == 0
def test_default_account_id1_active(conn):
row = _status(conn, 1)
assert row["status"] == "active" and row["active"] == 1
def test_set_status_mentine_invariant_active(conn):
from app.accounts import create_account, set_status
acct_id = create_account(conn, "Service Z", active=True)
for st, exp_active in [("blocked", 0), ("archived", 0), ("active", 1), ("pending", 0)]:
set_status(conn, acct_id, st)
row = _status(conn, acct_id)
assert row["status"] == st and row["active"] == exp_active
def test_set_status_invalid_ridica(conn):
from app.accounts import create_account, set_status
acct_id = create_account(conn, "Service W", active=True)
with pytest.raises(ValueError):
set_status(conn, acct_id, "inexistent")
def test_set_status_cont_inexistent_ridica(conn):
from app.accounts import set_status
with pytest.raises(ValueError):
set_status(conn, 9999, "blocked")
def test_set_active_mirror_status(conn):
from app.accounts import create_account, set_active
acct_id = create_account(conn, "Service M", active=True)
set_active(conn, acct_id, False)
assert _status(conn, acct_id)["status"] == "pending"
set_active(conn, acct_id, True)
assert _status(conn, acct_id)["status"] == "active"
def test_delete_account_soft(conn):
from app.accounts import create_account, delete_account, list_accounts
acct_id = create_account(conn, "Service D", active=True)
delete_account(conn, acct_id)
assert _status(conn, acct_id)["status"] == "deleted"
# exclus din listare
assert all(a["id"] != acct_id for a in list_accounts(conn))
def test_delete_purjeaza_pii_si_elibereaza_cui(conn):
"""Stergerea soft purjeaza creds RAR + revoca cheile API + elibereaza CUI (re-inregistrabil)."""
from app.accounts import create_account, delete_account, list_accounts
acct_id = create_account(conn, "Service GDPR", cui="RO12345", active=True)
conn.execute("UPDATE accounts SET rar_creds_enc='secret_enc' WHERE id=?", (acct_id,))
conn.execute("INSERT INTO api_keys (account_id, key_hash, active) VALUES (?, 'h', 1)", (acct_id,))
conn.commit()
delete_account(conn, acct_id)
row = conn.execute("SELECT status, rar_creds_enc, cui FROM accounts WHERE id=?",
(acct_id,)).fetchone()
assert row["status"] == "deleted"
assert row["rar_creds_enc"] is None, "creds RAR trebuie purjate la stergere"
assert row["cui"] is None, "CUI trebuie eliberat la stergere"
key_active = conn.execute("SELECT active FROM api_keys WHERE account_id=?", (acct_id,)).fetchone()
assert key_active["active"] == 0, "cheile API trebuie revocate"
# CUI eliberat -> se poate re-inregistra acelasi CUI
new_id = create_account(conn, "Service Nou", cui="RO12345", active=True)
assert new_id != acct_id
assert all(a["id"] != acct_id for a in list_accounts(conn))
def test_dev_id1_protejat_de_status_negativ(conn):
from app.accounts import set_status, delete_account
for verb in ("blocked", "archived", "deleted"):
with pytest.raises(ValueError):
set_status(conn, 1, verb)
with pytest.raises(ValueError):
delete_account(conn, 1)
# ramane activ
assert _status(conn, 1)["status"] == "active"
def test_migrare_deriva_status_din_active(conn):
"""DB veche fara coloana status -> _migrate o adauga si o deriva din active.
Pornim de la schema reala (fixtura `conn` a rulat init_db), reconstruim tabela accounts
FARA coloana status (simuleaza DB pre-5.5), apoi rulam _migrate.
"""
from app.db import _migrate
# Reconstruim accounts fara `status` (rebuild de tabela — singura cale in SQLite vechi).
conn.executescript(
"""
PRAGMA foreign_keys=OFF;
CREATE TABLE accounts_legacy (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, cui TEXT,
active INTEGER NOT NULL DEFAULT 1, rar_creds_enc TEXT, created_at TEXT
);
INSERT INTO accounts_legacy (id, name, cui, active, rar_creds_enc, created_at)
SELECT id, name, cui, active, rar_creds_enc, created_at FROM accounts;
DROP TABLE accounts;
ALTER TABLE accounts_legacy RENAME TO accounts;
"""
)
conn.execute("INSERT INTO accounts (name, active) VALUES ('Activ', 1)")
conn.execute("INSERT INTO accounts (name, active) VALUES ('Inactiv', 0)")
conn.commit()
assert "status" not in {r["name"] for r in conn.execute("PRAGMA table_info(accounts)")}
_migrate(conn)
conn.commit()
rows = {r["name"]: r["status"] for r in conn.execute("SELECT name, status FROM accounts")}
assert rows["default"] == "active" # id=1
assert rows["Activ"] == "active"
assert rows["Inactiv"] == "pending"

View File

@@ -112,4 +112,4 @@ def test_list_accounts_ordonat_fara_creds(conn):
assert ids == sorted(ids)
for r in rows:
assert "rar_creds_enc" not in r
assert set(r.keys()) == {"id", "name", "cui", "active", "created_at"}
assert set(r.keys()) == {"id", "name", "cui", "active", "status", "created_at"}

View File

@@ -0,0 +1,137 @@
"""Teste US-008 (PRD 5.5): rute admin pentru ciclul de viata al conturilor —
block/archive/delete + bulk pe lista account_id, require_admin + CSRF + PRG, dev id=1 protejat.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_lifecycle.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
def _csrf(client, url="/admin"):
resp = client.get(url, follow_redirects=True)
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, f"csrf negasit in {url}"
return m.group(1)
def _signup(client, name, email, password="parola_test_001"):
tok = _csrf(client, "/signup")
resp = client.post("/signup", data={"name": name, "email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
assert resp.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
(email,)).fetchone()
return int(row["account_id"])
finally:
conn.close()
def _make_admin(account_id):
from app.db import get_connection
from app.users import set_admin
conn = get_connection()
try:
set_admin(conn, account_id, is_admin=True)
conn.commit()
finally:
conn.close()
def _login(client, email, password="parola_test_001"):
tok = _csrf(client, "/login")
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": tok})
assert resp.status_code == 303
def _status(account_id):
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT status FROM accounts WHERE id=?", (account_id,)).fetchone()
return row["status"] if row else None
finally:
conn.close()
def _admin_login(client):
admin_id = _signup(client, "Admin SA", "admin@test.ro")
_make_admin(admin_id)
_login(client, "admin@test.ro")
return admin_id
@pytest.mark.parametrize("verb,expected", [
("block", "blocked"),
("archive", "archived"),
("delete", "deleted"),
])
def test_lifecycle_single(client, verb, expected):
target = _signup(client, "Tinta SRL", "tinta@test.ro")
_admin_login(client)
tok = _csrf(client)
resp = client.post(f"/admin/{verb}", data={"account_id": target, "csrf_token": tok})
assert resp.status_code == 303
assert _status(target) == expected
def test_bulk_pe_lista_account_id(client):
a = _signup(client, "A SRL", "a@test.ro")
b = _signup(client, "B SRL", "b@test.ro")
_admin_login(client)
tok = _csrf(client)
resp = client.post("/admin/block", data={"account_id": [a, b], "csrf_token": tok})
assert resp.status_code == 303
assert _status(a) == "blocked" and _status(b) == "blocked"
def test_bulk_sare_contul_dev(client):
target = _signup(client, "Tinta SRL", "t2@test.ro")
_admin_login(client)
tok = _csrf(client)
# include id=1 (cont de sistem) in selectie -> sarit, fara eroare; tinta procesata
resp = client.post("/admin/archive", data={"account_id": [1, target], "csrf_token": tok})
assert resp.status_code == 303
assert _status(1) == "active", "contul dev id=1 trebuie sa ramana neatins"
assert _status(target) == "archived"
def test_non_admin_403(client):
target = _signup(client, "Tinta SRL", "t3@test.ro")
_signup(client, "Neadmin SRL", "plain@test.ro")
_login(client, "plain@test.ro")
# csrf de pe o pagina accesibila non-admin
tok = _csrf(client, "/")
resp = client.post("/admin/block", data={"account_id": target, "csrf_token": tok})
assert resp.status_code == 403
def test_csrf_obligatoriu(client):
target = _signup(client, "Tinta SRL", "t4@test.ro")
_admin_login(client)
resp = client.post("/admin/delete", data={"account_id": target}) # fara csrf_token
assert resp.status_code != 303
assert _status(target) != "deleted"

View File

@@ -103,13 +103,13 @@ def _macro_html(checked: bool = True, form_id: str = "") -> str:
# --- markup / copy ---
def test_comutator_coada_prezent():
"""Textul tinteste COADA ("in coada"/"verificare"), NU "trimite"/"Manual" gol."""
"""5.5 (supersede framing 3.6): comutator etichetat Auto/Manual, compact.
Send-safety pastrata prin tooltip/Ajutor (Manual = tine pentru verificare; nimic nu
pleaca la RAR pana confirmi). Semantica de prezenta name=auto_send nealterata."""
html = _macro_html()
assert "in coada" in html, "comutatorul trebuie sa vorbeasca despre coada"
assert "verificare" in html, "optiunea de verificare manuala trebuie prezenta"
assert "name=\"auto_send\"" in html and 'value="true"' in html
# framing periculos interzis (citit global = send-safety):
assert "Manual" not in html, "fara 'Manual' gol (sugereaza bypass al confirmarii RAR)"
assert "Auto" in html and "Manual" in html, "ambele stari etichetate"
assert "verificare" in html, "sensul de verificare manuala trebuie pastrat (tooltip/ajutor)"
assert "trimite" not in html.lower(), "fara cuvantul 'trimite' izolat in eticheta"
assert "auto-send" not in html, "jargonul 'auto-send' trebuie inlocuit"
@@ -179,8 +179,9 @@ def test_comutator_in_tab_mapari(client):
_login(client, "tm@test.com")
resp = client.get("/?tab=mapari")
assert resp.status_code == 200
assert "Pune automat in coada" in resp.text
assert "aceasta operatie" in resp.text
# 5.5: comutatorul compact Auto/Manual e prezent in tabul Mapari
assert 'name="auto_send"' in resp.text
assert "Manual" in resp.text and "Auto" in resp.text
def test_comutator_in_panou_preview(client):
@@ -210,5 +211,6 @@ def test_comutator_in_panou_preview(client):
})
assert r.status_code == 200
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
assert "Pune automat in coada" in r.text
assert "aceasta operatie" in r.text
# 5.5: comutatorul compact Auto/Manual e prezent si in panoul de mapare din preview
assert 'name="auto_send"' in r.text
assert "Manual" in r.text and "Auto" in r.text

77
tests/test_errors.py Normal file
View File

@@ -0,0 +1,77 @@
"""Teste pentru app/errors.py — catalog central de erori (US-001 / PRD 5.4).
Urmeaza TDD: testele sunt scrise INAINTE de implementare.
"""
from __future__ import annotations
import pytest
from app.errors import CATALOG, eroare
# ---------------------------------------------------------------------------
# test_catalog_complet
# ---------------------------------------------------------------------------
def test_catalog_complet():
"""Fiecare intrare din CATALOG are 'problema' si 'fix' ne-goale."""
assert len(CATALOG) >= 24, "CATALOG trebuie sa contina cel putin 24 de coduri"
for cod, entry in CATALOG.items():
assert "problema" in entry, f"Lipseste 'problema' pentru codul {cod!r}"
assert "fix" in entry, f"Lipseste 'fix' pentru codul {cod!r}"
assert isinstance(entry["problema"], str) and entry["problema"].strip(), (
f"'problema' goala pentru codul {cod!r}"
)
assert isinstance(entry["fix"], str) and entry["fix"].strip(), (
f"'fix' gol pentru codul {cod!r}"
)
# ---------------------------------------------------------------------------
# test_eroare_construieste_3niveluri
# ---------------------------------------------------------------------------
def test_eroare_construieste_3niveluri():
"""eroare() intoarce dict cu exact cheile asteptate si valorile corecte."""
rezultat = eroare("VIN_FORMAT", field="vin", cauza="VIN-ul are 15 caractere")
chei_asteptate = {"field", "cod", "problema", "cauza", "fix", "message"}
assert set(rezultat.keys()) == chei_asteptate, (
f"Cheile obtinute: {set(rezultat.keys())} — asteptate: {chei_asteptate}"
)
assert rezultat["cod"] == "VIN_FORMAT"
assert rezultat["field"] == "vin"
assert rezultat["cauza"] == "VIN-ul are 15 caractere"
assert rezultat["problema"] == CATALOG["VIN_FORMAT"]["problema"]
assert rezultat["fix"] == CATALOG["VIN_FORMAT"]["fix"]
# message == cauza cand cauza este dat
assert rezultat["message"] == "VIN-ul are 15 caractere"
# ---------------------------------------------------------------------------
# test_message_back_compat
# ---------------------------------------------------------------------------
def test_message_back_compat():
"""message == cauza cand cauza e dat; message == problema cand cauza lipseste."""
# Cu cauza
cu_cauza = eroare("DATA_FORMAT", cauza="data_primita=31/06/2026")
assert cu_cauza["message"] == "data_primita=31/06/2026"
# Fara cauza
fara_cauza = eroare("DATA_FORMAT")
assert fara_cauza["message"] == CATALOG["DATA_FORMAT"]["problema"]
# cauza din dict e None sau egala cu problema
assert fara_cauza["cauza"] == CATALOG["DATA_FORMAT"]["problema"]
# ---------------------------------------------------------------------------
# test_cod_necunoscut_ridica
# ---------------------------------------------------------------------------
def test_cod_necunoscut_ridica():
"""eroare() pe cod absent din CATALOG ridica KeyError."""
with pytest.raises(KeyError):
eroare("INEXISTENT")

335
tests/test_import_errors.py Normal file
View File

@@ -0,0 +1,335 @@
"""Teste US-005 (PRD 5.4): erori de import imbracate pe 3 niveluri.
Verifica ca fiecare HTTPException de import in scop are `detail` superset:
- cheile vechi: `error`, `message`, context specific (sheets/found/n_ok)
- cheile noi din catalog: `cod`, `problema`, `cauza`, `fix`
"""
from __future__ import annotations
import io
import os
import tempfile
import openpyxl
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixture client #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client FastAPI cu DB temporara izolata per test."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "err.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
_HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
_ROW_OK = ["WVWZZZ1KZAW000123", "B999TST", "2026-06-15", "123456", "Revizie"]
def _make_xlsx(rows: list[list], extra_sheets: list[str] | None = None) -> bytes:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Sheet1"
for row in rows:
ws.append(row)
if extra_sheets:
for name in extra_sheets:
ws2 = wb.create_sheet(name)
ws2.append(_HEADER)
ws2.append(_ROW_OK)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _upload(client: TestClient, data: bytes, filename: str = "test.xlsx"):
return client.post(
"/v1/import",
files={"file": (filename, io.BytesIO(data), "application/octet-stream")},
)
def _assert_3niveluri(detail: dict, cod: str) -> None:
"""Verifica prezenta cheilor de pe 3 niveluri."""
assert detail.get("cod") == cod, f"Asteptat cod={cod!r}, primit {detail.get('cod')!r}"
assert "problema" in detail, "Lipseste cheia 'problema'"
assert "cauza" in detail, "Lipseste cheia 'cauza'"
assert "fix" in detail, "Lipseste cheia 'fix'"
assert "error" in detail, "Lipseste cheia veche 'error'"
assert "message" in detail, "Lipseste cheia veche 'message'"
# --------------------------------------------------------------------------- #
# 1. Fisier prea mare -> IMPORT_FISIER_PREA_MARE #
# --------------------------------------------------------------------------- #
class TestFisierPreaMare3Niveluri:
def test_fisier_prea_mare_3niveluri(self, client):
"""Upload fisier >5MB -> 413 cu cod IMPORT_FISIER_PREA_MARE + superset."""
# 5 MB + 100 bytes de junk (nu e un fisier valid, dar dimensiunea declanseaza
# FileTooLarge inainte de parsare xlsx)
data = b"PK" + b"X" * (5 * 1024 * 1024 + 100)
r = _upload(client, data, "mare.xlsx")
# Poate sa returneze 413 sau 422 (depinde daca e prins ca FileTooLarge sau altceva)
# Dupa implementare trebuie sa fie 413 pentru FileTooLarge
assert r.status_code == 413, f"Asteptat 413, primit {r.status_code}: {r.text}"
detail = r.json()["detail"]
assert detail["error"] == "file_too_large"
_assert_3niveluri(detail, "IMPORT_FISIER_PREA_MARE")
def test_fisier_prea_mare_peste_5000_randuri(self, client):
"""Upload xlsx cu >5000 randuri -> 413 cu cod IMPORT_FISIER_PREA_MARE."""
wb = openpyxl.Workbook()
ws = wb.active
ws.append(_HEADER)
for i in range(5001):
ws.append([f"WVWZZZ1KZAW{i:06d}", f"B{i:04d}TST", "2026-06-15", str(100000 + i), "Revizie"])
buf = io.BytesIO()
wb.save(buf)
r = _upload(client, buf.getvalue(), "mare.xlsx")
assert r.status_code == 413, f"Asteptat 413, primit {r.status_code}: {r.text}"
detail = r.json()["detail"]
assert detail["error"] == "file_too_large"
_assert_3niveluri(detail, "IMPORT_FISIER_PREA_MARE")
# --------------------------------------------------------------------------- #
# 2. Antet neclar -> IMPORT_ANTET_NECLAR #
# --------------------------------------------------------------------------- #
class TestAntetNeclar3Niveluri:
def test_antet_neclar_3niveluri(self, client):
"""CSV fara antet recunoscut -> 422 cu IMPORT_ANTET_NECLAR + 'found' pastrat."""
# CSV cu un singur camp pe prima linie - declanseaza HeaderError
csv_data = b"ValoareAleatoare\n123\n456\n"
r = _upload(client, csv_data, "test.csv")
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
detail = r.json()["detail"]
assert detail["error"] == "header_error"
assert "found" in detail, "Cheia 'found' trebuie pastrata"
_assert_3niveluri(detail, "IMPORT_ANTET_NECLAR")
# --------------------------------------------------------------------------- #
# 3. Encoding nesuportat -> IMPORT_ENCODING #
# --------------------------------------------------------------------------- #
class TestEncoding3Niveluri:
def test_encoding_3niveluri(self, client):
"""CSV in encoding nesuportat (UTF-16 fara BOM detectabil) -> 422 IMPORT_ENCODING."""
# UTF-16 Little Endian fara BOM — va esua la decodare
text = "VIN;Nr\nABC;DEF\n"
data_utf16 = text.encode("utf-16-le") # fara BOM, encodingul va esua
r = _upload(client, data_utf16, "test.csv")
# Fie 422 encoding_error, fie parsare ciudata (depinde de sniff)
# Daca nu declanseaza UnicodeDecodeError (parsatorul e robust),
# testam direct prin injectie: fisier cu bytes invalizi UTF
data_latin = b"VIN;Nr\n" + bytes([0xFF, 0xFE, 0x00]) + b"\n"
r2 = _upload(client, data_latin, "test2.csv")
if r2.status_code == 422:
detail = r2.json()["detail"]
if detail.get("error") == "encoding_error":
_assert_3niveluri(detail, "IMPORT_ENCODING")
return
# Alternativa: fisier cu bytes complet invalizi pentru toate encodingurile incercate
invalid_bytes = b"\xff\xfe" + bytes(range(128, 256)) * 10
r3 = _upload(client, invalid_bytes, "test3.csv")
if r3.status_code == 422:
detail = r3.json()["detail"]
if detail.get("error") == "encoding_error":
_assert_3niveluri(detail, "IMPORT_ENCODING")
# --------------------------------------------------------------------------- #
# 4. Fisier nerecunoscut -> IMPORT_FISIER_NERECUNOSCUT #
# --------------------------------------------------------------------------- #
class TestFisierNerecunoscut3Niveluri:
def test_fisier_nerecunoscut_3niveluri(self, client):
"""Fisier binar junk -> 422 cu IMPORT_FISIER_NERECUNOSCUT."""
r = _upload(client, b"\x00\x01\x02\x03binar", "test.xlsx")
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
detail = r.json()["detail"]
assert detail["error"] == "invalid_file"
_assert_3niveluri(detail, "IMPORT_FISIER_NERECUNOSCUT")
# --------------------------------------------------------------------------- #
# 5. Multiple sheets -> IMPORT_MULTIPLE_SHEETS #
# --------------------------------------------------------------------------- #
class TestMultipleSheets3Niveluri:
def test_multiple_sheets_3niveluri(self, client):
"""Xlsx cu >1 sheet non-gol -> 422 cu IMPORT_MULTIPLE_SHEETS + 'sheets' pastrat."""
data = _make_xlsx([_HEADER, _ROW_OK], extra_sheets=["Iulie"])
r = _upload(client, data, "multi.xlsx")
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
detail = r.json()["detail"]
assert detail["error"] == "multiple_sheets"
assert "sheets" in detail, "Cheia 'sheets' trebuie pastrata"
assert "Sheet1" in detail["sheets"] or "Iulie" in detail["sheets"]
_assert_3niveluri(detail, "IMPORT_MULTIPLE_SHEETS")
# --------------------------------------------------------------------------- #
# 6. Fara mapare coloane -> IMPORT_FARA_MAPARE_COLOANE #
# --------------------------------------------------------------------------- #
class TestFaraMapareColoane3Niveluri:
def test_fara_mapare_coloane_3niveluri(self, client):
"""Preview fara mapare configurata -> 422 cu IMPORT_FARA_MAPARE_COLOANE."""
data = _make_xlsx([_HEADER, _ROW_OK])
r_up = _upload(client, data, "test.xlsx")
assert r_up.status_code == 200, r_up.text
import_id = r_up.json()["import_id"]
# Preview fara a salva maparea de coloane
r = client.get(f"/v1/import/{import_id}/preview")
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
detail = r.json()["detail"]
assert detail["error"] == "no_column_mapping"
_assert_3niveluri(detail, "IMPORT_FARA_MAPARE_COLOANE")
# --------------------------------------------------------------------------- #
# 7. Confirmare gresita -> IMPORT_CONFIRMARE_GRESITA #
# --------------------------------------------------------------------------- #
class TestConfirmareGresita3Niveluri:
def _seed_op(self) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
"VALUES ('OE-1','Verificare')"
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (1, 'Revizie', 'OE-1', 1)"
)
conn.commit()
finally:
conn.close()
def test_confirmare_gresita_3niveluri(self, client):
"""Commit cu n_confirmat gresit -> 422 cu IMPORT_CONFIRMARE_GRESITA + n_ok pastrat."""
self._seed_op()
data = _make_xlsx([_HEADER, _ROW_OK])
r_up = _upload(client, data, "test.xlsx")
assert r_up.status_code == 200, r_up.text
import_id = r_up.json()["import_id"]
# Salveaza maparea
client.post(f"/v1/import/{import_id}/column-mapping", json={
"json_mapare": {
"VIN": "vin",
"Nr inmatriculare": "nr_inmatriculare",
"Data prestatie": "data_prestatie",
"Odometru final": "odometru_final",
"Operatie": "operatie",
},
"format_data": "YYYY-MM-DD",
})
# Preview pentru a rezolva randurile
r_prev = client.get(f"/v1/import/{import_id}/preview")
assert r_prev.status_code == 200, r_prev.text
# Commit cu numarul GRESIT (0 in loc de cel real)
r = client.post(f"/v1/import/{import_id}/commit", json={
"n_confirmat": 99, # gresit
"reviewed_rows": [],
})
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
detail = r.json()["detail"]
assert detail["error"] == "confirmare_gresita"
assert "n_ok" in detail, "Cheia 'n_ok' trebuie pastrata"
_assert_3niveluri(detail, "IMPORT_CONFIRMARE_GRESITA")
# --------------------------------------------------------------------------- #
# 8. Override ilizibil -> IMPORT_OVERRIDE_ILIZIBIL #
# --------------------------------------------------------------------------- #
class TestOverrideIlizibil3Niveluri:
def test_override_ilizibil_forma_dict(self):
"""Verifica direct ca detail-ul generat pentru override ilizibil are forma dict corecta.
In loc sa simulam un override corupt in DB (care necesita manipulare Fernet directa),
testam ca forma asteptata a detail-ului este un dict superset corect.
Testul de integrare completa este omis deoarece necesita injectie directs in DB.
"""
from app import errors
# Simuleaza ce face router-ul la eroarea de override ilizibil
msg = "override curent ilizibil; editare anulata"
detail = {
"error": "override_ilizibil",
"message": msg,
**errors.eroare("IMPORT_OVERRIDE_ILIZIBIL", cauza=msg),
}
assert detail["error"] == "override_ilizibil"
assert detail["message"] == msg
assert detail["cod"] == "IMPORT_OVERRIDE_ILIZIBIL"
assert "problema" in detail
assert "cauza" in detail
assert detail["cauza"] == msg
assert "fix" in detail
def test_override_ilizibil_via_api(self, client, monkeypatch):
"""Test integrare: override ilizibil returnat ca dict superset (nu string)."""
import io as _io
# Upload + mapare
data = _make_xlsx([_HEADER, _ROW_OK])
r_up = _upload(client, data, "test.xlsx")
assert r_up.status_code == 200, r_up.text
import_id = r_up.json()["import_id"]
# Injecteaza direct un override_json corupt in DB
import sqlite3
from app.config import get_settings
db_path = get_settings().db_path
conn_raw = sqlite3.connect(db_path)
try:
conn_raw.execute(
"UPDATE import_rows SET override_json=? WHERE batch_id=? AND row_index=0",
("TOKEN_CORUPT_INVALID", import_id),
)
conn_raw.commit()
finally:
conn_raw.close()
# Cerere de editare pe randul cu override corupt
r = client.post(f"/v1/import/{import_id}/rand/0/editeaza", json={"vin": "WVWZZZ1KZAW000999"})
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
detail = r.json()["detail"]
# Dupa US-005: detail trebuie sa fie dict, nu string
assert isinstance(detail, dict), f"detail trebuie sa fie dict, primit: {type(detail)}"
assert detail.get("error") == "override_ilizibil"
assert detail.get("cod") == "IMPORT_OVERRIDE_ILIZIBIL"
assert "problema" in detail
assert "fix" in detail

View File

@@ -161,3 +161,89 @@ def test_op_mapat_declanseaza_regula_odometru(client):
def test_item_fara_cod_si_fara_op_e_422(client):
r = client.post("/v1/prezentari", json=_body([{"denumire": "doar text"}]))
assert r.status_code == 422
# --------------------------------------------------------------------------- #
# US-003: 3 niveluri in classify_prezentare (needs_mapping) #
# --------------------------------------------------------------------------- #
def test_unmapped_are_3niveluri(client):
"""cod_op_service necunoscut -> needs_mapping; rar_error are cheie 'unmapped'
PASTRATA + campurile COD_NEMAPAT (cod/problema/cauza/fix)."""
import json
from app.mapping import classify_prezentare
content = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_op_service": "OP_NECUNOSCUT", "denumire": "Reparatie necunoscuta"}],
}
mapping = {}
mapping_meta = {}
res = classify_prezentare(content, mapping, mapping_meta)
assert res["status"] == "needs_mapping"
err = json.loads(res["rar_error"])
# Cheia originala pastrata
assert "unmapped" in err
assert len(err["unmapped"]) == 1
assert err["unmapped"][0]["cod_op_service"] == "OP_NECUNOSCUT"
# 3 niveluri prezente
assert err["cod"] == "COD_NEMAPAT"
assert err["problema"]
assert err["cauza"]
assert err["fix"]
def test_auto_send_oprit_3niveluri(client):
"""Mapare cu auto_send=0 -> needs_mapping; rar_error are cheie 'auto_send'
PASTRATA + campurile AUTO_SEND_OPRIT (cod/problema/cauza/fix)."""
import json
from app.mapping import classify_prezentare
content = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_op_service": "OP_REVIEW", "denumire": "Operatie cu review"}],
}
mapping = {"OP_REVIEW": "OE-1"}
mapping_meta = {"OP_REVIEW": {"cod_prestatie": "OE-1", "auto_send": False}}
res = classify_prezentare(content, mapping, mapping_meta)
assert res["status"] == "needs_mapping"
err = json.loads(res["rar_error"])
# Cheia originala pastrata
assert "auto_send" in err
# 3 niveluri prezente
assert err["cod"] == "AUTO_SEND_OPRIT"
assert err["problema"]
assert err["cauza"]
assert err["fix"]
def test_needs_data_pass_through(client):
"""VIN invalid -> needs_data; rar_error = array cu erori care au cod/problema/fix (US-002)."""
import json
from app.mapping import classify_prezentare
content = {
"vin": "VIN_INVALID_XXXXXXXXX", # nu trece regex
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
mapping = {}
mapping_meta = {}
res = classify_prezentare(content, mapping, mapping_meta)
assert res["status"] == "needs_data"
erori = json.loads(res["rar_error"])
assert isinstance(erori, list)
assert len(erori) >= 1
# Fiecare eroare are cele 3 niveluri (pass-through US-002)
for e in erori:
assert "cod" in e, f"lipseste 'cod' in {e}"
assert "problema" in e, f"lipseste 'problema' in {e}"
assert "fix" in e, f"lipseste 'fix' in {e}"

186
tests/test_tema.py Normal file
View File

@@ -0,0 +1,186 @@
"""Teste US-001 + US-002 (PRD 5.3): Light/Dark mode comutator tema.
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
"""
from __future__ import annotations
import os
import re
import tempfile
from pathlib import Path
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "tema.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _create_user(email: str = "tema@test.com", password: str = "parolasecreta"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Tema", active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit in /login"
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
follow_redirects=False,
)
assert resp.status_code == 303, f"Login esuat: {resp.status_code}"
# ── US-001: Tema light ─────────────────────────────────────────────────────────
def test_paleta_light_definita(client):
"""HTML de la GET /login contine un selector [data-theme="light"] care redefineste
cel putin --bg, --card, --ink, --muted, --line."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert '[data-theme="light"]' in html, 'Lipseste blocul [data-theme="light"] in HTML'
light_block = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', html, re.DOTALL)
assert light_block, 'Nu am gasit blocul CSS [data-theme="light"] { ... }'
block = light_block.group(1)
for var in ("--bg", "--card", "--ink", "--muted", "--line"):
assert var in block, f"Variabila {var} lipseste din blocul [data-theme=\"light\"]"
def test_dark_ramane_default(client):
""":root contine inca paleta dark exacta: --bg:#0f1115, --card:#181b22, --ink:#e6e9ef."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "--bg:#0f1115" in html, "Paleta dark --bg:#0f1115 a fost modificata sau stearsa"
assert "--card:#181b22" in html, "Paleta dark --card:#181b22 a fost modificata sau stearsa"
assert "--ink:#e6e9ef" in html, "Paleta dark --ink:#e6e9ef a fost modificata sau stearsa"
def test_suprafete_fara_fundal_hardcodat(client):
"""<style> NU mai contine literalii hex dark-fix #241a1a, #201c0f, #16241c
(banner eroare / banner warn / flash facute theme-aware)."""
resp = client.get("/login")
assert resp.status_code == 200
style_match = re.search(r'<style>(.*?)</style>', resp.text, re.DOTALL)
assert style_match, "<style> negasit in HTML"
style = style_match.group(1)
assert "#241a1a" not in style, "Fundalul hardcodat #241a1a (banner eroare) inca in <style>"
assert "#201c0f" not in style, "Fundalul hardcodat #201c0f (banner warn) inca in <style>"
assert "#16241c" not in style, "Fundalul hardcodat #16241c (flash) inca in <style>"
# ── US-002: Comutator tema + anti-FOUC ────────────────────────────────────────
def test_script_antifouc_in_head_inainte_de_style(client):
"""<head> contine un <script> care citeste localStorage (cheia 'theme') si seteaza
data-theme pe document.documentElement, pozitionat INAINTE de <style>."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
head_match = re.search(r'<head>(.*?)</head>', html, re.DOTALL | re.IGNORECASE)
assert head_match, "<head> negasit in HTML"
head = head_match.group(1)
style_pos = head.find('<style>')
assert style_pos >= 0, "<style> negasit in <head>"
head_before_style = head[:style_pos]
assert 'localStorage' in head_before_style, \
"Scriptul anti-FOUC (cu localStorage) trebuie sa fie in <head> INAINTE de <style>"
assert 'theme' in head_before_style, \
"Scriptul anti-FOUC trebuie sa citeasca cheia 'theme' din localStorage"
assert ('data-theme' in head_before_style or 'dataset.theme' in head_before_style), \
"Scriptul anti-FOUC trebuie sa seteze data-theme pe documentElement"
def test_buton_toggle_in_header_cu_eticheta(client):
"""<header> contine un <button> de comutare cu aria-label descriptiv (contine 'tema')."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
header_match = re.search(r'<header>(.*?)</header>', html, re.DOTALL | re.IGNORECASE)
assert header_match, "<header> negasit in HTML"
header = header_match.group(1)
labels = re.findall(r'<button[^>]*aria-label=["\']([^"\']+)["\']', header, re.IGNORECASE)
assert labels, "<button> cu aria-label negasit in <header>"
assert any('tema' in lbl.lower() for lbl in labels), \
f"Niciun <button> in <header> cu aria-label care contine 'tema'. Gasit: {labels}"
def test_toggle_pe_login_si_dashboard(client):
"""Butonul toggle apare atat pe /login (neautentificat) cat si pe dashboard (autentificat)."""
# Pe /login
resp = client.get("/login")
assert resp.status_code == 200
header_match = re.search(r'<header>(.*?)</header>', resp.text, re.DOTALL | re.IGNORECASE)
assert header_match, "<header> negasit pe /login"
assert re.search(
r'<button[^>]*aria-label=["\'][^"\']*tema[^"\']*["\']',
header_match.group(1),
re.IGNORECASE,
), "Butonul toggle lipseste pe /login"
# Pe dashboard (autentificat)
_create_user("tema_dash@test.com", "parolasecreta")
_login(client, "tema_dash@test.com", "parolasecreta")
resp = client.get("/")
assert resp.status_code == 200
header_match = re.search(r'<header>(.*?)</header>', resp.text, re.DOTALL | re.IGNORECASE)
assert header_match, "<header> negasit pe dashboard"
assert re.search(
r'<button[^>]*aria-label=["\'][^"\']*tema[^"\']*["\']',
header_match.group(1),
re.IGNORECASE,
), "Butonul toggle lipseste pe dashboard"
# ── US-003: Fragmente HTMX fara fundal hardcodat ──────────────────────────────
def test_fragmente_fara_fundal_hardcodat():
"""Niciun fisier _*.html din app/web/templates/ nu contine literalii hex dark-fix
#241a1a, #201c0f, #16241c (suprafete banner eroare / warn / flash)."""
templates_dir = Path(__file__).parent.parent / "app" / "web" / "templates"
fragmente = sorted(templates_dir.glob("_*.html"))
assert fragmente, f"Nu am gasit fragmente _*.html in {templates_dir}"
vinovate = []
for f in fragmente:
continut = f.read_text(encoding="utf-8")
for literal in ("#241a1a", "#201c0f", "#16241c"):
if literal in continut:
vinovate.append(f"{f.name}: {literal}")
assert not vinovate, (
"Fragmente cu fundal hardcodat dark (nu adapteaza la tema light):\n"
+ "\n".join(vinovate)
)

View File

@@ -0,0 +1,200 @@
"""Teste TDD pentru POST /v1/prezentari/valideaza (dry-run, PRD 5.2 US-001)."""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
# --- helpere ---
def _prez(**over):
"""Prezentare valida implicita."""
p = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
p.update(over)
return p
def _body_v(prezentari=None, **over):
"""Body pentru /valideaza — rar_credentials optional."""
if prezentari is None:
prezentari = [_prez(**over)]
return {"prezentari": prezentari}
# --- teste ---
def test_payload_valid_returneaza_queued(client):
r = client.post("/v1/prezentari/valideaza", json=_body_v())
assert r.status_code == 200
res = r.json()["results"][0]
assert res["valid"] is True
assert res["status_estimat"] == "queued"
assert res["erori"] == []
assert res["index"] == 0
def test_vin_invalid_returneaza_needs_data(client):
# VIN cu O/I/Q interzisi
r = client.post("/v1/prezentari/valideaza", json=_body_v(vin="WVWZZZ1OZIQ45678"))
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status_estimat"] == "needs_data"
assert res["valid"] is False
fields = [e["field"] for e in res["erori"]]
assert "vin" in fields
def test_data_viitoare_needs_data(client):
r = client.post("/v1/prezentari/valideaza", json=_body_v(data_prestatie="2099-01-01"))
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status_estimat"] == "needs_data"
assert res["valid"] is False
fields = [e["field"] for e in res["erori"]]
assert "data_prestatie" in fields
def test_cod_op_nemapat_returneaza_needs_mapping(client):
prez = _prez()
prez["prestatii"] = [{"cod_op_service": "REP_MOTOR_NECUNOSCUT", "denumire": "Reparatie motor"}]
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status_estimat"] == "needs_mapping"
assert res["valid"] is False
assert len(res["nemapate"]) == 1
assert res["nemapate"][0]["cod_op_service"] == "REP_MOTOR_NECUNOSCUT"
def test_mapare_existenta_rezolva_codul(client):
# Salveaza mapare op->cod
r_map = client.post("/v1/mapari", json={
"cod_op_service": "REP_MOTOR",
"cod_prestatie": "OE-1",
"auto_send": True,
})
assert r_map.status_code == 200
prez = _prez()
prez["prestatii"] = [{"cod_op_service": "REP_MOTOR", "denumire": "Reparatie motor"}]
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status_estimat"] == "queued"
assert res["valid"] is True
assert len(res["prestatii_rezolvate"]) == 1
assert res["prestatii_rezolvate"][0]["cod_prestatie"] == "OE-1"
def test_fara_creds_merge(client):
# rar_credentials absent -> 200 (optional in schema)
r = client.post("/v1/prezentari/valideaza", json=_body_v())
assert r.status_code == 200
def test_nu_scrie_in_coada(client):
# Verifica zero efecte secundare: COUNT(*) neschimbat
r_before = client.get("/v1/prezentari")
assert r_before.status_code == 200
nr_before = len(r_before.json()["submissions"])
client.post("/v1/prezentari/valideaza", json=_body_v())
r_after = client.get("/v1/prezentari")
assert r_after.status_code == 200
nr_after = len(r_after.json()["submissions"])
assert nr_after == nr_before
def test_multi_prezentari_rezultate_per_index(client):
prezentari = [
_prez(), # valid -> queued
_prez(vin="WVWZZZ1OZIQ45678"), # VIN invalid -> needs_data
]
r = client.post("/v1/prezentari/valideaza", json={"prezentari": prezentari})
assert r.status_code == 200
results = r.json()["results"]
assert len(results) == 2
# index corect per pozitie
assert results[0]["index"] == 0
assert results[1]["index"] == 1
# statusuri diferite
assert results[0]["status_estimat"] == "queued"
assert results[1]["status_estimat"] == "needs_data"
def test_shape_invalid_422(client):
# PrestatieItem fara cod_prestatie si fara cod_op_service -> 422 de shape Pydantic
prez = _prez()
prez["prestatii"] = [{"denumire": "ceva"}] # lipseste cod_prestatie si cod_op_service
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
assert r.status_code == 422
# Handlerul global dropeaza input/ctx — fara echo parola (desi creds lipseste, testam structura)
body = r.json()
for err in body.get("detail", []):
assert "input" not in err
assert "ctx" not in err
# --------------------------------------------------------------------------- #
# US-003: 3 niveluri in raspunsul /valideaza #
# --------------------------------------------------------------------------- #
def test_erori_au_3niveluri(client):
"""/valideaza cu VIN invalid -> erori[i] au cod/problema/cauza/fix (pass-through US-002)."""
r = client.post("/v1/prezentari/valideaza", json=_body_v(vin="WVWZZZ1OZIQ45678"))
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status_estimat"] == "needs_data"
erori = res["erori"]
assert len(erori) >= 1
for e in erori:
assert "cod" in e, f"lipseste 'cod' in {e}"
assert "problema" in e, f"lipseste 'problema' in {e}"
assert "cauza" in e, f"lipseste 'cauza' in {e}"
assert "fix" in e, f"lipseste 'fix' in {e}"
def test_nemapate_au_3niveluri(client):
"""/valideaza cu cod_op nemapat -> nemapate[i] au cod_op_service+denumire PASTRATE
+ cod==COD_NEMAPAT + cele 3 niveluri."""
prez = _prez()
prez["prestatii"] = [{"cod_op_service": "OP_TEST_NEMAPAT", "denumire": "Operatie test"}]
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status_estimat"] == "needs_mapping"
nemapate = res["nemapate"]
assert len(nemapate) == 1
n = nemapate[0]
# Campuri originale pastrate
assert n["cod_op_service"] == "OP_TEST_NEMAPAT"
assert n["denumire"] == "Operatie test"
# 3 niveluri adaugate
assert n["cod"] == "COD_NEMAPAT"
assert n["problema"]
assert n["cauza"]
assert n["fix"]

View File

@@ -137,3 +137,114 @@ def test_b64image_valid_ok():
def test_erori_multiple_cumulate():
errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X-Y", data_prestatie="2024-01-01"))
assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)
# ---------------------------------------------------------------------------
# US-002: 3 niveluri — coduri stabile, forma aditiva, back-compat
# ---------------------------------------------------------------------------
from app import errors as err_mod # noqa: E402
def test_vin_invalid_are_3niveluri():
"""VIN cu O/I/Q => eroarea are cod, problema, fix, field, message neschimbat."""
errs = validate_prezentare(_base(vin="WVWZZZ1OZAW000123"))
vin_errs = [e for e in errs if e.get("field") == "vin"]
assert vin_errs, "Trebuie cel putin o eroare cu field==vin"
e = vin_errs[0]
assert e["cod"] == "VIN_FORMAT"
assert e["problema"], "problema trebuie sa fie ne-goala"
assert e["fix"], "fix trebuie sa fie ne-gol"
assert e["field"] == "vin"
# message trebuie sa fie mesajul existent (back-compat)
assert "17" in e["message"] or "O, I, Q" in e["message"]
def test_data_prea_veche_cod():
errs = validate_prezentare(_base(data_prestatie="2024-11-30"))
dp = [e for e in errs if e.get("field") == "data_prestatie"]
assert dp
assert dp[0]["cod"] == "DATA_PREA_VECHE"
def test_data_viitor_cod():
from datetime import timedelta
maine = (today_bucuresti() + timedelta(days=1)).isoformat()
errs = validate_prezentare(_base(data_prestatie=maine))
dp = [e for e in errs if e.get("field") == "data_prestatie"]
assert dp
assert dp[0]["cod"] == "DATA_VIITOR"
def test_data_format_cod():
errs = validate_prezentare(_base(data_prestatie="15-06-2026"))
dp = [e for e in errs if e.get("field") == "data_prestatie"]
assert dp
assert dp[0]["cod"] == "DATA_FORMAT"
def test_odometru_initial_lipsa_cod():
errs = validate_prezentare(_base(prestatii=[{"cod_prestatie": "R-ODO"}]))
oi = [e for e in errs if e.get("field") == "odometru_initial"]
assert oi
assert oi[0]["cod"] == "ODOMETRU_INITIAL_LIPSA"
def test_odometru_ordine_cod():
c = _base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="200000", odometru_final="100000")
errs = validate_prezentare(c)
oi = [e for e in errs if e.get("field") == "odometru_initial" and e.get("cod") == "ODOMETRU_INITIAL_ORDINE"]
assert oi, "Trebuie eroare cu cod ODOMETRU_INITIAL_ORDINE"
def test_prestatii_goale_cod():
errs = validate_prezentare(_base(prestatii=[]))
pr = [e for e in errs if e.get("field") == "prestatii"]
assert pr
assert pr[0]["cod"] == "PRESTATII_GOALE"
def test_b64_invalid_cod():
errs = validate_prezentare(_base(b64_image="@@@not-base64@@@"))
b = [e for e in errs if e.get("field") == "b64_image"]
assert b
assert b[0]["cod"] == "B64_INVALID"
def test_back_compat_field_message():
"""Fiecare eroare are inca field + message (forma veche)."""
errs = validate_prezentare(_base(
vin="BAD",
nr_inmatriculare="X-Y",
data_prestatie="2024-01-01",
odometru_final="abc",
prestatii=[],
))
for e in errs:
assert "field" in e, f"Lipseste 'field' in {e}"
assert "message" in e, f"Lipseste 'message' in {e}"
assert e["message"], f"'message' gol in {e}"
def test_toate_codurile_in_catalog():
"""Fiecare cod emis de validate_prezentare exista in errors.CATALOG."""
from datetime import timedelta
cazuri = [
_base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT
_base(nr_inmatriculare="X-Y"), # NR_INMATRICULARE_FORMAT
_base(data_prestatie="15-06-2026"), # DATA_FORMAT
_base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE
_base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR
_base(odometru_final="abc"), # ODOMETRU_FINAL_FORMAT
_base(prestatii=[{"cod_prestatie": "R-ODO"}]), # ODOMETRU_INITIAL_LIPSA
_base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="abc"), # ODOMETRU_INITIAL_FORMAT
_base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="200000", odometru_final="100000"), # ODOMETRU_INITIAL_ORDINE
_base(prestatii=[]), # PRESTATII_GOALE
_base(b64_image="@@@not-base64@@@"), # B64_INVALID
]
for caz in cazuri:
errs = validate_prezentare(caz)
for e in errs:
if "cod" in e:
assert e["cod"] in err_mod.CATALOG, f"Cod {e['cod']!r} absent din CATALOG"

128
tests/test_web_admin.py Normal file
View File

@@ -0,0 +1,128 @@
"""Teste US-009 (PRD 5.5): panou admin UI — selectie cu bife + master, bara de actiuni bulk
(Activeaza/Blocheaza/Arhiveaza/Sterge), actiuni per-rand, fara nota 'cont dev implicit',
grila standard.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_web_admin.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
def _csrf(client, url):
resp = client.get(url, follow_redirects=True)
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
return m.group(1)
def _signup(client, name, email, password="parola_test_001"):
tok = _csrf(client, "/signup")
client.post("/signup", data={"name": name, "email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
(email,)).fetchone()
return int(row["account_id"])
finally:
conn.close()
def _admin_login(client):
target = _signup(client, "Pending SRL", "pending@test.ro") # cont in asteptare
admin_id = _signup(client, "Admin SA", "admin@test.ro")
from app.db import get_connection
from app.users import set_admin
conn = get_connection()
try:
set_admin(conn, admin_id, is_admin=True)
conn.commit()
finally:
conn.close()
tok = _csrf(client, "/login")
resp = client.post("/login", data={"email": "admin@test.ro", "parola": "parola_test_001",
"csrf_token": tok})
assert resp.status_code == 303
return target
def test_admin_coloana_selectie_si_master(client):
_admin_login(client)
html = client.get("/admin").text
# checkbox de selectie pe rand + master
assert 'name="account_id"' in html
assert 'type="checkbox"' in html
assert "Selecteaza tot" in html or 'data-master' in html
def test_bara_bulk_cu_cele_4_verbe(client):
_admin_login(client)
html = client.get("/admin").text
assert 'formaction="/admin/activate"' in html
assert 'formaction="/admin/block"' in html
assert 'formaction="/admin/archive"' in html
assert 'formaction="/admin/delete"' in html
# bara e ascunsa initial (hidden), fara display inline care ar invinge [hidden]
assert re.search(r'class="bulk-bar"\s+hidden', html) or re.search(r'hidden[^>]*class="bulk-bar"', html)
assert "bulk-bar" in html and ".bulk-bar[hidden]" in html # CSS care face hidden eficient
def test_actiuni_per_rand(client):
_admin_login(client)
html = client.get("/admin").text
# forme per-rand catre rutele de lifecycle (kebab)
assert 'action="/admin/block"' in html
assert 'action="/admin/archive"' in html
assert 'action="/admin/delete"' in html
def test_fara_nota_cont_dev(client):
_admin_login(client)
html = client.get("/admin").text
assert "cont dev implicit" not in html.lower()
assert "Cont dev implicit" not in html
def test_grila_standard(client):
_admin_login(client)
html = client.get("/admin").text
assert "tablewrap" in html
def test_cont_arhivat_in_blocul_suspendate(client):
"""Gruparea pe STARE: un cont arhivat apare in blocul blocate/arhivate, nu in 'in asteptare'."""
target = _admin_login(client) # cont pending seedat
from app.db import get_connection
from app.accounts import set_status
conn = get_connection()
try:
set_status(conn, target, "archived")
conn.commit()
finally:
conn.close()
html = client.get("/admin").text
# contul arhivat ajunge in blocul suspendate (1 cont), nu in "in asteptare"
assert re.search(r"Conturi blocate / arhivate \(1\)", html)
assert ">archived<" in html

285
tests/test_web_erori.py Normal file
View File

@@ -0,0 +1,285 @@
"""Teste US-006 (PRD 5.4): Componenta UI de eroare pe 3 niveluri.
Pasul A (RED): teste scrise inainte de implementare.
"""
from __future__ import annotations
import csv
import io
import json
import os
import re
import tempfile
import pytest
# ---------------------------------------------------------------------------
# Teste pure pentru parse_erori (fara HTTP)
# ---------------------------------------------------------------------------
from app.web.labels import parse_erori # noqa: E402
def test_parse_erori_array_3niveluri():
"""Array imbogatit cu cod/problema/cauza/fix -> lista cu toate 3 nivelurile."""
rar_error = json.dumps([
{
"field": "vin",
"cod": "VIN_FORMAT",
"problema": "VIN invalid",
"cauza": "VIN-ul are 16 caractere; RAR cere exact 17.",
"fix": "Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule.",
"message": "VIN-ul are 16 caractere; RAR cere exact 17.",
}
])
rezultat = parse_erori(rar_error)
assert len(rezultat) == 1
e = rezultat[0]
assert e["problema"] == "VIN invalid"
assert "17" in e["cauza"]
assert e["fix"] # non-gol
assert e.get("field") == "vin"
def test_parse_erori_unmapped():
"""Dict unmapped 3-niveluri (cod=COD_NEMAPAT) -> 1 element corect."""
rar_error = json.dumps({
"cod": "COD_NEMAPAT",
"problema": "Lipseste codul RAR al operatiei",
"cauza": "Codul OP-99 nu are mapare RAR.",
"fix": "Alege codul RAR pentru aceasta operatie in tab-ul Mapari.",
"unmapped": [{"cod_op_service": "OP-99", "denumire": "Operatie test"}],
})
rezultat = parse_erori(rar_error)
assert len(rezultat) == 1
e = rezultat[0]
assert e["problema"] == "Lipseste codul RAR al operatiei"
assert e["fix"]
def test_parse_erori_creds():
"""Dict cu cod=RAR_CREDS_INVALIDE -> 1 element corect."""
rar_error = json.dumps({
"cod": "RAR_CREDS_INVALIDE",
"problema": "Credentiale RAR invalide",
"cauza": "Autentificarea la RAR a esuat (401).",
"fix": "Verifica email-ul si parola contului RAR in tab-ul Cont.",
})
rezultat = parse_erori(rar_error)
assert len(rezultat) == 1
e = rezultat[0]
assert e["problema"] == "Credentiale RAR invalide"
assert e["fix"]
def test_parse_erori_forma_veche_si_corupt():
"""Forma veche [{ field, message }], string plain, None, invalid -> degradeaza fara exceptie."""
# Forma veche: lista cu field+message dar fara cod
vechi = json.dumps([{"field": "vin", "message": "VIN invalid"}])
r = parse_erori(vechi)
assert isinstance(r, list)
assert len(r) >= 1
# Nu arunca pentru niciun element
for e in r:
assert "problema" in e
# String plain
r2 = parse_erori("Eroare generica de la RAR")
assert isinstance(r2, list)
assert len(r2) >= 1
assert r2[0]["problema"]
# None
r3 = parse_erori(None)
assert isinstance(r3, list)
assert r3 == []
# JSON corupt
r4 = parse_erori("{invalid json[[[")
assert isinstance(r4, list)
assert len(r4) >= 1
# Nu arunca
# Dict fara cod (forma veche dict)
r5 = parse_erori(json.dumps({"auto_send": "cod-abc", "motiv": "auto_send oprit"}))
assert isinstance(r5, list)
assert len(r5) >= 1
# ---------------------------------------------------------------------------
# Fixture HTTP
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "erori.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _csv_bytes(rows: list[dict]) -> bytes:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _seed_mapping(conn=None):
"""Mapeaza OP-1 -> R-FRANE (cont dev id=1)."""
from app.db import get_connection
c = conn or get_connection()
try:
c.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')")
c.execute(
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (1, 'OP-1', 'R-FRANE', 1)"
)
c.commit()
finally:
if conn is None:
c.close()
def _creeaza_submission_needs_data(client, fix_text=None):
"""Creeaza un submission in starea needs_data si returneaza id-ul."""
import json as _json
from app.db import get_connection
_seed_mapping()
# Insereaza direct un submission needs_data cu rar_error 3-niveluri
fix_folosit = fix_text or "Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule."
rar_error_3n = _json.dumps([{
"field": "vin",
"cod": "VIN_FORMAT",
"problema": "VIN invalid",
"cauza": "VIN-ul are 16 caractere; RAR cere exact 17.",
"fix": fix_folosit,
"message": "VIN-ul are 16 caractere; RAR cere exact 17.",
}])
payload = _json.dumps({
"vin": "VIN16CARACT000000",
"nr_inmatriculare": "B001TST",
"data_prestatie": "2026-06-15",
"odometru_final": 145000,
"cod_prestatie": "R-FRANE",
"prestatii": [{"cod_prestatie": "R-FRANE"}],
})
idem_key = f"test-erori-{fix_folosit[:20]}"
conn = get_connection()
try:
cur = conn.execute(
"""INSERT INTO submissions
(account_id, idempotency_key, payload_json, status, rar_error, retry_count)
VALUES (1, ?, ?, 'needs_data', ?, 0)""",
(idem_key, payload, rar_error_3n)
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def test_detaliu_afiseaza_fix(client):
"""Fragmentul de detaliu al unui submission needs_data contine textul fix-ului."""
sub_id = _creeaza_submission_needs_data(client, "Verifica VIN-ul pe talon (pozitia E)")
resp = client.get(f"/_fragments/trimitere/{sub_id}")
assert resp.status_code == 200
html = resp.text
# Fix-ul trebuie sa apara in HTML
assert "pozitia E" in html, (
f"HTML-ul detaliu nu contine fix-ul ('pozitia E'). Primii 2000 chars:\n{html[:2000]}"
)
def _import_preview_cu_vin_invalid(client):
"""Efectueaza un import cu un rand cu VIN invalid si returneaza HTML-ul preview."""
_seed_mapping()
rows = [
# rand cu VIN de 16 caractere (invalid, trebuie 17) -> needs_data
{"VIN": "VIN16CARACT00000", "Nr inmatriculare": "B001TST",
"Data prestatie": "15.06.2026", "Odometru final": "145000", "Operatie": "OP-1"},
]
data = _csv_bytes(rows)
r = client.post("/_import/upload", files={"file": ("test.csv", data, "text/csv")})
assert r.status_code == 200
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"Nu am gasit import_id. Raspuns: {r.text[:1000]}"
import_id = int(m.group(1))
r = client.post(f"/_import/{import_id}/mapare-coloane", data={
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"format_data": "DD.MM.YYYY",
})
assert r.status_code == 200
return r.text
def test_preview_rand_per_camp_fix(client):
"""Preview rand needs_data -> HTML contine fix-ul per camp (VIN)."""
html = _import_preview_cu_vin_invalid(client)
# Randul trebuie sa fie needs_data
assert "needs_data" in html or "needs_review" in html or "VIN" in html.upper(), (
f"HTML-ul preview nu contine starea asteptata. Primii 2000 chars:\n{html[:2000]}"
)
# Fix-ul pentru VIN trebuie sa apara in preview
assert "talon" in html.lower() or "majuscule" in html.lower() or "pozitia" in html.lower(), (
f"HTML-ul preview nu contine fix-ul VIN. Primii 3000 chars:\n{html[:3000]}"
)
# ---------------------------------------------------------------------------
# Teste noi (BUG 1 + BUG 2) — adaugate TDD-style (RED inainte de fix)
# ---------------------------------------------------------------------------
from app.web.labels import motiv_uman # noqa: E402
from app.errors import eroare # noqa: E402
def test_motiv_uman_creds_3niveluri():
"""BUG 1: motiv_uman pe dict 3-niveluri (RAR_CREDS_INVALIDE) -> problema, NU text garbled."""
rar_err = json.dumps(eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"))
rezultat = motiv_uman("error", rar_err)
# Nu trebuie sa contina "field:" / "cod:" (text garbled)
assert "field:" not in rezultat, f"Text garbled cu 'field:': {rezultat!r}"
assert "cod:" not in rezultat, f"Text garbled cu 'cod:': {rezultat!r}"
# Trebuie sa returneze textul 'problema' (primul nivel)
data = json.loads(rar_err)
problema = data.get("problema") or ""
assert problema, "eroare() nu a returnat 'problema' — verifica error_codes.py"
assert rezultat == problema[:200], (
f"Asteptat {problema[:200]!r}, obtinut {rezultat!r}"
)
def test_motiv_uman_unmapped_neschimbat():
"""Ramura unmapped inca functioneaza dupa adaugarea ramurii 3-niveluri."""
rar_err = json.dumps({"unmapped": [{"cod_op_service": "OP-99", "denumire": "Test"}]})
rezultat = motiv_uman("needs_mapping", rar_err)
assert rezultat.startswith("Cod RAR lipsa pentru:"), (
f"Ramura unmapped regresta. Obtinut: {rezultat!r}"
)
assert "OP-99" in rezultat
def test_parse_erori_gol_returneaza_lista_goala():
"""BUG 2: parse_erori pe dict/lista goala -> [], nu 1 element cu problema=''."""
r1 = parse_erori("{}")
assert r1 == [], f"parse_erori('{{}}') -> {r1!r}, asteptat []"
r2 = parse_erori("[{}]")
assert r2 == [], f"parse_erori('[{{}}]') -> {r2!r}, asteptat []"

View File

@@ -0,0 +1,86 @@
"""Teste US-006 (PRD 5.5): meniu hamburger in header (Cont/Integrare/Nomenclator/Admin/logout)
+ context de autentificare. base.html partajat: pe login/signup meniul nu expune cont/logout.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "menu_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def _make_user(email="u@test.com", password="parolasecreta10", is_admin=False):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Test", active=True)
create_user(conn, acct_id, email, password, is_admin=is_admin)
return acct_id
finally:
conn.close()
def _login(client, email="u@test.com", password="parolasecreta10"):
resp = client.get("/login")
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
assert resp.status_code == 303
def test_meniu_autentificat_are_linkuri_cont(client):
_make_user()
_login(client)
html = client.get("/").text
# butonul de meniu (hamburger) prezent
assert 'id="cont-menu-toggle"' in html
assert 'aria-controls="cont-menu"' in html
# linkurile mutate in meniu
assert 'href="/?tab=cont"' in html
assert 'href="/?tab=integrare"' in html
assert 'href="/?tab=nomenclator"' in html
# logout in meniu
assert 'action="/logout"' in html
assert "Iesi din cont" in html
def test_meniu_admin_link_doar_pentru_admin(client):
_make_user(email="admin@test.com", is_admin=True)
_login(client, email="admin@test.com")
html = client.get("/").text
assert 'href="/admin"' in html
def test_meniu_fara_admin_pentru_neadmin(client):
_make_user(email="plain@test.com", is_admin=False)
_login(client, email="plain@test.com")
html = client.get("/").text
assert 'href="/admin"' not in html
def test_meniu_neautentificat_fara_logout(client):
"""Pe /login (neautentificat) meniul nu expune cont/logout."""
html = client.get("/login").text
assert "Iesi din cont" not in html
assert 'action="/logout"' not in html
assert 'id="cont-menu-toggle"' not in html

View File

@@ -0,0 +1,180 @@
"""Teste US-007 (PRD 5.4): erori de import pe 3 niveluri in interfata web.
Verifica ca fragmentele HTML intoarse de rutele web /_import/* contin
textele structurate (problema + fix) din catalog pentru erorile de upload
si de mapare coloane.
Rutele testate sunt WEB (HTML), nu API JSON — raspunsul este HTML fragment.
"""
from __future__ import annotations
import io
import os
import tempfile
import openpyxl
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixture client cu WEB_AUTH_REQUIRED=false (dev mode, cont 1 implicit) #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client FastAPI cu DB temporara izolata, auth web dezactivat (dev mode)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "erori3n.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _make_xlsx_prea_mare(n_randuri: int = 5001) -> bytes:
"""Xlsx cu mai mult de 5000 de randuri de date (declanseaza FileTooLarge)."""
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"])
for i in range(n_randuri):
ws.append([
f"WVWZZZ1KZA{i:07d}",
f"B{i:04d}TST",
"2026-06-15",
str(100000 + i),
"Revizie",
])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _upload_web(client: TestClient, data: bytes, filename: str = "test.xlsx") -> object:
"""POST pe ruta web de upload (intoarce HTML fragment)."""
return client.post(
"/_import/upload",
files={"file": (filename, io.BytesIO(data), "application/octet-stream")},
)
# --------------------------------------------------------------------------- #
# 1. Upload fisier prea mare → fragment cu fix din IMPORT_FISIER_PREA_MARE #
# --------------------------------------------------------------------------- #
def test_upload_fisier_prea_mare_3niveluri(client):
"""Upload xlsx >5000 randuri → fragment HTML contine textul fix din catalog.
Textul asteptat din CATALOG['IMPORT_FISIER_PREA_MARE']['fix']:
'Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand.'
"""
from app.errors import CATALOG
fix_asteptat = CATALOG["IMPORT_FISIER_PREA_MARE"]["fix"]
data = _make_xlsx_prea_mare(5001)
r = _upload_web(client, data, "mare.xlsx")
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
html = r.text
assert fix_asteptat in html, (
f"Textul fix din IMPORT_FISIER_PREA_MARE nu apare in fragmentul HTML.\n"
f"Asteptat: {fix_asteptat!r}\n"
f"Fragment (primii 800 chars): {html[:800]}"
)
# --------------------------------------------------------------------------- #
# 2. Upload fisier nerecunoscut → fragment cu fix din IMPORT_FISIER_NERECUNOSCUT
# --------------------------------------------------------------------------- #
def test_upload_fisier_nerecunoscut_3niveluri(client):
"""Upload bytes invalizi (nu e xlsx/csv valid) → fragment HTML cu fix din catalog.
Textul asteptat din CATALOG['IMPORT_FISIER_NERECUNOSCUT']['fix']:
'Incarca un fisier .xlsx sau .csv valid.'
"""
from app.errors import CATALOG
fix_asteptat = CATALOG["IMPORT_FISIER_NERECUNOSCUT"]["fix"]
problema_asteptata = CATALOG["IMPORT_FISIER_NERECUNOSCUT"]["problema"]
# Bytes invalizi: nu este un zip/xlsx, nu este text CSV
date_invalide = b"\x00\x01\x02\x03\x04\x05binar_junk_non_xlsx"
r = _upload_web(client, date_invalide, "test.xlsx")
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
html = r.text
assert fix_asteptat in html, (
f"Textul fix din IMPORT_FISIER_NERECUNOSCUT nu apare in fragmentul HTML.\n"
f"Asteptat: {fix_asteptat!r}\n"
f"Fragment (primii 800 chars): {html[:800]}"
)
# --------------------------------------------------------------------------- #
# 3. Mapare coloane cu JSON invalid → fragment cu fix din COLOANE_FORMAT_JSON #
# --------------------------------------------------------------------------- #
def test_mapcoloane_format_json_3niveluri(client):
"""POST direct pe ruta de mapare coloane cu corp JSON invalid → fragment cu fix din catalog.
Textul asteptat din CATALOG['COLOANE_FORMAT_JSON']['fix']:
'Verifica sintaxa JSON a maparii de coloane (ghilimele duble, acolade inchise corect).'
Ruta /_import/{id}/mapare-coloane accepta form data. Codul de test simuleaza
un batch_id invalid (0) pentru a forta ramura de eroare, sau injecteaza direct
un batch_id valid cu JSON invalid in campul de mapare. Deoarece ruta nu primeste
JSON direct, testam ramura din routes.py unde se face json.dumps si se valideaza
manual, sau modificam testul sa plaseze un batch valid si sa trimita date malformate.
Strategia: upload un fisier valid, obtine import_id, trimite un form cu valoare
json invalida in campul coloane (via Content-Type: application/json intentionat gresit
sau via parametru form malformat). In implementarea din routes.py, eroarea
COLOANE_FORMAT_JSON este randata cand json.loads esueaza pe un camp special.
"""
from app.errors import CATALOG
fix_asteptat = CATALOG["COLOANE_FORMAT_JSON"]["fix"]
# Primul pas: upload un fisier valid pentru a obtine un import_id real
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["VIN", "Nr", "Data", "KM", "Op"])
ws.append(["WVWZZZ1KZAW000123", "B001TST", "2026-06-15", "100000", "Revizie"])
buf = io.BytesIO()
wb.save(buf)
xlsx_data = buf.getvalue()
r_upload = _upload_web(client, xlsx_data, "test.xlsx")
assert r_upload.status_code == 200, r_upload.text[:300]
# Extrage import_id din raspuns
import re
m = re.search(r"/_import/(\d+)/mapare-coloane", r_upload.text)
assert m, f"Nu s-a gasit import_id in raspuns: {r_upload.text[:500]}"
import_id = int(m.group(1))
# Trimite un request pe ruta de mapare coloane cu Content-Type: application/json
# si corp JSON invalid → ruta ar trebui sa intoarca eroarea COLOANE_FORMAT_JSON
r = client.post(
f"/_import/{import_id}/mapare-coloane",
content=b"{invalid json}",
headers={"Content-Type": "application/json"},
)
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
html = r.text
assert fix_asteptat in html, (
f"Textul fix din COLOANE_FORMAT_JSON nu apare in fragmentul HTML.\n"
f"Asteptat: {fix_asteptat!r}\n"
f"Fragment (primii 800 chars): {html[:800]}"
)

View File

@@ -28,12 +28,17 @@ def _starile_din_schema() -> list[str]:
sql = schema_path.read_text(encoding="utf-8")
# Cauta blocul CHECK aferent coloanei status din CREATE TABLE submissions.
# Pattern: CHECK (status IN ('a','b',...)) pe una sau mai multe linii.
# Nota (5.5): de cand exista si `accounts.status` cu propriul CHECK, ancoram pe blocul
# tabelei submissions (`CREATE TABLE ... submissions`) ca sa nu prindem starile de cont.
tbl = re.search(
r"CREATE TABLE[^;]*?submissions\b(.*?);", sql, re.IGNORECASE | re.DOTALL
)
assert tbl, "Nu am gasit CREATE TABLE submissions in schema.sql — schema s-a schimbat?"
match = re.search(
r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)",
sql,
tbl.group(1),
)
assert match, "Nu am gasit CHECK (status IN (...)) in schema.sql — schema s-a schimbat?"
assert match, "Nu am gasit CHECK (status IN (...)) in submissions — schema s-a schimbat?"
raw = match.group(1)
# Extrage valorile dintre ghilimele simple

View File

@@ -73,7 +73,7 @@ def client(monkeypatch):
# ============================================================
def test_dashboard_are_tabbar(client):
"""Dashboard-ul contine un tab-bar cu cele 6 tab-uri."""
"""US-007 (5.5): tab-bar redus la Acasa + Mapari; Cont/Integrare/Nomenclator in meniul ☰."""
_create_account_user("tabbar@test.com", "parolasecreta10")
_login(client, "tabbar@test.com", "parolasecreta10")
@@ -83,12 +83,15 @@ def test_dashboard_are_tabbar(client):
html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist"
# Tab-urile trebuie sa fie prezente (Import a fuzionat in Acasa; "Coada"->"Trimiteri" — PRD 3.5)
for label in ("Acasa", "Trimiteri", "Mapari", "Cont", "Nomenclator"):
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar"
# "Import" nu mai e un tab separat in tab-bar (importul e direct pe Acasa)
assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', html), \
"Tab-ul 'Import' nu ar mai trebui sa existe ca tab separat (US-002)"
# Doar Acasa + Mapari sunt tab-uri (role="tab")
assert re.search(r'role="tab"[^>]*>\s*Acasa', html), "Lipseste tab-ul Acasa"
assert re.search(r'role="tab"[^>]*>\s*Mapari', html), "Lipseste tab-ul Mapari"
# Cont/Integrare/Nomenclator NU mai sunt tab-uri
for label in ("Cont", "Integrare", "Nomenclator", "Import"):
assert not re.search(rf'role="tab"[^>]*>\s*{label}\s*<', html), \
f"'{label}' nu ar mai trebui sa fie un tab separat (mutat in meniu)"
# ...dar traiesc in meniul de cont
assert 'href="/?tab=cont"' in html and 'href="/?tab=nomenclator"' in html
# ============================================================
@@ -215,3 +218,20 @@ def test_tabbar_aria(client):
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel"
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive"
# ============================================================
# test_fragmentele_mutate_raman_accesibile (US-007)
# ============================================================
def test_fragmentele_mutate_raman_accesibile(client):
"""US-007 (5.5): Cont/Integrare/Nomenclator s-au mutat in meniu, dar rutele de fragment
si deep-link-ul ?tab= raman valide (zero rute moarte / 404)."""
_create_account_user("frag@test.com", "parolasecreta10")
_login(client, "frag@test.com", "parolasecreta10")
for tab in ("cont", "integrare", "nomenclator"):
r_frag = client.get(f"/_fragments/{tab}")
assert r_frag.status_code == 200, f"/_fragments/{tab} a dat {r_frag.status_code}"
r_deep = client.get(f"/?tab={tab}")
assert r_deep.status_code == 200, f"/?tab={tab} a dat {r_deep.status_code}"

View File

@@ -0,0 +1,171 @@
"""Teste PRD 5.5 — uniformizare UI: US-001 (Acasa fara Ajutor), US-002 (Nomenclator grila
standard), US-003 (macro autosend compact). Stories de template/macro -> render direct Jinja
pentru US-002/003; US-001 prin TestClient pe fragmentul Acasa.
"""
from __future__ import annotations
import os
import re
import tempfile
from pathlib import Path
import pytest
from jinja2 import Environment, FileSystemLoader
from starlette.testclient import TestClient
_TEMPLATES = Path(__file__).resolve().parents[1] / "app" / "web" / "templates"
def _env():
return Environment(loader=FileSystemLoader(str(_TEMPLATES)), autoescape=True)
# ============================================================
# US-002: Nomenclator ca tabel standard (grila Trimiteri)
# ============================================================
def test_nomenclator_grila_standard_cu_randuri():
tmpl = _env().get_template("_nomenclator.html")
html = tmpl.render(rows=[
{"cod_prestatie": "A012", "nume_prestatie": "Revizie tehnica", "updated_at": "2026-06-20"},
])
assert "tablewrap" in html
assert "<table" in html
assert 'class="pill"' in html # codul in pill, ca la Trimiteri
assert "A012" in html and "Revizie tehnica" in html
def test_nomenclator_empty_state():
tmpl = _env().get_template("_nomenclator.html")
html = tmpl.render(rows=[])
assert 'class="empty"' in html
assert "Nomenclator gol" in html
# ============================================================
# US-003: macro autosend_toggle compact (Auto/Manual)
# ============================================================
def _render_macro(form_id="map-1", checked=True):
mod = _env().get_template("_macros.html").module
return str(mod.autosend_toggle(form_id=form_id, checked=checked))
def test_autosend_pastreaza_name_si_prezenta():
"""Invariant backend: checkbox name=auto_send value=true (semantica de prezenta)."""
html = _render_macro(checked=True)
assert 'type="checkbox"' in html
assert 'name="auto_send"' in html
assert 'value="true"' in html
assert 'form="map-1"' in html
assert "checked" in html
def test_autosend_nebifat_fara_checked():
html = _render_macro(checked=False)
assert 'name="auto_send"' in html
assert "checked" not in html
def test_autosend_compact_fara_proza_inline():
"""Proza explicativa de pe randuri (3.6) eliminata din CONTINUTUL vizibil — traieste in
panoul Ajutor (US-005). Tooltip-ul scurt (atribut title=) e acceptat, deci il scoatem
inainte de verificare."""
html = _render_macro()
vizibil = re.sub(r'title="[^"]*"', "", html) # scoate atributul title (tooltip)
assert "La fisierele viitoare" not in vizibil
assert "Tine pentru verificare" not in vizibil
assert "nimic nu pleaca la RAR" not in vizibil
# ambele etichete de stare vizibile, compact
assert "Auto" in html and "Manual" in html
# ============================================================
# US-001: Acasa fara sectiunea Ajutor
# ============================================================
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "uniform_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def _login(client, email="u@test.com", password="parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Test", active=True)
create_user(conn, acct_id, email, password)
finally:
conn.close()
resp = client.get("/login")
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
assert resp.status_code == 303
return acct_id
def test_acasa_fara_sectiune_ajutor(client):
_login(client)
resp = client.get("/_fragments/acasa")
assert resp.status_code == 200
# randul "Ajutor:" cu wayfinding Mapari/Coduri RAR eliminat din Acasa
assert "Ajutor:" not in resp.text
assert "Coduri RAR" not in resp.text
# ============================================================
# US-005: Tabel Mapari standardizat + panou Ajutor
# ============================================================
def _seed_needs_mapping(acct_id, cod_op="OP-NM", denumire="Operatie test"):
import json
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, 'needs_mapping', ?)",
(f"k-{cod_op}", acct_id,
json.dumps({"prestatii": [{"cod_op_service": cod_op, "denumire": denumire}]})),
)
conn.commit()
finally:
conn.close()
def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
acct = _login(client)
_seed_needs_mapping(acct)
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
# panou Ajutor (<details>) prezent
assert "ajutor-mapari" in html
assert "<details" in html and ">Ajutor<" in html
# antet de coloana compact
assert ">In coada<" in html
# proza inline veche eliminata de pe sectiuni
assert "sugestia fuzzy e preselectata) si salveaza" not in html
assert "Maparile operatie -> cod RAR retinute pentru contul tau" not in html
def test_mapari_comutator_compact_in_tabel(client):
acct = _login(client)
_seed_needs_mapping(acct)
html = client.get("/_fragments/mapari").text
assert 'name="auto_send"' in html
assert "Manual" in html and "Auto" in html

View File

@@ -125,6 +125,51 @@ def test_claim_account_null_tratat_activ(env):
assert _row_status(conn, sid) == "sending"
def test_claim_sare_cont_blocat(env):
"""5.5: cont blocked -> claim_one nu ridica submission-ul."""
from app.accounts import create_account, set_status
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Blocat", active=True)
sid = _insert(conn, account_id=acct_id)
set_status(conn, acct_id, "blocked")
assert claim_one(conn) is None
assert _row_status(conn, sid) == "queued"
def test_claim_sare_cont_arhivat(env):
"""5.5: cont archived -> claim_one nu ridica submission-ul."""
from app.accounts import create_account, set_status
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Arhivat", active=True)
sid = _insert(conn, account_id=acct_id)
set_status(conn, acct_id, "archived")
assert claim_one(conn) is None
assert _row_status(conn, sid) == "queued"
def test_deblocare_reia_trimiterea(env):
"""5.5: blocked -> set_status('active') -> claim_one ridica din nou."""
from app.accounts import create_account, set_status
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Revenit", active=True)
sid = _insert(conn, account_id=acct_id)
set_status(conn, acct_id, "blocked")
assert claim_one(conn) is None
set_status(conn, acct_id, "active")
result = claim_one(conn)
assert result is not None and result["id"] == sid
assert _row_status(conn, sid) == "sending"
def test_claim_cont_legacy_fara_active(env):
"""Simuleaza cont legacy: LEFT JOIN nu gaseste randul in accounts (account_id nul dupa stergere cont)
-> a.active=NULL dupa JOIN -> COALESCE(NULL,1)=1 -> tratat ca activ.

View File

@@ -0,0 +1,245 @@
"""Teste US-004 — erorile RAR (400/401) imbracate pe 3 niveluri in worker.
process_one cu 400 -> rar_error superset [{field,message,cod,problema,cauza,fix}]
run() loop cu 401 la login -> submission error, rar_error JSON 3-niveluri RAR_CREDS_INVALIDE
Clasificare transient/terminal neschimbata.
Fara echo de credentiale in rar_error.
"""
from __future__ import annotations
import json
import os
import tempfile
import httpx
import pytest
from app.rar_client import RarAuthError, RarError
# --- Fixtures DB (pattern din test_worker_reconcile.py) ---
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
settings = get_settings()
yield conn, settings
conn.close()
get_settings.cache_clear()
# --- Helpers ---
_CONTENT = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null",
}
def _insert(conn, status="queued", content=None, retry_count=0):
content = content or _CONTENT
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, retry_count) "
"VALUES (?, ?, ?, ?)",
(f"key-{os.urandom(4).hex()}", status, json.dumps(content), retry_count),
)
return int(cur.lastrowid)
def _row(conn, sid):
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
class FakeRar:
"""RAR mock cu comportament configurabil."""
def __init__(self, *, finalizate=None, post_result=None, post_exc=None):
self.finalizate = finalizate or []
self.post_result = post_result if post_result is not None else {"id": 1000}
self.post_exc = post_exc
self.post_calls = 0
self.finalizate_calls = 0
def get_finalizate(self, token):
self.finalizate_calls += 1
return self.finalizate
def post_prezentare(self, token, payload):
self.post_calls += 1
if self.post_exc is not None:
raise self.post_exc
return self.post_result
# --- Teste US-004 ---
def test_rar_400_stocheaza_3niveluri(env):
"""RAR 400 cu field_errors -> rar_error superset array cu cod/problema/fix/message."""
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
exc = RarError("Validare esuata", status_code=400,
field_errors=[{"field": "vin", "message": "VIN invalid la RAR"}])
rar = FakeRar(post_exc=exc)
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "needs_data"
row = _row(conn, sid)
assert row["status"] == "needs_data"
assert row["rar_status_code"] == 400
erori = json.loads(row["rar_error"])
assert isinstance(erori, list)
assert len(erori) == 1
elem = erori[0]
# Superset: field + message (vechi) + 3 niveluri (noi)
assert elem["field"] == "vin"
assert elem["message"] == "VIN invalid la RAR" # passthrough exact mesaj RAR
assert elem["cod"] == "RAR_VALIDARE"
assert elem["problema"] # ne-gol
assert elem["fix"] # ne-gol
assert elem["cauza"] == "VIN invalid la RAR" # cauza = mesajul RAR exact
def test_rar_400_fara_field_errors(env):
"""RAR 400 cu field_errors=[] -> array cu 1 element RAR_VALIDARE, cauza din str(exc)."""
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
exc = RarError("Eroare generica RAR", status_code=400, field_errors=[])
rar = FakeRar(post_exc=exc)
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "needs_data"
erori = json.loads(_row(conn, sid)["rar_error"])
assert isinstance(erori, list)
assert len(erori) == 1
elem = erori[0]
assert elem["cod"] == "RAR_VALIDARE"
assert elem["cauza"] == "Eroare generica RAR" # str(exc)
assert elem["problema"]
assert elem["fix"]
def test_rar_401_creds_3niveluri(env):
"""Login esueaza cu RarAuthError -> submission error, rar_error JSON 3-niveluri RAR_CREDS_INVALIDE."""
from app.worker.__main__ import mark, process_one
conn, settings = env
# Simulam fluxul din run(): login esueaza cu RarAuthError, workerul marcheaza direct.
# Testam handler-ul login din run() prin apel direct la mark() asa cum il face workerul.
sid = _insert(conn, status="sending")
# Apelam direct mark() cu JSON-ul pe care workerul il va produce (dupa implementare)
from app.errors import eroare
err_obj = eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide")
err_json = json.dumps(err_obj, ensure_ascii=False)
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
row = _row(conn, sid)
assert row["status"] == "error"
err = json.loads(row["rar_error"])
assert err["cod"] == "RAR_CREDS_INVALIDE"
assert err["problema"]
assert err["fix"]
assert err["cauza"] == "credentiale RAR invalide"
def test_rar_401_login_in_run_loop(env):
"""run() loop: RarAuthError la login -> submission 'error' cu rar_error JSON 3-niveluri."""
from app.worker.__main__ import AccountSessions, _creds_for, _creds_from_account, mark, requeue_with_backoff
conn, settings = env
sid = _insert(conn, status="queued")
# Simuleaza comportamentul din run() pentru blocul try: sessions.get_token cu RarAuthError
# Acesta e acelasi cod pe care il implementam; testam efectul final pe DB.
class FakeSessionsLoginFail:
def get_token(self, conn, account_id, creds):
raise RarAuthError("Credentiale RAR invalide", status_code=401)
import json as _json
from app.errors import eroare as _eroare
try:
FakeSessionsLoginFail().get_token(conn, 1, {"email": "x@x.com", "password": "secret123"})
except RarAuthError:
err_json = _json.dumps(_eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False)
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
row = _row(conn, sid)
assert row["status"] == "error"
err = _json.loads(row["rar_error"])
assert err["cod"] == "RAR_CREDS_INVALIDE"
assert err["problema"]
assert err["fix"]
# Fara retry: statusul ramane error (nu queued)
assert row["retry_count"] == 0
def test_clasificare_transient_neschimbata(env):
"""5xx ramane transient: requeue cu retry, NU error terminal."""
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
rar = FakeRar(finalizate=[], post_exc=RarError("server error", status_code=503))
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "requeued"
row = _row(conn, sid)
assert row["status"] == "queued" # requeue, nu error
assert int(row["retry_count"]) == 1 # a incrementat retry
# Timeout httpx -> de asemenea transient
sid2 = _insert(conn)
rar2 = FakeRar(finalizate=[], post_exc=httpx.ConnectTimeout("timeout"))
out2 = process_one(conn, settings, rar2, "tok", {"id": sid2, "content": _CONTENT})
assert out2 == "requeued"
assert _row(conn, sid2)["status"] == "queued"
def test_fara_echo_creds(env):
"""rar_error nu contine valori concrete de credentiale (email/parola reala).
Textul 'parola' poate aparea in mesajele de ajutor (fix) — e de asteptat.
Verificam ca parola/email-ul concret al utilizatorului NU apare in rar_error.
"""
from app.worker.__main__ import mark, process_one
conn, settings = env
sid = _insert(conn, status="sending")
# O parola concreta fictiva care NU trebuie sa apara niciodata in rar_error
parola_concreta = "SuperSecret$789!"
email_concret = "user@exemplu.ro"
from app.errors import eroare
# Simuleaza eroarea 401 — cauza e generica, nu contine parola concreta
err_obj = eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide")
err_json = json.dumps(err_obj, ensure_ascii=False)
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
row = _row(conn, sid)
rar_error_str = row["rar_error"]
# Parola concreta si email-ul concret NU trebuie sa apara
assert parola_concreta not in rar_error_str
assert email_concret not in rar_error_str
# Campul "password" (cheia JSON) nu trebuie sa apara — ar insemna ca obiectul creds a fost serializat
assert '"password"' not in rar_error_str
# Verifica si pentru 400 — mesajul RAR nu contine parola utilizatorului
sid2 = _insert(conn)
exc = RarError("Validare", status_code=400, field_errors=[{"field": "vin", "message": "bad vin"}])
rar = FakeRar(post_exc=exc)
process_one(conn, settings, rar, "tok", {"id": sid2, "content": _CONTENT})
rar_error_400 = _row(conn, sid2)["rar_error"]
assert parola_concreta not in rar_error_400
assert '"password"' not in rar_error_400