From d7ba1195d42f9ed14bcb1cb17f94049fd66de9fe Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Fri, 19 Jun 2026 08:56:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20dashboard=20compact=20=E2=80=94=20?= =?UTF-8?q?import=20pe=20Acasa,=20status=20cu=20bife,=20Trimiteri=20lizibi?= =?UTF-8?q?le,=20Mapari=20complete=20(3.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acasa = ecran de import (tab Import scos, ?tab=import->Acasa). Bara status compacta pe 2 randuri cu bife accesibile (glife + text) + data formatata. 'Coada'->'Trimiteri': coloane RO, stare umana, detaliu la click in panou dedicat. Mapari pe 3 sectiuni (de rezolvat / op salvate / formate coloane), Cont doar cheie+creds. Filtrare Trimiteri, corectie inline needs_data cu re-enqueue + detectie coliziune idempotency, badge contoare pe tab-uri. Helper pur partajat payload_view.py (web + GET /v1/prezentari). Backend trimitere (worker/idempotenta/mapping/schema) neatins. 483 teste. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/router.py | 23 +- app/payload_view.py | 112 ++++ app/web/labels.py | 76 +++ app/web/routes.py | 553 +++++++++++++++++- app/web/templates/_acasa.html | 113 ++-- app/web/templates/_coada.html | 45 +- app/web/templates/_mapari.html | 219 +++++-- app/web/templates/_preview_import.html | 18 +- app/web/templates/_status.html | 72 +-- app/web/templates/_submissions.html | 45 +- app/web/templates/_trimitere_detaliu.html | 98 ++++ app/web/templates/dashboard.html | 7 +- docs/ROADMAP.md | 5 +- ...-3.5-dashboard-compact-trimiteri-mapari.md | 495 ++++++++++++++++ tests/test_dashboard_scope.py | 8 +- tests/test_payload_view.py | 82 +++ tests/test_web_badge.py | 123 ++++ tests/test_web_corectie.py | 205 +++++++ tests/test_web_dashboard.py | 100 ++++ tests/test_web_filtrare.py | 141 +++++ tests/test_web_formate_coloane.py | 145 +++++ tests/test_web_labels.py | 36 +- tests/test_web_mapari_salvate.py | 219 +++++++ tests/test_web_mapari_ui.py | 116 ++++ tests/test_web_onboarding.py | 44 +- tests/test_web_preview_motive.py | 88 +++ tests/test_web_status.py | 129 ++++ tests/test_web_submissions.py | 150 +++++ tests/test_web_tabs.py | 7 +- 29 files changed, 3241 insertions(+), 233 deletions(-) create mode 100644 app/payload_view.py create mode 100644 app/web/templates/_trimitere_detaliu.html create mode 100644 docs/prd/prd-3.5-dashboard-compact-trimiteri-mapari.md create mode 100644 tests/test_payload_view.py create mode 100644 tests/test_web_badge.py create mode 100644 tests/test_web_corectie.py create mode 100644 tests/test_web_dashboard.py create mode 100644 tests/test_web_filtrare.py create mode 100644 tests/test_web_formate_coloane.py create mode 100644 tests/test_web_mapari_salvate.py create mode 100644 tests/test_web_mapari_ui.py create mode 100644 tests/test_web_preview_motive.py create mode 100644 tests/test_web_status.py create mode 100644 tests/test_web_submissions.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 39b3b51..91e7f16 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -34,6 +34,7 @@ from ...mapping import ( save_mapping, ) from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult +from ...payload_view import prezentare_din_payload from ...validation import validate_prezentare router = APIRouter(prefix="/v1", tags=["v1"]) @@ -139,19 +140,31 @@ def list_prezentari( conn = get_connection() try: scope_sql, scope_params = account_scope_clause(account_id) + # payload_json e plaintext (vezi submissions.payload_json); il citim doar ca + # sa derivam campurile afisabile prin helper-ul partajat (US-003, DRY), nu il expunem. + cols = ( + "id, status, id_prezentare, rar_status_code, retry_count, " + "created_at, updated_at, payload_json" + ) if status: rows = conn.execute( - f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " - f"FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC LIMIT ?", + f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? " + f"ORDER BY id DESC LIMIT ?", scope_params + [status, limit], ).fetchall() else: rows = conn.execute( - f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " - f"FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?", + f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?", scope_params + [limit], ).fetchall() - return {"submissions": [dict(r) for r in rows]} + out = [] + for r in rows: + d = dict(r) + # Campuri afisabile derivate din payload (acelasi helper ca dashboardul web); + # payload_json brut nu se intoarce in raspuns. + d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None)) + out.append(d) + return {"submissions": out} finally: conn.close() diff --git a/app/payload_view.py b/app/payload_view.py new file mode 100644 index 0000000..37f293a --- /dev/null +++ b/app/payload_view.py @@ -0,0 +1,112 @@ +"""Extragere payload submission -> campuri afisabile (US-003, PRD 3.5). + +Helper PUR partajat intre canalul web (dashboard Trimiteri) si canalul API +(`GET /v1/prezentari`), ca extragerea sa NU diverge intre cele doua (decizie +eng review, DRY). Fara DB, fara request — primeste `payload_json` (text JSON +plaintext, vezi `submissions.payload_json`) sau un dict deja parsat. + +Defensiv prin constructie: nu arunca niciodata pe payload malformat — degradeaza +la em-dash. Citeste cheile tolerant (canalele API si import pot diferi usor: +`nr_inmatriculare` vs `numar`/`numarInmatriculare`; coercion Excel pe odometru/VIN). +""" + +from __future__ import annotations + +import json +from typing import Any + +EMPTY = "—" + + +def _clean_str(value: Any) -> str: + """str() defensiv: None/'' -> '', altfel string strip-uit (coercion Excel safe).""" + if value is None: + return "" + return str(value).strip() + + +def _clean_odometru(value: Any) -> str: + """Odometru afisat curat: strip '.0' din coercion Excel (123456.0 -> 123456).""" + s = _clean_str(value) + if "." in s: + before, after = s.split(".", 1) + if after == "0" and before.lstrip("-").isdigit(): + return before + return s + + +def _vin_scurt(vin: str) -> str: + """Forma trunchiata a VIN-ului pentru tabel (integral ramane in detaliu). + + VIN are 17 caractere; in tabel aratam ultimele 6 cu prefix elipsa ca sa + incapa fara sa ascundem complet identitatea vehiculului. + """ + if not vin: + return "" + if len(vin) <= 8: + return vin + return "…" + vin[-6:] + + +def _prima_prestatie(prestatii: Any) -> dict[str, Any]: + """Primul item de prestatie ca dict, defensiv (lista goala/non-dict -> {}).""" + if isinstance(prestatii, list): + for item in prestatii: + if isinstance(item, dict): + return item + return {} + + +def _payload_dict(payload: str | dict | None) -> dict[str, Any]: + """Normalizeaza intrarea la dict. JSON invalid / tip neasteptat -> {}.""" + if payload is None: + return {} + if isinstance(payload, dict): + return payload + if isinstance(payload, str): + try: + data = json.loads(payload) + except (ValueError, TypeError): + return {} + return data if isinstance(data, dict) else {} + return {} + + +def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]: + """Campuri afisabile dintr-un payload de submission. + + Intoarce un dict cu chei stabile (toate string-uri, EMPTY cand lipsesc): + vehicul_nr, vin, vin_scurt, operatie, data_prestatie, odometru, cod. + + `operatie` = denumire prestatiei daca exista, altfel codul; `cod` = codul RAR + (`cod_prestatie`) sau, in lipsa, codul intern (`cod_op_service`). + """ + data = _payload_dict(payload) + + vin = _clean_str(data.get("vin")) + nr = _clean_str( + data.get("nr_inmatriculare") + or data.get("numar") + or data.get("numarInmatriculare") + ) + odo = _clean_odometru( + data.get("odometru_final") + if data.get("odometru_final") is not None + else data.get("odometru") + ) + data_prest = _clean_str(data.get("data_prestatie") or data.get("dataPrestatie")) + + item = _prima_prestatie(data.get("prestatii")) + cod = _clean_str(item.get("cod_prestatie") or item.get("cod_op_service")) + denumire = _clean_str(item.get("denumire")) + operatie = denumire or cod + + return { + "vehicul_nr": nr or EMPTY, + "vin": vin or EMPTY, + "vin_scurt": _vin_scurt(vin) or EMPTY, + "operatie": operatie or EMPTY, + "data_prestatie": data_prest or EMPTY, + "odometru": odo or EMPTY, + "cod": cod or EMPTY, + } diff --git a/app/web/labels.py b/app/web/labels.py index 2ebfdf3..1172e7a 100644 --- a/app/web/labels.py +++ b/app/web/labels.py @@ -6,6 +6,8 @@ Functii pure: fara DB, fara request. Usor de testat unitar si de importat in tem Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001. """ +import json +from datetime import datetime from typing import Tuple # --------------------------------------------------------------------------- @@ -119,6 +121,80 @@ def eticheta_rar(stare: str) -> Eticheta: ) +# --------------------------------------------------------------------------- +# Format data RAR (US-001, PRD 3.5) +# --------------------------------------------------------------------------- + +def format_data_rar(raw: object) -> str: + """Formateaza un timestamp ISO ca `dd.mm.yyyy hh24:mi:ss` (ora romaneasca). + + - Valoare lipsa (None / "") -> "—". + - ISO valid (cu sau fara timezone / 'Z' / microsecunde) -> data formatata, + fara fractiuni de secunda. + - Format invalid -> fallback grijuliu: intoarce stringul brut (nu arunca), + ca operatorul sa vada totusi ceva, nu o pagina rupta. + """ + if raw is None: + return "—" + s = str(raw).strip() + if not s: + return "—" + iso = s.replace("Z", "+00:00") if s.endswith("Z") else s + try: + dt = datetime.fromisoformat(iso) + except (ValueError, TypeError): + return s + return dt.strftime("%d.%m.%Y %H:%M:%S") + + +# --------------------------------------------------------------------------- +# Motiv uman din rar_error (US-004, PRD 3.5) +# --------------------------------------------------------------------------- + +def motiv_uman(status: str, rar_error: object) -> str: + """Transforma `rar_error` (JSON tehnic) intr-un motiv lizibil pentru coloana Motiv. + + Formele intalnite (vezi router.py / mapping.py): + - validare continut: list[{field, message}] -> mesajele concatenate. + - operatie nemapata: {"unmapped": [{cod_op_service, denumire}]}. + - auto-send oprit: {"auto_send": "..."}. + - eroare RAR: text simplu sau dict generic. + Fara rar_error -> "". Nu arunca niciodata (degradeaza la text brut trunchiat). + """ + if not rar_error: + return "" + raw = rar_error if isinstance(rar_error, str) else str(rar_error) + try: + data = json.loads(raw) + except (ValueError, TypeError): + return raw[:160] + + if isinstance(data, dict): + if "unmapped" in data: + ops = data.get("unmapped") or [] + nume = ", ".join( + (o.get("cod_op_service") or "") for o in ops if isinstance(o, dict) + ).strip(", ") + 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)" + parti = [f"{k}: {v}" for k, v in data.items()] + return "; ".join(parti)[:200] + + if isinstance(data, list): + msgs: list[str] = [] + for e in data: + if isinstance(e, dict): + msgs.append( + str(e.get("message") or e.get("msg") or "; ".join(str(v) for v in e.values())) + ) + else: + msgs.append(str(e)) + return "; ".join(m for m in msgs if m)[:200] + + return str(data)[:160] + + # --------------------------------------------------------------------------- # Constante auxiliare (microcopy fix, fara logica) # --------------------------------------------------------------------------- diff --git a/app/web/routes.py b/app/web/routes.py index a82a271..8e1f640 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -13,22 +13,26 @@ from __future__ import annotations import hashlib import json +import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Any -from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from .. import __version__ from ..auth import rotate_api_key +from ..payload_view import prezentare_din_payload from ..web.csrf import get_csrf_token, verify_csrf from .labels import ( ETICHETA_ULTIMA_AUTENTIFICARE_RAR, eticheta_rar, eticheta_stare, eticheta_worker, + format_data_rar, + motiv_uman, ) from ..web.session import require_login from ..api.v1.import_router import ( @@ -43,11 +47,14 @@ from ..config import get_settings from ..crypto import decrypt_creds, encrypt_creds from ..db import get_connection, read_heartbeat from ..idempotency import build_key, canonicalize_row +from ..validation import validate_prezentare from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file from ..users import is_account_admin from ..mapping import ( DEFAULT_ACCOUNT_ID, account_or_default, + account_scope_clause, + has_no_auto_send, load_mapping_meta, load_nomenclator, pending_unmapped, @@ -121,7 +128,9 @@ def _rar_state(hb, worker_alive: bool) -> str: return "indisponibil?" if age > 108000 else "ok" -_TABS_VALIDE = {"acasa", "import", "coada", "mapari", "cont", "nomenclator"} +# US-002: "import" nu mai e tab separat — importul traieste pe Acasa. ?tab=import +# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404. +_TABS_VALIDE = {"acasa", "coada", "mapari", "cont", "nomenclator"} def _get_acasa_context(request: Request, conn, account_id: int) -> dict: @@ -158,13 +167,17 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: "are_creds": are_creds, "are_trimiteri": are_trimiteri, "are_cheie_folosita": are_cheie_folosita, + # US-002: Acasa include caseta de upload -> are nevoie de csrf_token + "csrf_token": get_csrf_token(request), } def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str: """Randeaza panoul Acasa ca string HTML.""" if conn is None: - return templates.get_template("_acasa.html").render({"request": request}) + return templates.get_template("_acasa.html").render( + {"request": request, "csrf_token": get_csrf_token(request)} + ) ctx = _get_acasa_context(request, conn, account_id) return templates.get_template("_acasa.html").render(ctx) @@ -183,10 +196,12 @@ def _render_panel_coada(request: Request) -> str: def _render_panel_mapari(request: Request, conn, account_id: int) -> str: - """Randeaza panoul Mapari ca string HTML.""" + """Randeaza panoul Mapari ca string HTML (3 sectiuni: de rezolvat / op salvate / formate).""" return templates.get_template("_mapari.html").render({ "request": request, "pending": pending_unmapped(conn, account_id), + "saved_mappings": _load_saved_op_mappings(conn, account_id), + "column_formats": _load_column_formats(conn, account_id), "nomenclator": load_nomenclator(conn), "message": None, "csrf_token": get_csrf_token(request), @@ -251,12 +266,19 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse: conn = get_connection() try: panel_html = _render_panel_for_tab(request, conn, account_id, active_tab) + # Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari, blocate -> Trimiteri. + counts = _status_counts(conn, account_id) + badges = { + "mapari": counts.get("needs_mapping", 0), + "coada": sum(counts.get(s, 0) for s in _BLOCKED), + } ctx = { "request": request, "rar_env": get_settings().rar_env, "version": __version__, "active_tab": active_tab, "panel_html": panel_html, + "badges": badges, "is_admin": is_account_admin(conn, account_id), "csrf_token": get_csrf_token(request), } @@ -355,16 +377,22 @@ def fragment_status(request: Request) -> HTMLResponse: # Etichete umane pre-calculate (nu logica in template) worker_lbl = eticheta_worker(worker_alive) # eticheta_rar accepta "ok" sau orice alt string -> indisponibil/necunoscut - rar_lbl = eticheta_rar("ok" if rar_state == "ok" else rar_state) + rar_ok = rar_state == "ok" + rar_lbl = eticheta_rar("ok" if rar_ok else rar_state) + blocate_total = sum(counts.get(s, 0) for s in _BLOCKED) return templates.TemplateResponse("_status.html", { "request": request, "worker_lbl": worker_lbl, "rar_lbl": rar_lbl, + # Stari binare pentru bife accesibile (US-001 PRD 3.5): glifa + culoare + "worker_ok": worker_alive, + "rar_ok": rar_ok, "eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR, - "last_login": hb["last_rar_login_ok"] if hb else None, + "last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None), "counts_queued": counts.get("queued", 0), "counts_sent": counts.get("sent", 0), + "blocate_total": blocate_total, "blocate_defalcat": _blocate_defalcat(counts), "account_active": _account_active(conn, account_id), }) @@ -372,23 +400,376 @@ def fragment_status(request: Request) -> HTMLResponse: conn.close() +def _is_iso_date(value: object) -> bool: + """True daca `value` e o data ISO YYYY-MM-DD (comparabila lexicografic corect).""" + s = str(value or "").strip() + if len(s) != 10: + return False + try: + datetime.strptime(s, "%Y-%m-%d") + return True + except (ValueError, TypeError): + return False + + +def _submission_row_view(r) -> dict: + """Imbogateste un rand de submission cu campuri afisabile umane (US-003/US-004).""" + eticheta = eticheta_stare(r["status"]) + return { + "id": r["id"], + "status": r["status"], + "stare_text": eticheta[0], + "stare_css": eticheta[2], + "prez": prezentare_din_payload(r["payload_json"]), + "id_prezentare": r["id_prezentare"], + "updated_at": format_data_rar(r["updated_at"]), + "motiv": motiv_uman(r["status"], r["rar_error"]), + } + + @router.get("/_fragments/submissions", response_class=HTMLResponse) -def fragment_submissions(request: Request) -> HTMLResponse: +def fragment_submissions( + request: Request, + status: str | None = None, + vehicul: str | None = None, + data_de: str | None = None, + data_pana: str | None = None, +) -> HTMLResponse: + """Tabel Trimiteri, scoped pe cont, cu filtre optionale (US-009). + + Filtrarea pe stare se face in SQL (foloseste idx_submissions_account_status); + filtrarea pe vehicul (nr/VIN, case-insensitive) si pe interval data_prestatie + se face dupa parsarea payload_json in Python (plafon perf notat — eng review). + """ + account_id = require_login(request) + status = (status or "").strip() or None + vehicul_q = (vehicul or "").strip().upper() or None + data_de = (data_de or "").strip() or None + data_pana = (data_pana or "").strip() or None + filtru_activ = bool(status or vehicul_q or data_de or data_pana) + + conn = get_connection() + try: + scope_sql, scope_params = account_scope_clause(account_id) + where = [scope_sql] + params: list = list(scope_params) + if status: + where.append("status=?") + params.append(status) + # Filtrarea pe vehicul/data se face in Python (dupa parsarea payload). Daca am + # taia la LIMIT inainte de filtru, am rata silentios randuri mai vechi care + # potrivesc. Cand un filtru text/data e activ, scoatem LIMIT-ul din SQL si plafonam + # afisarea dupa filtrare (OK la scara actuala — plafon perf notat, eng review). + limit_sql = "" if (vehicul_q or data_de or data_pana) else " LIMIT 200" + rows = conn.execute( + "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " + "updated_at, payload_json FROM submissions " + f"WHERE {' AND '.join(where)} ORDER BY id DESC{limit_sql}", + params, + ).fetchall() + + view = [] + for r in rows: + v = _submission_row_view(r) + prez = v["prez"] + if vehicul_q: + hay = f"{prez['vehicul_nr']} {prez['vin']}".upper() + if vehicul_q not in hay: + continue + if data_de or data_pana: + d = prez["data_prestatie"] + # Comparam doar date in format ISO (YYYY-MM-DD); altfel comparatia de string + # ar fi gresita (ex. "05.12.2024"). Valori ne-ISO sunt excluse din filtru. + if not _is_iso_date(d): + continue + if data_de and d < data_de: + continue + if data_pana and d > data_pana: + continue + view.append(v) + if len(view) >= 200: + break + + return templates.TemplateResponse("_submissions.html", { + "request": request, + "rows": view, + "filtru_activ": filtru_activ, + }) + finally: + conn.close() + + +# Stari ne-trimise blocate pe care le putem corecta inline (US-010). +_CORECTABILE = ("needs_data", "needs_mapping") + + +def _payload_form_values(payload_json) -> dict: + """Valori brute pentru prefill-ul formularului de corectie (US-010).""" + try: + data = json.loads(payload_json) if payload_json else {} + if not isinstance(data, dict): + data = {} + except (ValueError, TypeError): + data = {} + return { + "form_vin": data.get("vin") or "", + "form_nr": data.get("nr_inmatriculare") or "", + "form_data": data.get("data_prestatie") or "", + "form_odo_final": data.get("odometru_final") or "", + "form_odo_initial": data.get("odometru_initial") or "", + } + + +def _detaliu_ctx(request: Request, row, *, message: str | None = None, + error: bool = False, corectie_errors: list | None = None) -> dict: + """Context pentru _trimitere_detaliu.html dintr-un rand de submission.""" + eticheta = eticheta_stare(row["status"]) + ctx = { + "request": request, + "csrf_token": get_csrf_token(request), + "id": row["id"], + "status": row["status"], + "stare_text": eticheta[0], + "stare_css": eticheta[2], + "stare_subtext": eticheta[1], + "prez": prezentare_din_payload(row["payload_json"]), + "id_prezentare": row["id_prezentare"], + "rar_status_code": row["rar_status_code"], + "rar_error": row["rar_error"], + "motiv": motiv_uman(row["status"], row["rar_error"]), + "retry_count": row["retry_count"], + "created_at": format_data_rar(row["created_at"]), + "updated_at": format_data_rar(row["updated_at"]), + "next_attempt_at": format_data_rar(row["next_attempt_at"]), + # randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu + "editabil": row["status"] in _CORECTABILE, + "corectie_msg": message, + "corectie_error": error, + "corectie_errors": corectie_errors or [], + } + ctx.update(_payload_form_values(row["payload_json"])) + return ctx + + +def _fetch_submission_scoped(conn, account_id: int, submission_id: int): + """Randul scoped pe cont sau None (404 cross-account, nu confirmam existenta — B3).""" + scope_sql, scope_params = account_scope_clause(account_id) + return conn.execute( + f"SELECT * FROM submissions WHERE id=? AND {scope_sql}", + [submission_id] + scope_params, + ).fetchone() + + +# Campuri afisate in detaliul trimiterii (panou dedicat US-004). payload_json e +# plaintext si se foloseste doar pentru campurile derivate (prezentare_din_payload). +@router.get("/_fragments/trimitere/{submission_id}", response_class=HTMLResponse) +def fragment_trimitere_detaliu(request: Request, submission_id: int) -> HTMLResponse: + """Detaliu complet al unei trimiteri, in panou dedicat (#trimitere-detaliu). + + Scoped pe contul sesiunii: 404 daca randul nu exista SAU apartine altui cont + (acelasi mesaj, nu confirmam existenta — vezi B3/router.py). + """ account_id = require_login(request) conn = get_connection() try: - rows = conn.execute( - "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at " - "FROM submissions " - "WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) " - "ORDER BY id DESC LIMIT 100", - (account_id, account_id), - ).fetchall() - return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows}) + row = _fetch_submission_scoped(conn, account_id, submission_id) + if not row: + raise HTTPException(status_code=404, detail="trimitere inexistenta") + return templates.TemplateResponse("_trimitere_detaliu.html", _detaliu_ctx(request, row)) finally: conn.close() +@router.post("/trimitere/{submission_id}/corecteaza", response_class=HTMLResponse) +async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLResponse: + """Corectie inline pentru randuri ne-trimise blocate (needs_data/needs_mapping). + + Re-valideaza (validation.py, fara reguli noi), reconstruieste payload_json, + recalculeaza idempotency_key (canonicalize -> build_key, ca la enqueue) si + re-pune randul in 'queued' (re-enqueue). NU atinge worker-ul / masina de stari. + Randurile sent/sending/queued/error raman read-only (gard explicit -> 403). + Coliziune de idempotency detectata INAINTE de UPDATE (fara 500/duplicat). + """ + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + conn = get_connection() + try: + row = _fetch_submission_scoped(conn, account_id, submission_id) + if not row: + raise HTTPException(status_code=404, detail="trimitere inexistenta") + # Gard read-only: doar randurile blocate ne-trimise sunt corectabile. + if row["status"] not in _CORECTABILE: + raise HTTPException(status_code=403, detail="trimitere read-only (deja procesata)") + + try: + content = json.loads(row["payload_json"]) if row["payload_json"] else {} + if not isinstance(content, dict): + content = {} + except (ValueError, TypeError): + content = {} + + # Aplica DOAR campurile prezente in form (negoale). + for camp in ("vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "odometru_initial"): + val = form.get(camp) + if isinstance(val, str) and val.strip() != "": + content[camp] = val.strip() + + # Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune + # niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil + # la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata. + mapping_meta = load_mapping_meta(conn, account_id) + mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()} + resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping) + content["prestatii"] = resolved + + # Canonicalizare (strip ".0" odometru, VIN/nr upper) INAINTE de validare si cheie. + canon = canonicalize_row(content) + content.update({ + "vin": canon["vin"], + "nr_inmatriculare": canon["nr_inmatriculare"], + "odometru_final": canon["odometru_final"], + }) + payload_json = json.dumps(content, ensure_ascii=False) + + if unmapped: + conn.execute( + "UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, " + "updated_at=datetime('now') WHERE id=?", + (payload_json, json.dumps({"unmapped": unmapped}, ensure_ascii=False), row["id"]), + ) + row2 = _fetch_submission_scoped(conn, account_id, submission_id) + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx(request, row2, error=True, + message="Lipseste inca un cod RAR — rezolva operatia in tab-ul Mapari."), + ) + + if has_no_auto_send(resolved, mapping_meta): + conn.execute( + "UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, " + "updated_at=datetime('now') WHERE id=?", + (payload_json, + json.dumps({"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"}, + ensure_ascii=False), + row["id"]), + ) + row2 = _fetch_submission_scoped(conn, account_id, submission_id) + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx(request, row2, error=True, + message="Cod cu auto-send oprit — confirma manual din tab-ul Mapari."), + ) + + errors = validate_prezentare(content) + if errors: + # Inca invalid: persista valorile introduse, ramane needs_data, arata motivul pe camp. + conn.execute( + "UPDATE submissions SET status='needs_data', payload_json=?, rar_error=?, " + "updated_at=datetime('now') WHERE id=?", + (payload_json, json.dumps(errors, ensure_ascii=False), row["id"]), + ) + row2 = _fetch_submission_scoped(conn, account_id, submission_id) + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx(request, row2, message="Mai sunt campuri invalide — vezi mai jos.", + error=True, corectie_errors=errors), + ) + + # Valid: recalculeaza cheia. Coliziune cu alt rand -> opreste, fara 500/duplicat. + new_key = build_key(account_id, canon) + if new_key != row["idempotency_key"]: + dup = conn.execute( + "SELECT id FROM submissions WHERE idempotency_key=? AND id<>?", + (new_key, row["id"]), + ).fetchone() + if dup: + row2 = _fetch_submission_scoped(conn, account_id, submission_id) + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx( + request, row2, + message=f"Exista deja o trimitere identica (rand #{dup['id']}). Corectia a fost oprita.", + error=True, + ), + ) + + try: + conn.execute( + "UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, " + "rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), " + "updated_at=datetime('now') WHERE id=?", + (new_key, payload_json, row["id"]), + ) + except sqlite3.IntegrityError: + # Plasa de siguranta pentru cursa TOCTOU pe UNIQUE(idempotency_key): + # pre-check-ul a trecut dar alt rand a primit cheia intre timp. Fara 500. + row2 = _fetch_submission_scoped(conn, account_id, submission_id) + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx(request, row2, error=True, + message="Exista deja o trimitere identica. Corectia a fost oprita."), + ) + row2 = _fetch_submission_scoped(conn, account_id, submission_id) + return templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."), + ) + finally: + conn.close() + + +def _load_saved_op_mappings(conn, account_id: int) -> list[dict]: + """Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele + prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu).""" + acct = account_or_default(account_id) + rows = conn.execute( + "SELECT o.id, o.cod_op_service, o.cod_prestatie, o.auto_send, n.nume_prestatie " + "FROM operations_mapping o " + "LEFT JOIN nomenclator_rar n ON n.cod_prestatie = o.cod_prestatie " + "WHERE o.account_id=? ORDER BY o.cod_op_service", + (acct,), + ).fetchall() + return [ + { + "id": r["id"], + "cod_op_service": r["cod_op_service"], + "cod_prestatie": r["cod_prestatie"], + "auto_send": bool(r["auto_send"]), + "nume_prestatie": r["nume_prestatie"], + } + for r in rows + ] + + +def _load_column_formats(conn, account_id: int) -> list[dict]: + """Formate de coloane salvate (column_mappings) ale contului (US-006). + + Coloanele afisate = cheile din json_mapare (campurile recunoscute). Scoped pe cont. + """ + acct = account_or_default(account_id) + rows = conn.execute( + "SELECT id, signature_coloane, json_mapare, format_data, created_at " + "FROM column_mappings WHERE account_id=? ORDER BY id DESC", + (acct,), + ).fetchall() + out: list[dict] = [] + for r in rows: + try: + jm = json.loads(r["json_mapare"]) if r["json_mapare"] else {} + except (ValueError, TypeError): + jm = {} + out.append({ + "id": r["id"], + "signature_coloane": r["signature_coloane"], + "mappings": jm, + "columns": list(jm.keys()), + "format_data": r["format_data"], + "created_at": r["created_at"], + }) + return out + + def _render_mapari( request: Request, conn, account_id: int, *, message: str | None = None ) -> HTMLResponse: @@ -397,6 +778,8 @@ def _render_mapari( { "request": request, "pending": pending_unmapped(conn, account_id), + "saved_mappings": _load_saved_op_mappings(conn, account_id), + "column_formats": _load_column_formats(conn, account_id), "nomenclator": load_nomenclator(conn), "message": message, "csrf_token": get_csrf_token(request), @@ -447,6 +830,146 @@ def post_mapare( conn.close() +# =========================================================================== # +# US-005 — Mapari operatii salvate: editare cod/auto-send + stergere # +# CRUD pe operations_mapping scoped pe sesiune; re-rezolva blocatele la edit. # +# =========================================================================== # + +@router.post("/mapari/salvate", response_class=HTMLResponse) +def post_editeaza_mapare_salvata( + request: Request, + cod_op_service: str = Form(...), + cod_prestatie: str = Form(...), + csrf_token: str | None = Form(None), + auto_send: bool = Form(False), +) -> HTMLResponse: + """Editeaza o mapare op->cod salvata (cod RAR / auto-send) + re-rezolva blocatele. + + Scoped pe contul sesiunii (save_mapping foloseste account_or_default(sesiune) — + cross-account imposibil). Respinge cod inexistent in nomenclator. + """ + account_id = require_login(request) + verify_csrf(request, csrf_token) + conn = get_connection() + try: + cod = cod_prestatie.strip().upper() + exists = conn.execute( + "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) + ).fetchone() + if not exists: + return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}") + save_mapping(conn, account_id, cod_op_service, cod, auto_send) + stats = reresolve_account(conn, account_id) + msg = ( + f"Mapare actualizata: {cod_op_service.strip()} -> {cod}. " + f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, " + f"{stats['still_blocked']} inca nemapate." + ) + return _render_mapari(request, conn, account_id, message=msg) + finally: + conn.close() + + +@router.post("/mapari/salvate/sterge", response_class=HTMLResponse) +def post_sterge_mapare_salvata( + request: Request, + cod_op_service: str = Form(...), + csrf_token: str | None = Form(None), +) -> HTMLResponse: + """Sterge o mapare op->cod salvata. Scoped pe contul sesiunii.""" + account_id = require_login(request) + verify_csrf(request, csrf_token) + acct = account_or_default(account_id) + conn = get_connection() + try: + conn.execute( + "DELETE FROM operations_mapping WHERE account_id=? AND cod_op_service=?", + (acct, cod_op_service.strip()), + ) + return _render_mapari( + request, conn, account_id, + message=f"Mapare stearsa: {cod_op_service.strip()}.", + ) + finally: + conn.close() + + +# =========================================================================== # +# US-006 — Formate de coloane salvate: editare format data + stergere # +# CRUD pe column_mappings scoped pe sesiune (prin id, verificat pe account). # +# =========================================================================== # + +@router.post("/formate-coloane/editeaza", response_class=HTMLResponse) +async def post_editeaza_format_coloane( + request: Request, + format_id: int = Form(...), + format_data: str | None = Form(None), + csrf_token: str | None = Form(None), +) -> HTMLResponse: + """Editeaza un format de coloane salvat (format data). Scoped pe cont prin id+account_id. + + json_mapare optional (string JSON valid) — daca e dat, inlocuieste maparea coloanelor. + """ + account_id = require_login(request) + verify_csrf(request, csrf_token) + acct = account_or_default(account_id) + form = await request.form() + json_mapare_raw = form.get("json_mapare") + conn = get_connection() + try: + owned = conn.execute( + "SELECT 1 FROM column_mappings WHERE id=? AND account_id=?", + (format_id, acct), + ).fetchone() + if not owned: + return _render_mapari( + request, conn, account_id, message="Format inexistent sau inaccesibil." + ) + fmt = (format_data or "").strip() or None + if isinstance(json_mapare_raw, str) and json_mapare_raw.strip(): + try: + jm = json.loads(json_mapare_raw) + if not isinstance(jm, dict): + raise ValueError + except (ValueError, TypeError): + return _render_mapari( + request, conn, account_id, message="Mapare coloane invalida (JSON)." + ) + conn.execute( + "UPDATE column_mappings SET json_mapare=?, format_data=? WHERE id=? AND account_id=?", + (json.dumps(jm, ensure_ascii=False), fmt, format_id, acct), + ) + else: + conn.execute( + "UPDATE column_mappings SET format_data=? WHERE id=? AND account_id=?", + (fmt, format_id, acct), + ) + return _render_mapari(request, conn, account_id, message="Format de coloane actualizat.") + finally: + conn.close() + + +@router.post("/formate-coloane/sterge", response_class=HTMLResponse) +def post_sterge_format_coloane( + request: Request, + format_id: int = Form(...), + csrf_token: str | None = Form(None), +) -> HTMLResponse: + """Sterge un format de coloane salvat. Scoped pe cont prin id+account_id.""" + account_id = require_login(request) + verify_csrf(request, csrf_token) + acct = account_or_default(account_id) + conn = get_connection() + try: + conn.execute( + "DELETE FROM column_mappings WHERE id=? AND account_id=?", + (format_id, acct), + ) + return _render_mapari(request, conn, account_id, message="Format de coloane sters.") + finally: + conn.close() + + # =========================================================================== # # Import UI (U5) — upload → mapare coloane → preview → confirmare # # Consuma helper-e din import_router fara a edita fisierul backend. # diff --git a/app/web/templates/_acasa.html b/app/web/templates/_acasa.html index edd094f..6c47829 100644 --- a/app/web/templates/_acasa.html +++ b/app/web/templates/_acasa.html @@ -1,81 +1,56 @@
-{% set toti_esentiali = are_creds and are_trimiteri %} + {# === Centru de greutate: caseta de upload (importul e operatia principala) === #} + {% include '_upload.html' %} -{% if toti_esentiali %} -{# Ghid colapsat/discret cand toti pasii esentiali sunt gata #} -
- Totul e configurat — - vezi coada -
-{% else %} -{# Card ghid de pornire vizibil cand nu toti pasii sunt finalizati #} -
-

Primii pasi

-
    + {# === Subordonat: primii pasi pe un singur rand compact === #} + {% set toti_esentiali = are_creds and are_trimiteri %} + {% if not toti_esentiali %} +
    +
    + Primii pasi: - {# Pas 1: Conecteaza contul RAR (esential) #} -
  • - {% if are_creds %} - - {% else %} - - {% endif %} - - Conecteaza-ti contul RAR - - Email + parola portal AUTOPASS RAR - + {# Pas 1: Cont RAR (esential) #} + + {% if are_creds %} + + {% else %} + + {% endif %} + Cont RAR -
  • - {# Pas 2: Cheie API (optional) #} -
  • - {% if are_cheie_folosita %} - - {% else %} - - {% endif %} - - Ia-ti cheia API - - Optional — doar daca trimiti din soft propriu prin API - + {# Pas 2: Cheie API (optional) #} + + {% if are_cheie_folosita %} + + {% else %} + + {% endif %} + Cheie API + (optional) -
  • - {# Pas 3: Import primul fisier (esential) #} -
  • - {% if are_trimiteri %} - - {% else %} - - {% endif %} - - Importa primul fisier - - Incarca un fisier xlsx/csv cu prezentarile de declarat la RAR - + {# Pas 3: Import (esential) — marcat ca pas curent #} + + {% if are_trimiteri %} + + {% else %} + + {% endif %} + Import (incarca fisierul sus) -
  • - -
-
-{% endif %} - -{# Rezumat si scurtaturi rapide (mereu vizibile) #} -
-

Bun venit la Gateway RAR AUTOPASS

-

- Importa fisiere din tab-ul Import, - urmareste coada in tab-ul Coada - si rezolva mapari lipsa in tab-ul Mapari. -

-
- Coada submissions - Import fisier nou - Mapari operatii +
+
+ {% endif %} + + {# === Subordonat: ajutor rapid pe un rand discret === #} +
+ Ajutor: + Trimiteri + Mapari + Coduri RAR
-
diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html index b5bbd72..ad7a93f 100644 --- a/app/web/templates/_coada.html +++ b/app/web/templates/_coada.html @@ -1,14 +1,55 @@
-

Coada submissions

+

Trimiteri catre RAR

export CSV: trimise toate
-
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
se incarca…
+ + +
diff --git a/app/web/templates/_mapari.html b/app/web/templates/_mapari.html index a71eef0..8e59f5f 100644 --- a/app/web/templates/_mapari.html +++ b/app/web/templates/_mapari.html @@ -1,61 +1,182 @@ -
-

Mapari de rezolvat

+
{% if message %} -
{{ message }}
+
{{ message }}
{% endif %} - {% if not pending %} -
- Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR. - Importa un fisier nou daca vrei sa adaugi prezentari. -
- {% else %} -

- Operatii ROAAUTO necunoscute, blocate in needs_mapping. - Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat. -

+ + + +
+

De rezolvat

- {% for e in pending %} - {% set top = e.suggestions[0] if e.suggestions else None %} - {% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %} -
- - + {% if not pending %} +
+ Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR. + Importa un fisier nou daca vrei sa adaugi prezentari. +
+ {% else %} +

+ Operatii ROAAUTO necunoscute, blocate in needs_mapping. + Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat. +

-
-
{{ e.cod_op_service }} - {{ e.blocked }} blocate
-
{{ e.denumire or '(fara denumire)' }}
- {% if e.suggestions %} -
- sugestii: - {% for s in e.suggestions[:3] %} - {{ s.cod_prestatie }} ({{ s.score|round|int }}%){% if not loop.last %}, {% endif %} - {% endfor %} + {% for e in pending %} + {% set top = e.suggestions[0] if e.suggestions else None %} + {% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %} + + + + +
+
{{ e.cod_op_service }} + {{ e.blocked }} blocate
+
{{ e.denumire or '(fara denumire)' }}
+ {% if e.suggestions %} +
+ sugestii: + {% for s in e.suggestions[:3] %} + {{ s.cod_prestatie }} ({{ s.score|round|int }}%){% if not loop.last %}, {% endif %} + {% endfor %} +
+ {% endif %}
- {% endif %} -
-
- -
+
+ +
-
- -
+
+ +
-
- +
+ +
+ + {% endfor %} + {% endif %} +
+ + + + +
+

Mapari operatii salvate

+ + {% if not saved_mappings %} +
+ Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
- - {% endfor %} - {% endif %} + {% else %} +

+ Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau auto-send si salveaza; + la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat. +

+ + {% for m in saved_mappings %} +
+ + + +
+
{{ m.cod_op_service }}
+
+ acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %} +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ {% endfor %} + {% endif %} +
+ + + + +
+

Formate de coloane salvate

+ + {% if not column_formats %} +
+ Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului + se retine aici si se reaplica automat la fisierele cu acelasi antet. +
+ {% else %} +

+ Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat (nu suprascrie). +

+ + {% for f in column_formats %} +
+
+
+ {{ f.columns | length }} coloane recunoscute + {% if f.format_data %} + data: {{ f.format_data }} + {% endif %} +
+
+ {% for col, camp in f.mappings.items() %} + {{ col }} → {{ camp }}{% if not loop.last %}; {% endif %} + {% endfor %} +
+
+ +
+ + + + +
+ +
+ + + +
+
+ {% endfor %} + {% endif %} +
+
diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html index 0d43d5e..788bd9e 100644 --- a/app/web/templates/_preview_import.html +++ b/app/web/templates/_preview_import.html @@ -156,12 +156,15 @@ {% elif row.flags %} {{ row.flags[0] }} {% elif row.errors %} - {%- set e = row.errors[0] -%} - {%- if e is mapping -%} - {{ e.values() | list | first }} - {%- else -%} - {{ e }} - {%- endif -%} + {# US-008: arata MOTIVUL (mesajul de validare), nu numele campului #} + {%- for e in row.errors -%} + {%- if e is mapping -%} + {{ e.get('message') or e.get('msg') or (e.values() | list | first) }} + {%- else -%} + {{ e }} + {%- endif -%} + {%- if not loop.last %}; {% endif -%} + {%- endfor -%} {% endif %} @@ -242,7 +245,8 @@
diff --git a/app/web/templates/_status.html b/app/web/templates/_status.html index ae53bb4..9fe2fb0 100644 --- a/app/web/templates/_status.html +++ b/app/web/templates/_status.html @@ -13,62 +13,52 @@
{% endif %} -
+ +
- -
-
{{ worker_lbl[0] }}
-
- {{ worker_lbl[0].split(':')[1].strip() if ':' in worker_lbl[0] else worker_lbl[0] }} -
- {% if worker_lbl[1] %} -
{{ worker_lbl[1] }}
- {% endif %} -
+ {# Bifa: glifa (✓/✗) + culoare + text — accesibil (nu doar culoare, design review) #} + {% macro bifa(ok, text, tip) %} + + {% if ok %} + + {{ text }} + {% else %} + + {{ text }} + {% endif %} + + {% endmacro %} - -
-
Legatura RAR
-
- {{ rar_lbl[0].split(':')[1].strip() if ':' in rar_lbl[0] else rar_lbl[0] }} -
- {% if rar_lbl[1] and rar_lbl[2] != 's-sent' %} -
{{ rar_lbl[1] }}
- {% endif %} -
+ {{ bifa(worker_ok, worker_lbl[0], worker_lbl[1]) }} + {{ bifa(rar_ok, rar_lbl[0], rar_lbl[1]) }} - -
-
{{ eticheta_ultima_auth }}
-
{{ last_login or '—' }}
-
- - -
-
In asteptare
-
{{ counts_queued }}
-
- - -
-
Declarate la RAR
-
{{ counts_sent }}
-
+ + {{ eticheta_ultima_auth }}: + {{ last_login }} + +
+ +
+ In asteptare: {{ counts_queued }} + Declarate la RAR: {{ counts_sent }} + Blocate: + {{ blocate_total }} +
{% if blocate_defalcat %}
-
Necesita atentia ta
+
Necesita atentia ta
{% for eticheta, n in blocate_defalcat %} {% if n > 0 %}
{{ eticheta[0] }} - ({{ n }}) + ({{ n }}) {% if eticheta[1] %} -
{{ eticheta[1] }}
+
{{ eticheta[1] }}
{% endif %}
{% endif %} diff --git a/app/web/templates/_submissions.html b/app/web/templates/_submissions.html index 4d94015..7983a2b 100644 --- a/app/web/templates/_submissions.html +++ b/app/web/templates/_submissions.html @@ -1,26 +1,55 @@ {% if rows %}
- + + + + + + + + + + {% for r in rows %} - - - + + + + + + - - - + {% endfor %}
#StareidPrezentareHTTP RARRetryActualizatMotiv
#StareVehiculOperatieData prestatieNr. prezentare RARActualizatMotiv
{{ r.id }}{{ r.status }}
{{ r.id }}{{ r.stare_text }} + {{ r.prez.vehicul_nr }} + {% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %} + {{ r.prez.vin_scurt }} + {% endif %} + {{ r.prez.operatie }}{{ r.prez.data_prestatie }} {{ r.id_prezentare or '—' }}{{ r.rar_status_code or '—' }}{{ r.retry_count }} {{ r.updated_at }}{{ (r.rar_error or '')[:80] }}{{ r.motiv }}
+{% elif filtru_activ %} +
+ Nimic pe filtrul curent. + + sterge filtrele + +
{% else %}
Nicio trimitere inca — - incepe cu Import + incepe cu un import sau trimite o prezentare prin API.
{% endif %} diff --git a/app/web/templates/_trimitere_detaliu.html b/app/web/templates/_trimitere_detaliu.html new file mode 100644 index 0000000..88dde96 --- /dev/null +++ b/app/web/templates/_trimitere_detaliu.html @@ -0,0 +1,98 @@ +
+
+

Detaliu trimitere #{{ id }}

+ {{ stare_text }} + +
+ + {% if stare_subtext %} +

{{ stare_subtext }}

+ {% endif %} + +
+
Numar inmatriculare
{{ prez.vehicul_nr }}
+
VIN (serie sasiu)
{{ prez.vin }}
+
Operatie
{{ prez.operatie }}
+
Cod RAR
{{ prez.cod }}
+
Data prestatie
{{ prez.data_prestatie }}
+
Odometru final
{{ prez.odometru }}
+
Nr. prezentare RAR
{{ id_prezentare or '—' }}
+
Cod HTTP RAR
{{ rar_status_code or '—' }}
+
Reincercari
{{ retry_count }}
+
Creat
{{ created_at }}
+
Actualizat
{{ updated_at }}
+
Urmatoarea incercare
{{ next_attempt_at }}
+
+ + {% if motiv %} +
+
Motiv
+
{{ motiv }}
+
+ {% endif %} + + {% if rar_error %} +
+ Mesaj tehnic RAR (integral) +
{{ rar_error }}
+
+ {% endif %} + + {# === Corectie inline (US-010): doar randuri ne-trimise blocate === #} + {% if editabil %} + {% set err_map = {} %} + {% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %} +
+

Corecteaza si re-trimite

+ + {% if corectie_msg %} +
{{ corectie_msg }}
+ {% endif %} + +
+ +
+ + {% macro camp(nume, eticheta, valoare, tip='text') %} +
+ + + {% if err_map.get(nume) %} +
{{ err_map.get(nume) }}
+ {% endif %} +
+ {% endmacro %} + + {{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }} + {{ camp('vin', 'VIN (serie sasiu)', form_vin) }} + {{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', form_data) }} + {{ camp('odometru_final', 'Odometru final', form_odo_final) }} + {{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }} +
+
+ +
+
+
+ {% endif %} +
+ + diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index 504ce0f..05fc951 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -22,24 +22,25 @@
{% set tabs = [ ("acasa", "Acasa", "tab-acasa"), - ("import", "Import", "tab-import"), - ("coada", "Coada", "tab-coada"), + ("coada", "Trimiteri", "tab-coada"), ("mapari", "Mapari", "tab-mapari"), ("cont", "Cont", "tab-cont"), ("nomenclator", "Nomenclator", "tab-nomenclator") ] %} {% for tab_id, tab_label, tab_elem_id in tabs %} + {% set badge = (badges.get(tab_id, 0) if badges else 0) %} {{ tab_label }} + hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}{% if badge %}{% endif %} {% endfor %}
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c514b00..95acb41 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,7 +48,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". -**Ultima actualizare**: 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). +**Ultima actualizare**: 2026-06-19 — 3.5 LIVRAT (dashboard compact: import pe prima pagina, status cu bife, Trimiteri lizibile, Mapari complete). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). + +> 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). > 3.3b LIVRAT (self-service cheie/creds + admin web + email). US-007 rute web proprii pentru rotire cheie + setare creds RAR scoped pe sesiune (C13, nu endpointul API). US-010 rol admin (`users.is_admin`) + `require_admin`→`AdminRequired`→403 + CLI `tools/account.py set-admin` + bootstrap automat (primul cont care se inregistreaza = admin, citit in `BEGIN IMMEDIATE` anti-race). US-011 panou `/admin` (conturi in asteptare/active, activare/dezactivare cu CSRF + PRG, contul dev id=1 protejat) + link "Panou admin" pe dashboard doar pentru admini + buton logout. US-012 `app/email.py notify_signup` best-effort DEGRADAT fara SMTP (no-op + log, prinde orice exceptie, nu blocheaza signup) + config `smtp_*`. Fix migrare defensiva `users.is_admin`/`email_verified` in `_migrate` (gap prins de VERIFY r1, ca C1 pe `accounts.active`). 2 runde VERIFY context curat (r2 PASS, sweep securitate toate rutele noi sub require_login/require_admin + CSRF, scoped sesiune). `/code-review` high: TOCTOU bootstrap mutat in tranzactie + `_render_admin` extras (anti-duplicare + N+1). 393 teste pass. Urmeaza Etapa 4 (4.1 mapare AI/MCP). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`. SMTP real = follow-up pe US-012. @@ -81,6 +83,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi | 3.3a | Self-onboarding web (core) | DONE | 2026-06-17 | `users` (scrypt) + sesiune (`SessionMiddleware`, same_site=strict) + CSRF (enforce prod, inclusiv login/signup) + rate-limit signup/login + signup/login/logout + dashboard & import scoped pe sesiune (NULL→1, anti-leak C6) + gate worker `active=0` (`COALESCE`). 2 runde VERIFY (leak `/_fragments/mapari` prins+reparat) + code-review (csrf erori, scrypt_params, login rate-limit). 361 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) | | 3.3b | Self-service cheie/creds + admin web + email | DONE | 2026-06-18 | US-007 (rute web proprii `/cont/roteste-cheie`+`/cont/rar-creds` scoped sesiune, C13), US-010 (rol admin `is_admin` + `require_admin`→403 + CLI `set-admin` + bootstrap primul cont=admin), US-011 (`/admin` activare/dezactivare cu CSRF+PRG, link doar pt admini + logout), US-012 (`app/email.py` notify best-effort degradat fara SMTP + log `SIGNUP`). Fix migrare defensiva `users.is_admin`/`email_verified`. 2 runde VERIFY context curat (r1 a prins migrarea lipsa, reparat; r2 PASS) + `/code-review` high (TOCTOU bootstrap admin mutat in tranzactie + extras `_render_admin` anti-duplicare/N+1). 393 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) | | 3.4 | Interfata web ergonomica (tab-uri + wizard + microcopy uman) | DONE | 2026-06-18 | Dashboard reorganizat in 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=` + panou activ server-side + lazy pe rest; bara status cu etichete umane (`app/web/labels.py`) + defalcare blocate; import ca stepper 4 pasi (PUR vizual); Acasa onboarding auto-bifat + empty states. Backend trimitere neatins. 434 teste. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md) | +| 3.5 | Dashboard compact: import pe prima pagina, status cu bife, Trimiteri lizibile, Mapari complete | DONE | 2026-06-19 | 11 stories (4 valuri), 3 review-uri de plan facute. Acasa=ecran de import (scoate tab Import); bara status compacta font normal + bife accesibile (auto-send/RAR) + data `dd.mm.yyyy hh24:mi:ss`; "Coada"→"Trimiteri" cu coloane RO + detalii comanda din `payload_json` (helper partajat `payload_view.py`) + detaliu la click in panou dedicat; filtrare Trimiteri (US-009); corectie inline `needs_data` cu re-enqueue + detectie coliziune idempotency (US-010); badge contoare pe tab-uri (US-011); "Mapari" 3 sectiuni (de rezolvat / op salvate cu re-rezolvare auto / formate coloane), "Cont"=doar cheie+creds; feedback `needs_data` la import. Backend trimitere neatins. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md) | ### Etapa 4 — Viitor (Treapta 3) diff --git a/docs/prd/prd-3.5-dashboard-compact-trimiteri-mapari.md b/docs/prd/prd-3.5-dashboard-compact-trimiteri-mapari.md new file mode 100644 index 0000000..40d7859 --- /dev/null +++ b/docs/prd/prd-3.5-dashboard-compact-trimiteri-mapari.md @@ -0,0 +1,495 @@ +# PRD 3.5 — Dashboard compact: import pe prima pagina, status cu bife, Trimiteri lizibile, Mapari complete + +**Stare**: inchis + +> Proces complet: `docs/ROADMAP.md` §5. Contractul RAR (sursa de adevar): `docs/api-rar-contract.md`. +> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead). +> Aceasta e o livrabila de **UI/UX** — continua 3.4 (`prd-3.4-ux-dashboard-web.md`). Atinge stratul web +> (Jinja2 + HTMX, zero build) si rute de prezentare/listare. **Nu** modifica `worker/`, `mapping.py` +> (logica de rezolvare), `idempotency.py`, masina de stari submissions sau contractul RAR. + +## 1. Obiectiv + +Feedback de utilizare pe interfata 3.4: e mai buna, dar (a) importul — operatia principala — e ascuns +intr-un tab, (b) bara de status are fonturi mici si etichete fara bife, (c) pagina "Coada" e neintuitiva +(coloane tehnice "HTTP RAR", stare in engleza "sent", "Motiv" gol, si **nu se vede la ce comanda se +refera** — doar `idPrezentare`), (d) pagina "Mapari" arata doar operatiile nerezolvate — maparile deja +salvate par pierdute, iar maparile de coloane nu se vad nicaieri. + +Livrabila rezolva aceste patru zone, fara a schimba comportamentul backend de trimitere: + +1. **Acasa = ecranul de import.** Prima pagina arata direct caseta de upload (importul e operatia + principala), sub o bara de status compacta; ghidul "primii pasi" si link-urile de ajutor coboara pe + un singur rand discret. Tab-ul "Import" separat dispare (era acelasi flux). +2. **Bara de status compacta, font normal, cu bife.** Doua randuri: sus doua bife (verde/rosu) pentru + "Trimitere automata" si "Legatura RAR" + "Ultima autentificare RAR" in format `dd.mm.yyyy hh24:mi:ss`; + jos contoarele (in asteptare / declarate / blocate). +3. **"Coada" → "Trimiteri", lizibila.** Coloane umane (Stare in romana via `labels.py`, Vehicul, + Operatie, Data prestatie, Nr. prezentare RAR, Actualizat, Motiv uman). Click pe rand → detaliu complet + (toate campurile, inclusiv codul HTTP tehnic si motivul integral). Detaliile comenzii se citesc din + `payload_json` (text JSON simplu, nu criptat). +4. **"Mapari" complet — trei sectiuni.** (1) De rezolvat (`needs_mapping`, ca acum), (2) Mapari operatii + salvate — `operations_mapping` editabil (schimba cod RAR / auto-send / sterge), (3) Formate de coloane + salvate — `column_mappings` per semnatura (vezi coloanele, format data, editeaza/sterge). "Cont" ramane + doar cheie API + creds RAR. + +Decizii de layout confirmate cu utilizatorul (AskUserQuestion, cu preview): Acasa=Import direct; status pe +doua randuri cu bife; Trimiteri cu detalii in tabel + expand; toate maparile intr-un singur loc ("Mapari"). + +## 2. Non-Goals (anti scope-creep) + +- **Fara schimbari de backend de trimitere**: worker, mapare op→cod (rezolvarea), idempotenta, + reconciliere, masina de stari submissions raman neatinse. Doar prezentare + listare/editare web. +- **Fara endpoint-uri API noi `/v1/*`** si fara schimbari de schema SQL **de structura**. Tabelele + `operations_mapping` / `column_mappings` exista deja; doar le expunem/edita prin rute web. Exceptie + controlata (acceptata la CEO review): US-009 poate adauga **un index** pe `submissions` pentru filtrare + (nu coloane noi, nu tabele noi). +- **Fara framework JS / build step**: ramane Jinja2 + HTMX + CSS in `base.html`. Eventualul JS e vanilla + inline, minim. Fara React/Vue/Tailwind/bundler. +- **Fara rescriere a fluxului de import** (parsare, mapare coloane, preview, commit raman ca logica) — + doar muta upload-ul pe Acasa si imbraca rezultatul. +- **Fara redesign login/signup/admin** dincolo de aplicarea acelorasi clase/etichete daca e trivial. +- **Fara i18n / tema light**: texte in romana hardcodate, paleta dark din `base.html`. +- **Fara modificari de worker / masina de stari / reconciliere**: US-010 (corectie inline) doar + re-valideaza (`validation.py`) si re-pune randul in `queued` (re-enqueue), fara sa atinga worker-ul. +- **Editarea de continut e permisa DOAR pentru randuri ne-trimise blocate** (`needs_data`/`needs_mapping`), + prin US-010. Randurile `sent`/`FINALIZATA` raman **read-only** (terminal la RAR, fara anulare/corectie). + +## 3. Stories atomice + +> Fiecare story: cea mai mica unitate care lasa sistemul functional. Backend + UI pentru acelasi +> comportament = 2 stories. Toate atingerile sunt in `app/web/` (templates + routes + `labels.py`). +> Verificare E2E = browser HTMX pe `http://localhost:8000/` (Playwright MCP sau `/browse`). +> **Regula de aur**: fluxul import → commit → worker → FINALIZATA la RAR test NU are voie sa se strice, +> iar deep-link-urile `?tab=` raman valide. + +### US-001: Bara de status compacta cu bife + data formatata (backend format + UI) +**Ca** operator de service **vreau** o bara de status compacta, cu font normal, cu bife clare si data +ultimei autentificari completa **pentru ca** sa vad starea sistemului dintr-o privire, fara sa ghicesc. + +- **Depinde de**: — +- **Fisiere**: `app/web/labels.py` (helper format data), `app/web/routes.py` (`fragment_status`), + `app/web/templates/_status.html`, `tests/test_web_labels.py`, `tests/test_web_status.py` (~5 fisiere) +- **Test intai (RED)**: `test_format_data_rar` — `2026-06-18T14:30:22` (sau forma stocata in + `worker_heartbeat.last_rar_login_ok`) → `"18.06.2026 14:30:22"`; valoare lipsa → `"—"`; format invalid + → fallback grijuliu (nu arunca). `tests/test_web_status.py::test_status_are_bife` — fragmentul randat + contine bifa verde cand worker viu + RAR ok, rosie cand oprit/indisponibil. +- **Continut**: + - Helper pur in `labels.py` (ex. `format_data_rar(raw) -> str`) care produce `dd.mm.yyyy hh24:mi:ss`. + - `_status.html` rescris pe **doua randuri**: rand 1 = `[bifa] Trimitere automata activa` + + `[bifa] Legatura RAR OK` + `Ultima autentificare RAR: `; rand 2 = `In asteptare: N | + Declarate la RAR: N | Blocate: N`. Font normal (13-14px), fara `font-size:11px/12px`. + - **Accesibilitate (design review)**: starea NU se distinge doar prin culoare. Glifa difera — + `✓` (✓) pentru activ/OK, `✗` (✗) pentru oprit/indisponibil — plus textul difera + (activa/oprita, functionala/indisponibila). Culoarea (verde/rosu) e redundanta, nu singurul semnal. + - Pastreaza avertismentul "cont in asteptare de activare" (regresia reparata in 3.4) si poll-ul 15s. +- **Acceptance criteria**: + - [x] Data ultimei autentificari apare ca `dd.mm.yyyy hh24:mi:ss` (test pe helper, pur). + - [x] Doua stari binare au bifa verde/rosie dupa starea reala (worker viu/mort, RAR ok/indisponibil). + - [x] Niciun text din bara nu mai foloseste `font-size` sub 13px. + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: browser pe `/` — bara compacta, bife corecte, data formatata romaneste. + +### US-002: Acasa devine ecranul de import (upload inline + help compact, scoatere tab Import) +**Ca** operator **vreau** sa import direct de pe prima pagina **pentru ca** importul e ce fac cel mai des. + +- **Depinde de**: US-001 (bara status finala deasupra) +- **Fisiere**: `app/web/templates/dashboard.html` (lista tab-uri), `app/web/routes.py` (`_TABS_VALIDE`, + `_render_panel`, `fragment_import`/`fragment_acasa`), `app/web/templates/_acasa.html`, + `app/web/templates/_upload.html` (reutilizat), `tests/test_web_dashboard.py` (~5 fisiere) +- **Test intai (RED)**: `test_acasa_contine_upload` — fragmentul `/_fragments/acasa` contine formularul de + upload (`hx-post` catre ruta de import existenta); `test_tab_import_redirect` — `?tab=import` nu mai e + tab separat (redirect la `acasa` sau eticheta absenta din tab-bar), iar deep-link-ul nu da 404. +- **Continut**: + - `_acasa.html`: caseta de upload (din `_upload.html`) ca prim element; sub ea, "primii pasi" pe un + **singur rand** compact (`o Cont RAR o Cheie API * Import`) + un rand de ajutor cu link-uri mici + (Ghid / Coada / Mapari). Upload-ul porneste stepper-ul existent (target `#import-section`). + - **Ierarhie (design review)**: upload-ul e vizual DOMINANT (titlu clar + caseta mare); checklist-ul + "primii pasi" si ajutorul sunt subordonate (font mai mic, sub upload), nu concureaza cu el. Prima + pagina are un singur centru de greutate: importa un fisier. + - Scoate `("import", ...)` din lista de tab-uri din `dashboard.html`; pastreaza `?tab=import` valid + (redirect la `acasa`) pentru orice URL salvat. Stepper-ul, mapare-coloane, preview, commit raman. + - "Incarca alt fisier" din stepper trimite inapoi la Acasa (nu la un tab inexistent). +- **Acceptance criteria**: + - [x] Pe `/` (tab implicit Acasa) caseta de upload e vizibila fara click suplimentar. + - [x] Tab-bar-ul nu mai are "Import"; `?tab=import` nu da 404 (redirect/echivalent la Acasa). + - [x] Fluxul upload → mapare coloane → preview → commit functioneaza neschimbat (target/csrf intacte). + - [x] Link-urile de ajutor + checklist incap pe randuri compacte, font normal. + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: browser — incarc un fisier de pe Acasa, parcurg stepper-ul pana la commit. + +### US-003: Prezentare detalii comanda din payload (backend pur, testat) +**Ca** operator **vreau** ca fiecare trimitere sa-mi spuna despre ce vehicul/operatie e vorba +**pentru ca** acum vad doar `idPrezentare` si nu stiu ce comanda am trimis si ce nu. + +- **Depinde de**: — +- **Fisiere**: `app/payload_view.py` (nou, helper PARTAJAT web+API), `app/api/v1/router.py` (refactor + mic: `GET /v1/prezentari` foloseste acelasi helper, nu o copie), `tests/test_payload_view.py` + (~3 fisiere). Decizie eng review (DRY): un singur modul de extragere payload→campuri afisabile, ca sa + nu diverge intre canalul web si cel API (router.py:247-264 face azi extragerea sa proprie). +- **Test intai (RED)**: `test_detalii_din_payload` — dat un `payload_json` (text JSON: `vin`, `numar`/ + `numarInmatriculare`, `odometru_final`, `data_prestatie`, `cod_prestatie`/`cod_op_service`/`denumire`), + helperul intoarce un dict de prezentare `{vehicul_nr, vin_scurt, operatie, data_prestatie, odometru, + cod}`; `test_payload_partial` — campuri lipsa → `"—"`/gol fara exceptie; `test_payload_invalid` → + fallback grijuliu (nu arunca); `test_payload_coercion_excel` — odometru `"123.0"`/numeric si VIN + non-string (coercion Excel) afisate curat (`str()` defensiv), chei API vs import (`numar` vs + `numarInmatriculare`) ambele rezolvate. +- **Continut**: functie pura care primeste randul submission (sau `payload_json`) si produce campurile + de afisat. `vin_scurt` = forma trunchiata pentru tabel (VIN integral ramane in detaliu). Citeste cheile + defensiv (canalele API si import pot diferi usor; `payload_json` e plaintext — vezi `router.py`). +- **Acceptance criteria**: + - [x] Helper pur (fara DB, fara request), 100% acoperit de teste pe cazurile plin/partial/invalid. + - [x] Nu arunca niciodata pe payload malformat (degradeaza la `—`). + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: — (acoperit de US-004). + +### US-004: "Coada" → "Trimiteri" — tabel lizibil + detaliu la click +**Ca** operator **vreau** un tabel de trimiteri pe care il inteleg, cu detaliile comenzii +**pentru ca** "HTTP RAR"/"sent"/"Motiv gol" nu-mi spun nimic si nu stiu la ce comanda se refera randul. + +- **Depinde de**: US-003 (prezentare payload), US-001 (`labels.py` pt stare umana) +- **Fisiere**: `app/web/templates/_coada.html` (titlu + tab label), `app/web/templates/_submissions.html`, + `app/web/templates/dashboard.html` (eticheta tab "Coada"→"Trimiteri", `tab_id` ramane `coada`), + `app/web/routes.py` (`fragment_submissions` imbogatit + ruta detaliu `/_fragments/trimitere/{id}`), + `app/web/templates/_trimitere_detaliu.html` (nou), `tests/test_web_submissions.py` (~6 fisiere) +- **Test intai (RED)**: `test_submissions_coloane_umane` — tabelul contine antete RO (Stare, Vehicul, + Operatie, Data prestatie, Nr. prezentare RAR, Motiv) si **nu** mai contine "HTTP RAR" ca antet principal, + nici status brut englezesc afisat ca atare (folose `labels.eticheta_stare`); `test_detaliu_trimitere` — + `/_fragments/trimitere/{id}` intoarce detaliul complet scoped pe cont (404 cross-account). +- **Continut**: + - Eticheta tab "Coada" → **"Trimiteri"** (pastreaza `tab_id="coada"` ca deep-link `?tab=coada` sa ramana + valid). Titlu sectiune "Trimiteri catre RAR". + - `_submissions.html`: coloane = `#`, **Stare** (`eticheta_stare` text RO + pill culoare), **Vehicul** + (nr + VIN scurt, din US-003), **Operatie**, **Data prestatie**, **Nr. prezentare RAR** (`id_prezentare` + sau `—`), **Actualizat**, **Motiv** (text uman; pt `needs_data` → ex. "lipsa odometru"). Codul HTTP + tehnic NU mai e coloana principala — coboara in detaliu. + `query`-ul include `payload_json` (pt US-003) pe langa campurile actuale. + - Click pe rand → `hx-get="/_fragments/trimitere/{id}"` → `_trimitere_detaliu.html` cu toate campurile + (vehicul integral, operatie+cod, odometru, data, stare, `rar_status_code`, `rar_error` integral, + retry, timestamps). Scoped pe contul sesiunii. + - **Detaliul se randeaza intr-un PANOU DEDICAT** (ex. `#trimitere-detaliu` sub/langa tabel), NU inline + in randul din tabel. Motiv (CEO review, Finding #1): `_submissions.html` are `hx-trigger="every 10s"`; + un expand inline ar fi sters de poll-ul de refresh. Panoul dedicat nu e prins de poll. + - **Vizibilitate (design review)**: la deschidere, panoul trebuie sa fie evident — scroll-to panou + si/sau evidentiere a randului selectat in tabel. Altfel pare ca "nu s-a intamplat nimic" (panoul + apare sub fold). +- **Acceptance criteria**: + - [x] Antetele coloanelor sunt in romana; starea afisata e text uman (nu "sent"). + - [x] Fiecare rand arata vehicul + operatie + data (din payload), nu doar `idPrezentare`. + - [x] Click pe rand deschide detaliul complet, scoped pe cont (404 la id-ul altui cont). + - [x] Motivul pentru `needs_data`/`error` apare in coloana Motiv (nu gol cand exista `rar_error`). + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: browser — tab Trimiteri, identific un rand dupa vehicul, deschid detaliul. + +### US-005: Listare + editare mapari operatii salvate (backend) +**Ca** operator **vreau** sa vad si sa pot schimba maparile operatie→cod pe care le-am salvat +**pentru ca** acum, dupa ce mapez si trimit, ele dispar din ecran si par pierdute. + +- **Depinde de**: — +- **Fisiere**: `app/web/routes.py` (query `operations_mapping` + rute edit/delete), + `tests/test_web_mapari_salvate.py` (~2 fisiere) +- **Test intai (RED)**: `test_lista_mapari_salvate` — intoarce randurile `operations_mapping` ale contului + cu `nume_prestatie` jonctionat din nomenclator; `test_editeaza_mapare_salvata` — POST schimba + `cod_prestatie`/`auto_send` doar pe contul propriu (cross-account interzis), verifica `cod_prestatie` + exista in nomenclator (ca la `/mapari` actual); `test_sterge_mapare_salvata` — DELETE scoped pe cont. +- **Continut**: functie de listare scoped pe cont + rute web `POST /mapari/salvate` (update) si + `POST /mapari/salvate/sterge` (delete) cu CSRF + PRG/HTMX swap. **Nu** schimba logica de rezolvare din + `mapping.py`; doar CRUD pe tabela existenta. **Re-rezolvare obligatorie** (promovata din optional la CEO + review): la schimbarea unui cod, submission-urile blocate (`needs_mapping`) pe acel `cod_op_service` se + re-rezolva automat, reutilizand helperul de re-rezolvare existent (`reresolve_account`/echivalent) — + fara cod nou de rezolvare. Inchide pain-ul "am mapat dar nu vad efectul". +- **Acceptance criteria**: + - [x] Listarea e scoped pe contul sesiunii (fara leak cross-account — vezi C6 din 3.3a). + - [x] Editarea respinge cod inexistent in nomenclator si cont strain. + - [x] Stergerea afecteaza doar contul propriu. + - [x] La editarea unui cod, submission-urile `needs_mapping` pe acel `cod_op_service` se deblocheaza + automat (test: rand blocat → editez maparea → randul trece din `needs_mapping`). + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: — (acoperit de US-006). + +### US-006: Listare + editare/stergere formate de coloane salvate (backend) +**Ca** operator **vreau** sa vad formatele de import memorate si sa le pot edita/sterge +**pentru ca** nu stiu ce coloane sunt retinute si ce se intampla cand vin cu un fisier cu alte coloane. + +- **Depinde de**: — +- **Fisiere**: `app/web/routes.py` (query `column_mappings` + rute edit/delete), + `tests/test_web_formate_coloane.py` (~2 fisiere) +- **Test intai (RED)**: `test_lista_formate_coloane` — intoarce randurile `column_mappings` ale contului + cu coloanele (din `signature_coloane`/`json_mapare`), `format_data` si un contor de utilizare (cate + `import_batches`/submissions folosesc acea semnatura — best-effort, sau omis daca nu e ieftin); + `test_sterge_format_coloane` — DELETE scoped pe cont; `test_editeaza_format_coloane` — POST schimba + `json_mapare`/`format_data` pentru o semnatura, scoped pe cont. +- **Continut**: listare scoped pe cont + rute `POST /formate-coloane/...` (edit/delete) cu CSRF. + Comportament documentat (nu cod nou de import): un fisier cu **alte coloane** = semnatura noua = format + nou separat (`UNIQUE (account_id, signature_coloane)`), nu suprascrie; **acelasi antet** = maparea + retinuta se reaplica automat la urmatorul import (comportament existent din 2.4). +- **Acceptance criteria**: + - [x] Listarea arata coloanele fiecarui format + format data, scoped pe cont. + - [x] Stergerea/editarea afecteaza doar contul propriu (fara leak cross-account). + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: — (acoperit de US-006 UI in US-007). + +### US-007: Pagina "Mapari" cu trei sectiuni (UI) +**Ca** operator **vreau** o singura pagina "Mapari" cu tot ce tine de mapari +**pentru ca** sa rezolv ce e blocat si sa-mi gestionez maparile salvate (operatii + coloane) intr-un loc. + +- **Depinde de**: US-005, US-006 +- **Fisiere**: `app/web/templates/_mapari.html`, `app/web/routes.py` (`_render_mapari`/`fragment_mapari` + imbogatit sa treaca cele 3 seturi de date), `app/web/templates/_cont.html` (ramane doar cheie API + + creds RAR — fara mapari), `tests/test_web_mapari_ui.py` (~4 fisiere) +- **Test intai (RED)**: `test_mapari_trei_sectiuni` — fragmentul `/_fragments/mapari` contine cele trei + sectiuni (De rezolvat / Mapari operatii salvate / Formate de coloane salvate); `test_cont_fara_mapari` + — `/_fragments/cont` nu mai contine sectiuni de mapari. +- **Continut**: + - `_mapari.html` reorganizat pe 3 sectiuni: (1) **De rezolvat** = `pending_unmapped` (ca acum), (2) + **Mapari operatii salvate** = lista din US-005, fiecare cu select cod RAR + checkbox auto-send + buton + sterge (HTMX swap pe `#mapari-section`), (3) **Formate de coloane salvate** = lista din US-006, fiecare + cu coloanele afisate + format data + actiuni edit/sterge. Empty states prietenoase per sectiune. + - `_cont.html`: confirma ca nu contine mapari (cheie API + creds RAR doar). +- **Acceptance criteria**: + - [x] "Mapari" arata cele 3 sectiuni; sectiunile goale au mesaj prietenos, nu lipsesc tacit. + - [x] Salvarea/stergerea reincarca sectiunea via HTMX fara reload de pagina. + - [x] "Cont" nu mai contine mapari. + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: browser — vad maparile salvate dupa ce am mapat+trimis; editez un cod; vad formatul + de coloane al fisierului importat. + +### US-008: Feedback clar pentru randuri respinse la import (lipsa odometru / needs_data) +**Ca** operator **vreau** sa inteleg de ce un rand nu s-a importat **pentru ca** am incarcat un fisier +fara odometru si pur si simplu "nu s-a importat", fara explicatie. + +- **Depinde de**: US-002 (importul pe Acasa) +- **Fisiere**: `app/web/templates/_preview_import.html`, `app/web/routes.py` (mesaj preview, daca e cazul), + `tests/test_web_preview_motive.py` (~3 fisiere) +- **Test intai (RED)**: `test_preview_arata_motiv_needs_data` — un rand `needs_data` din lipsa odometru + apare in preview cu motiv explicit ("lipsa odometru" / mesajul de validare), nu doar numarat la "blocate". +- **Continut**: preview-ul de import (cele 6 stari deja existente din 2.5) afiseaza pentru randurile + `needs_data`/`needs_review` **motivul** (din validare/`error`), ca operatorul sa stie ce sa corecteze. + Reutilizeaza mesajele de validare existente (`validation.py`); fara reguli noi de validare. +- **Acceptance criteria**: + - [x] Un rand fara odometru apare explicit cu motivul, nu doar in contorul "blocate". + - [x] Contoarele preview (ok/needs_data/...) raman corecte. + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: browser — import un fisier fara odometru, vad in preview de ce e blocat randul. + +### US-009: Filtrare/cautare in "Trimiteri" (stare / vehicul / data prestatie) +**Ca** operator cu sute de trimiteri **vreau** sa filtrez lista **pentru ca** sa gasesc rapid o comanda +sau toate cele blocate, fara sa derulez tot. + +- **Depinde de**: US-003, US-004 +- **Fisiere**: `app/web/routes.py` (`fragment_submissions` accepta parametri de filtru), + `app/web/templates/_coada.html` (controale filtru), `app/web/templates/_submissions.html`, + `app/schema.sql` (index pe `submissions(account_id, status)` daca lipseste — exista deja + `idx_submissions_account_status`, deci probabil zero schimbari), `tests/test_web_filtrare.py` (~4 fisiere) +- **Test intai (RED)**: `test_filtru_stare` — `?status=needs_data` intoarce doar acele randuri (scoped pe + cont); `test_filtru_vehicul` — cautare text pe nr/VIN (case-insensitive); `test_filtru_data` — interval + `data_prestatie`. Toate scoped pe cont, fara leak. +- **Continut**: controale HTMX (select stare + input text vehicul + interval data) care reincarca + `/_fragments/submissions` cu query string; filtrarea pe vehicul/data se face dupa parsarea + `payload_json` (text JSON), filtrarea pe stare in SQL (foloseste indexul existent). Pastreaza poll-ul + (poll-ul re-trimite filtrul curent). Empty state "nimic pe filtrul curent" **+ buton „sterge filtrele"** + (design review) cand exista un filtru activ. +- **Acceptance criteria**: + - [x] Filtrele combina (stare + vehicul + data) si raman aplicate la refresh-ul de 10s. + - [x] Filtrarea e scoped pe cont (fara leak cross-account). + - [x] Nu necesita coloane/tabele noi (cel mult confirma indexul existent). + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: browser — filtrez dupa "blocate" si dupa un numar de inmatriculare. + +### US-010: Corectie inline pentru randuri ne-trimise blocate (needs_data) +**Ca** operator **vreau** sa completez un camp lipsa (ex. odometru) direct pe randul blocat si sa-l +re-trimit **pentru ca** acum trebuie sa refac tot fisierul de import doar pentru un camp. + +- **Depinde de**: US-003, US-004 +- **Fisiere**: `app/web/routes.py` (ruta `POST /trimitere/{id}/corecteaza`), + `app/web/templates/_trimitere_detaliu.html` (form de corectie pe randurile `needs_data`/`needs_mapping`), + `tests/test_web_corectie.py` (~3 fisiere) +- **Test intai (RED)**: `test_corectie_needs_data` — un rand `needs_data` din lipsa odometru: completez + odometru → re-validare (`validation.py`) → status `queued`, payload actualizat, idempotency recalculata; + `test_corectie_sent_interzis` — un rand `sent`/`FINALIZATA` NU poate fi editat (403/refuz, read-only); + `test_corectie_coliziune_idempotency` — daca noua cheie coincide cu alt submission existent, corectia se + opreste cu mesaj „exista deja o trimitere identica (rand #N)", fara IntegrityError/500 si fara duplicat; + `test_corectie_cont_strain` — interzis cross-account. +- **Continut**: pe panoul de detaliu (US-004), pentru randuri `needs_data`/`needs_mapping`, un mini-form + cu campurile relevante; la submit re-valideaza prin `validation.py` (fara reguli noi), reconstruieste + `payload_json` + recalculeaza `idempotency_key` (canonicalize → build_key, ca la enqueue), seteaza + status `queued` si `next_attempt_at=now`. **Nu** atinge worker-ul / masina de stari (doar re-enqueue). + Randurile `sent`/`FINALIZATA` raman read-only (gard explicit). + - **Coliziune idempotency** (decizie eng review): INAINTE de UPDATE, verifica daca noua `idempotency_key` + exista deja pe alt submission al contului; daca da, opreste corectia si afiseaza „exista deja o + trimitere identica (rand #N)" cu link la acel rand. Fara 500, fara duplicat. (UNIQUE pe + `idempotency_key` ar arunca IntegrityError altfel.) + - **Loc + eroare (design review)**: formul de corectie traieste IN panoul de detaliu (US-004), nu intr-o + sectiune separata. Eroarea de validare se afiseaza clar pe campul invalid (nu un mesaj generic sus). +- **Acceptance criteria**: + - [x] Un rand `needs_data` corectat valid trece in `queued` cu payload + idempotency actualizate. + - [x] Randurile `sent`/`FINALIZATA` nu pot fi editate (gard testat). + - [x] Coliziunea de idempotency e prinsa si comunicata clar (rand-duplicat identificat), fara 500. + - [x] Corectia respinge date inca invalide (mesaj de validare) si conturi straine. + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: browser — corectez odometru pe un rand blocat, il vad trecand in asteptare; + cu worker `--send` pe RAR test, ajunge FINALIZATA. + +### US-011: Badge cu contoare pe tab-uri (atentionari) +**Ca** operator **vreau** sa vad pe eticheta tab-ului cate lucruri ma asteapta **pentru ca** sa stiu +unde sa intervin fara sa deschid fiecare tab. + +- **Depinde de**: US-007 (mapari), US-004 (trimiteri) +- **Fisiere**: `app/web/templates/dashboard.html` (badge pe eticheta tab), `app/web/routes.py` + (contoarele deja calculate in `fragment_status`/panel context, transmise la tab-bar), + `tests/test_web_badge.py` (~3 fisiere) +- **Test intai (RED)**: `test_badge_mapari` — cand exista operatii `needs_mapping`, eticheta "Mapari" + poarta un numar; `test_badge_trimiteri_blocate` — cand exista randuri blocate, "Trimiteri" poarta + marcaj; `test_badge_zero_ascuns` — fara nimic de rezolvat, niciun badge. +- **Continut**: numar mic pe eticheta tab-ului, alimentat din contoarele existente (needs_mapping pt + Mapari, blocate pt Trimiteri). Pur prezentare; reutilizeaza ce calculeaza deja status-ul. Accesibil + (text in `aria-label`, nu doar culoare). +- **Acceptance criteria**: + - [x] Badge apare doar cand contorul > 0; dispare la zero. + - [x] Numarul e corect si scoped pe cont. + - [x] `aria-label`-ul tab-ului include sensul badge-ului (nu doar pastila colorata). + - [x] `python3 -m pytest -q` trece. +- **Verificare E2E**: browser — cu o operatie nemapata, "Mapari" arata "(1)"; dupa rezolvare, dispare. + +## 4. Riscuri + +- **Scoaterea tab-ului "Import" rupe deep-link-uri/teste** (`?tab=import`, link-uri din `_acasa.html`, + "Incarca alt fisier" din stepper). Mitigare: `?tab=import` → redirect la `acasa`; grep dupa toate + referintele `tab=import`/`/_fragments/import` inainte de stergere; test dedicat (US-002). +- **Citirea `payload_json` pentru detalii** depinde de forma payload-ului, care difera usor intre canalul + API si import. Mitigare: helper pur defensiv cu fallback `—` (US-003), testat pe ambele forme; nu se + bazeaza pe o cheie obligatorie. +- **Leak cross-account** pe noile listari/editari (mapari salvate, formate coloane, detaliu trimitere). + Mitigare: toate scoped pe contul sesiunii cu regula NULL→1 (C6/3.3a), test cross-account per ruta noua, + re-folosind pattern-ul `account_scope_clause` (3.2). +- **Afisare PII (VIN/nr) pe ecran** in Trimiteri. Acceptabil: e proprietatea contului autentificat, scoped + pe sesiune; nu se expune in loguri si nu apare in raspunsuri 422 (handler existent in `main.py`). +- **Aglomerare "Mapari" cu 3 sectiuni**. Mitigare: empty states + colaps vizual cand o sectiune e goala. + +## 5. Intrebari deschise + +> Se rezolva cu utilizatorul ÎNAINTE de executie (poarta de aprobare PRD). + +- ~~Detaliul trimiterii: expand inline sau panou?~~ **REZOLVAT (CEO review):** panou dedicat + `#trimitere-detaliu`, nu inline — altfel poll-ul de 10s sterge expand-ul (vezi US-004). +- "Editare" format de coloane: redeschidem editorul de mapare campuri (`_mapcoloane.html`) prefiltrat pe + semnatura salvata, sau permitem doar stergere + re-import? (propunere MVP: stergere + vizualizare; edit + de campuri = nice-to-have daca incape in story). +- Contor de utilizare pe formate de coloane: il afisam (cost de query) sau il omitem in v1? (propunere: + omitem daca nu e ieftin — nu e critic). + +## 6. Valuri de executie (graful de dependente) + +``` +Val 1: [US-001] [US-003] [US-005] [US-006] ← fara dependente, fisiere distincte → paralel (max 3-4) +Val 2: [US-002] [US-004] ← US-002←US-001 ; US-004←US-003+US-001 +Val 3: [US-007] [US-008] [US-009] [US-010] ← US-007←US-005+US-006 ; US-008←US-002 ; + US-009←US-003+US-004 ; US-010←US-003+US-004 +Val 4: [US-011] ← US-011←US-007+US-004 (contoare din ambele) +``` + +> Atentie la fisiere partajate intre valuri: `routes.py`, `dashboard.html`, `_status.html`, +> `_submissions.html` si `_trimitere_detaliu.html` (US-004/009/010) sunt atinse de mai multe stories — +> secventiaza-le sau worktree + merge de catre lead (vezi anti-pattern ROADMAP). In special US-009 si +> US-010 ating ambele acelasi panou de detaliu/tabel — ruleaza-le secvential, nu in paralel. + +## 7. Review-uri de plan + +### CEO review (2026-06-19) — SELECTIVE EXPANSION + +- **Abordare aleasa**: A (cele 8 stories complete), cu expansiuni cherry-pick acceptate. +- **Expansiuni acceptate** (adaugate ca stories): US-009 filtrare/cautare Trimiteri, US-010 corectie + inline pentru `needs_data`, US-011 badge contoare pe tab-uri. +- **Finding promovat**: re-rezolvarea automata la editarea unei mapari salvate, din "optional" in scope + obligatoriu (US-005). +- **Finding de robustete inchis**: detaliul Trimiteri merge in **panou dedicat**, nu inline — altfel + poll-ul de 10s din `_submissions.html` ar sterge expand-ul (US-004 + §5). +- **Schimbare de scope fata de draft**: editarea de continut e acum permisa pentru randuri ne-trimise + blocate (US-010); `sent`/`FINALIZATA` raman read-only. Vezi §2 non-goals actualizat. + +### Eng review (2026-06-19) + +- **Step 0**: 11 stories e mult, dar e o livrabila UI sparta in stories atomice TDD (conventia + proiectului), nu overbuild. Risc real = contentia pe fisiere partajate, nu numarul. Scope confirmat. +- **Decizie idempotency (US-010)**: corectia detecteaza coliziunea de `idempotency_key` INAINTE de UPDATE + si o comunica clar (rand-duplicat identificat), fara 500/duplicat. +- **Decizie DRY (US-003)**: helper partajat `app/payload_view.py` folosit si de web si de + `GET /v1/prezentari` — o singura sursa de extragere payload→campuri. +- **Aserțiune adaugata**: test ca `submissions.payload_json` e plaintext (US-003) — daca vreodata se + cripteaza, testul cade si stim sa adaptam. +- **Plafon perf notat (US-009)**: filtrarea pe vehicul/data parseaza payload in Python per rand; OK la + scara actuala, `json_extract()` daca devine necesar. Nu blocheaza. +- **Secventiere intarita**: US-004 (schelet tabel+panou) → apoi US-009 si US-010, strict secvential pe + `_submissions.html`/`_trimitere_detaliu.html`/`fragment_submissions`. NU paraleliza valul 3 pe ele. + +### Design review (2026-06-19) — rating 7/10 → 9/10 dupa fixuri + +Layout-urile au fost alese vizual cu utilizatorul (mockup-uri ASCII in AskUserQuestion). Patru cerinte +de design adaugate ca AC: + +- **Accesibilitate bife (US-001)**: glife distincte (✓/✗) + text, nu doar culoare (daltonism). +- **Ierarhie Acasa (US-002)**: upload-ul vizual dominant; checklist + ajutor subordonate. +- **Vizibilitate panou (US-004)**: scroll-to / evidentiere rand la deschiderea detaliului. +- **Stari de eroare (US-009/010)**: „sterge filtrele" + empty state; eroare de validare pe campul invalid, + in panoul de detaliu (decizie utilizator). + +## 8. Raport review-uri de plan (consolidat) + +| Review | Data | Rezultat | Decizii cheie | +|---|---|---|---| +| CEO (SELECTIVE EXPANSION) | 2026-06-19 | Aprobat cu expansiuni | Abordare A; +US-009/010/011; re-rezolvare US-005 obligatorie; detaliu in panou (nu inline) | +| Eng | 2026-06-19 | Aprobat | Coliziune idempotency US-010 detectata pre-UPDATE; helper partajat `payload_view.py`; secventiere val 3 | +| Design | 2026-06-19 | Aprobat (9/10) | Bife accesibile; ierarhie Acasa; vizibilitate panou; stari de eroare | + +**Scope final**: 11 stories (US-001…US-011), in 4 valuri. Backend de trimitere (worker, masina de stari, +reconciliere, idempotenta ca logica) neatins; singura mutatie de date noua = corectia US-010 (re-enqueue +randuri ne-trimise) + posibil un index (US-009). **DECIZII NEREZOLVATE**: niciuna care sa blocheze +executia — raman 2 intrebari de finete in §5 (editare format coloane: stergere+vizualizare in MVP; +contor utilizare formate: omis daca nu e ieftin), ambele cu propunere si fara impact pe arhitectura. + +Urmatorul pas (ROADMAP §5): `**Stare**: aprobat` → EXECUTE (TDD pe valuri). Poarta umana: aprobarea PRD. + +--- + +## Raport VERIFY + +> Completat de subagentul verificator (context curat, ROADMAP §5.6) — 2026-06-19. + +**Verdict global: PASS.** Toate cele 11 stories verificate prin cod + teste. Regresia de aur intacta. +Non-Goals respectate. + +- **Suita**: `python3 -m pytest -q` → **483 passed** (de la 434 baseline 3.4; +49 teste noi). Verde. +- **PASS/FAIL per story**: toate US-001…US-011 PASS. Dovezi (verificator context curat): + - US-001: `format_data_rar` (dd.mm.yyyy hh24:mi:ss, lipsa→"—", invalid→fallback fara exceptie); + bife accesibile cu glife `✓`/`✗` + text distinct + culoare redundanta; fara font-size <13px. + - US-002: Acasa include upload (`hx-post="/_import/upload"`), tab Import scos, `?tab=import`→Acasa (fara 404). + - US-003: `app/payload_view.py` pur/defensiv, refolosit de `GET /v1/prezentari` (DRY), payload_json brut neexpus. + - US-004: coloane RO, stare umana, detaliu in panou dedicat `#trimitere-detaliu` (nu inline), 404 cross-account. + - US-005/006: CRUD `operations_mapping`/`column_mappings` scoped pe cont, re-rezolvare la edit cod. + - US-007: `_fragments/mapari` 3 sectiuni cu empty states; `_fragments/cont` fara mapari. + - US-008: preview arata mesajul de validare pentru randuri needs_data. + - US-009: filtre stare(SQL)/vehicul/data scoped pe cont; empty state cu "sterge filtrele". + - US-010: corectie needs_data→queued cu payload+idempotency recalculate; sent read-only (403); + coliziune idempotency prinsa pre-UPDATE; cross-account 404. + - US-011: badge Mapari(needs_mapping)/Trimiteri(blocate), ascuns la zero, scoped, aria-label cu sens. +- **Regresia de aur**: flux import→commit→coada + canal API `POST /v1/prezentari` intacte + (`test_import_ui/e2e`, `test_api`, `test_web_tabs`); deep-link-uri `?tab=` valide. +- **Non-Goals**: `git diff --stat` confirma `app/worker/`, `app/idempotency.py`, `app/mapping.py`, + `app/schema.sql` NEATINSE; CHECK status pastreaza cele 6 stari; niciun endpoint `/v1/*` nou. +- **E2E live RAR**: neprobat in sesiune (fara credentiale RAR live, identic cu 3.4) — recomandata + probare manuala `./start.sh test both --send` + browser pe `http://localhost:8000/`. + +### Findings `/code-review` (high) — reparate inainte de inchidere + +1. **Corectie + needs_mapping (sever)**: ruta `POST /trimitere/{id}/corecteaza` re-punea in `queued` + fara re-rezolvarea prestatiilor → un cod nemapat putea ajunge la RAR cu `codPrestatie: null` + (FINALIZATA ireversibil). **Fix**: re-ruleaza `resolve_prestatii` + `has_no_auto_send` (ca + `reresolve_account`); cod nemapat ramane `needs_mapping`. Test: `test_corectie_needs_mapping_nu_ajunge_in_coada`. +2. **Filtru dupa LIMIT 200**: cautarea pe vehicul/data rata randuri mai vechi de 200. **Fix**: fara LIMIT + in SQL cand filtrul text/data e activ, plafonare dupa filtrare. +3. **Coliziune idempotency non-atomica (cursa TOCTOU → 500)**: **Fix**: `try/except sqlite3.IntegrityError` + in jurul UPDATE-ului `queued`, mesaj prietenos in loc de 500. +4. **Comparatie data non-ISO gresita la filtru**: **Fix**: `_is_iso_date` — compar doar date ISO YYYY-MM-DD. + +Findings de cleanup (scope-clause hand-coded in `_status_counts`/`_get_acasa_context`, `_render_panel_*` +duplicate) sunt preexistente din 3.4, in afara scope-ului 3.5 — neatinse intentionat. diff --git a/tests/test_dashboard_scope.py b/tests/test_dashboard_scope.py index 1562531..ffb73cb 100644 --- a/tests/test_dashboard_scope.py +++ b/tests/test_dashboard_scope.py @@ -83,14 +83,14 @@ def test_submissions_fragment_scoped(env, monkeypatch): monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a) r = client.get("/_fragments/submissions") assert r.status_code == 200 - assert f"{sub_a}" in r.text - assert f"{sub_b}" not in r.text + assert f'id="trimitere-row-{sub_a}"' in r.text + assert f'id="trimitere-row-{sub_b}"' not in r.text monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b) r = client.get("/_fragments/submissions") assert r.status_code == 200 - assert f"{sub_b}" in r.text - assert f"{sub_a}" not in r.text + assert f'id="trimitere-row-{sub_b}"' in r.text + assert f'id="trimitere-row-{sub_a}"' not in r.text def test_nelogat_redirect(monkeypatch): diff --git a/tests/test_payload_view.py b/tests/test_payload_view.py new file mode 100644 index 0000000..71a02d2 --- /dev/null +++ b/tests/test_payload_view.py @@ -0,0 +1,82 @@ +"""Teste US-003 (PRD 3.5): helper pur payload -> campuri afisabile. + +Helper partajat web + API (DRY, eng review). Defensiv: nu arunca pe payload +malformat; tolerant la diferentele de chei intre canale (numar vs +numarInmatriculare) si la coercion Excel (odometru "123.0", VIN non-string). +""" + +from __future__ import annotations + +import json + +from app.payload_view import prezentare_din_payload, EMPTY + + +def test_detalii_din_payload(): + """Payload complet -> toate campurile afisabile corecte.""" + payload = json.dumps({ + "vin": "WVWZZZ1JZXW000001", + "nr_inmatriculare": "B123XYZ", + "odometru_final": "123456", + "data_prestatie": "2026-06-18", + "prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}], + }) + d = prezentare_din_payload(payload) + assert d["vehicul_nr"] == "B123XYZ" + assert d["vin"] == "WVWZZZ1JZXW000001" + assert "000001" in d["vin_scurt"] # trunchiat dar identificabil + assert d["operatie"] == "Reparatie frane" + assert d["cod"] == "R-FRANE" + assert d["data_prestatie"] == "2026-06-18" + assert d["odometru"] == "123456" + + +def test_payload_partial(): + """Campuri lipsa -> EMPTY, fara exceptie.""" + d = prezentare_din_payload(json.dumps({"vin": "WVWZZZ1JZXW000002"})) + assert d["vin"] == "WVWZZZ1JZXW000002" + assert d["vehicul_nr"] == EMPTY + assert d["operatie"] == EMPTY + assert d["cod"] == EMPTY + assert d["data_prestatie"] == EMPTY + assert d["odometru"] == EMPTY + + +def test_payload_gol(): + """Payload gol / None -> toate EMPTY, fara exceptie.""" + for p in (None, "", "{}", {}): + d = prezentare_din_payload(p) + assert d["vehicul_nr"] == EMPTY + assert d["vin"] == EMPTY + + +def test_payload_invalid(): + """JSON invalid / tip neasteptat -> fallback grijuliu (nu arunca).""" + for bad in ("nu-e-json", "[1,2,3]", "null", "12345"): + d = prezentare_din_payload(bad) + assert d["vin"] == EMPTY # degradeaza curat + + +def test_payload_coercion_excel(): + """Odometru '123.0'/numeric si VIN non-string afisate curat; chei API alternative.""" + # Excel coercion: odometru float-string si numeric + d1 = prezentare_din_payload({"odometru_final": "123456.0"}) + assert d1["odometru"] == "123456" + d2 = prezentare_din_payload({"odometru_final": 123456}) + assert d2["odometru"] == "123456" + # VIN non-string (coercion Excel) + d3 = prezentare_din_payload({"vin": 12345678901234567}) + assert d3["vin"] == "12345678901234567" + # Chei alternative canal API (numar / numarInmatriculare / odometru) + d4 = prezentare_din_payload({"numar": "CJ99ABC", "odometru": "777.0"}) + assert d4["vehicul_nr"] == "CJ99ABC" + assert d4["odometru"] == "777" + d5 = prezentare_din_payload({"numarInmatriculare": "TM01AAA"}) + assert d5["vehicul_nr"] == "TM01AAA" + + +def test_operatie_fallback_la_cod(): + """Fara denumire -> operatie afiseaza codul; cod intern cand lipseste cel RAR.""" + d = prezentare_din_payload({"prestatii": [{"cod_op_service": "OP-77"}]}) + assert d["cod"] == "OP-77" + assert d["operatie"] == "OP-77" diff --git a/tests/test_web_badge.py b/tests/test_web_badge.py new file mode 100644 index 0000000..97b3874 --- /dev/null +++ b/tests/test_web_badge.py @@ -0,0 +1,123 @@ +"""Teste US-011 (PRD 3.5): badge cu contoare pe tab-uri (atentionari). + +Badge doar cand contorul > 0; numar corect scoped pe cont; aria-label cu sens. +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> 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 + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _ins(acct: int, status: str) -> None: + from app.db import get_connection + conn = get_connection() + try: + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)", + (f"k-{os.urandom(5).hex()}", acct, status, json.dumps({"vin": "X", "prestatii": []})), + ) + conn.commit() + finally: + conn.close() + + +def _tab_link(html: str, elem_id: str) -> str: + """Extrage tag-ul ... al tab-ului cu id-ul dat.""" + m = re.search(rf'', html, re.DOTALL) + assert m, f"Tab {elem_id} negasit" + return m.group(0) + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "badge.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 test_badge_mapari(client): + """Cu operatii needs_mapping, tab-ul Mapari poarta un numar + aria-label.""" + acct = _create_account_user("bm@test.com") + _ins(acct, "needs_mapping") + _ins(acct, "needs_mapping") + _login(client, "bm@test.com") + + resp = client.get("/") + assert resp.status_code == 200 + link = _tab_link(resp.text, "tab-mapari") + assert "tab-badge" in link + assert "2" in link + assert "necesita atentie" in link # aria-label + + +def test_badge_trimiteri_blocate(client): + """Cu randuri blocate, tab-ul Trimiteri poarta marcaj.""" + acct = _create_account_user("bt@test.com") + _ins(acct, "needs_data") + _ins(acct, "error") + _login(client, "bt@test.com") + + resp = client.get("/") + assert resp.status_code == 200 + link = _tab_link(resp.text, "tab-coada") + assert "tab-badge" in link + assert "2" in link + + +def test_badge_zero_ascuns(client): + """Fara nimic de rezolvat, niciun badge.""" + _create_account_user("bz@test.com") + _login(client, "bz@test.com") + + resp = client.get("/") + assert resp.status_code == 200 + assert "tab-badge" not in resp.text + + +def test_badge_scoped_pe_cont(client): + """Badge-ul numara doar submission-urile contului propriu.""" + acct1 = _create_account_user("bs1@test.com", name="C1") + _create_account_user("bs2@test.com", name="C2") + _ins(acct1, "needs_mapping") + + _login(client, "bs2@test.com") + resp = client.get("/") + assert "tab-badge" not in resp.text # contul 2 nu are nimic diff --git a/tests/test_web_corectie.py b/tests/test_web_corectie.py new file mode 100644 index 0000000..7830230 --- /dev/null +++ b/tests/test_web_corectie.py @@ -0,0 +1,205 @@ +"""Teste US-010 (PRD 3.5): corectie inline pentru randuri ne-trimise blocate. + +needs_data corectat valid -> queued cu payload + idempotency actualizate; sent +read-only (403); coliziune de idempotency prinsa pre-UPDATE (fara 500/duplicat); +cross-account interzis (404). +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> 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 + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _csrf(client) -> str: + resp = client.get("/?tab=coada") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m + return m.group(1) + + +def _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int: + from app.db import get_connection + conn = get_connection() + try: + k = key or f"k-{os.urandom(6).hex()}" + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)", + (k, acct, status, json.dumps(payload)), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +def _row(sid: int): + from app.db import get_connection + conn = get_connection() + try: + return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone() + finally: + conn.close() + + +def _payload(vin: str, *, odo: str = "55000") -> dict: + return { + "vin": vin, "nr_inmatriculare": "B100AAA", "data_prestatie": "2026-06-10", + "odometru_final": odo, "prestatii": [{"cod_prestatie": "R-X"}], + } + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "corectie.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 test_corectie_needs_data(client): + """needs_data fara odometru -> completez odometru -> queued, payload + key actualizate.""" + acct = _create_account_user("cd@test.com") + # needs_data: odometru gol + sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0CD001", odo="")) + old_key = _row(sid)["idempotency_key"] + _login(client, "cd@test.com") + csrf = _csrf(client) + + resp = client.post(f"/trimitere/{sid}/corecteaza", data={ + "odometru_final": "77000", "csrf_token": csrf, + }) + assert resp.status_code == 200 + r = _row(sid) + assert r["status"] == "queued" + assert json.loads(r["payload_json"])["odometru_final"] == "77000" + assert r["idempotency_key"] != old_key # recalculata + assert r["rar_error"] is None + + +def test_corectie_inca_invalid_ramane_blocat(client): + """Corectie cu date inca invalide -> ramane needs_data + mesaj de validare.""" + acct = _create_account_user("ci@test.com") + sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0CI001", odo="")) + _login(client, "ci@test.com") + csrf = _csrf(client) + + # odometru tot invalid (non-numeric) + resp = client.post(f"/trimitere/{sid}/corecteaza", data={ + "odometru_final": "abc", "csrf_token": csrf, + }) + assert resp.status_code == 200 + assert _row(sid)["status"] == "needs_data" + assert "odometruFinal" in resp.text # mesajul de validare e afisat + + +def test_corectie_sent_interzis(client): + """Randurile sent NU pot fi editate (read-only -> 403).""" + acct = _create_account_user("cs@test.com") + sid = _insert(acct, status="sent", payload=_payload("WVWZZZ1JZXW0CS001")) + _login(client, "cs@test.com") + csrf = _csrf(client) + + resp = client.post(f"/trimitere/{sid}/corecteaza", data={ + "odometru_final": "88000", "csrf_token": csrf, + }) + assert resp.status_code == 403 + assert _row(sid)["status"] == "sent" # neschimbat + + +def test_corectie_coliziune_idempotency(client): + """Daca noua cheie coincide cu alt submission -> oprire cu mesaj, fara 500/duplicat.""" + from app.idempotency import build_key, canonicalize_row + acct = _create_account_user("cc@test.com") + + target = _payload("WVWZZZ1JZXW0CC999", odo="99000") + existing_key = build_key(acct, canonicalize_row(target)) + # B: submission existent cu cheia tinta + sid_b = _insert(acct, status="queued", payload=target, key=existing_key) + # A: needs_data, acelasi continut dar fara odometru + sid_a = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0CC999", odo="")) + + _login(client, "cc@test.com") + csrf = _csrf(client) + + resp = client.post(f"/trimitere/{sid_a}/corecteaza", data={ + "odometru_final": "99000", "csrf_token": csrf, + }) + assert resp.status_code == 200 + assert "deja o trimitere identica" in resp.text + assert f"#{sid_b}" in resp.text + # A NU a fost re-pus in coada (a ramas blocat), B neatins + assert _row(sid_a)["status"] == "needs_data" + assert _row(sid_b)["idempotency_key"] == existing_key + + +def test_corectie_needs_mapping_nu_ajunge_in_coada(client): + """Un rand needs_mapping cu cod nemapat NU trece in queued la corectie de continut + (altfel ar pleca la RAR cu codPrestatie null — FINALIZATA ireversibil).""" + acct = _create_account_user("cm@test.com") + payload = { + "vin": "WVWZZZ1JZXW0CM001", "nr_inmatriculare": "B100AAA", "data_prestatie": "2026-06-10", + "odometru_final": "", "prestatii": [{"cod_op_service": "OP-NEMAP", "denumire": "ceva"}], + } + sid = _insert(acct, status="needs_mapping", payload=payload) + _login(client, "cm@test.com") + csrf = _csrf(client) + + # completez odometru, dar codul ramane nemapat + resp = client.post(f"/trimitere/{sid}/corecteaza", data={ + "odometru_final": "70000", "csrf_token": csrf, + }) + assert resp.status_code == 200 + assert _row(sid)["status"] == "needs_mapping" # NU queued + assert "cod RAR" in resp.text.lower() or "mapari" in resp.text.lower() + + +def test_corectie_cont_strain(client): + """Corectie pe randul altui cont -> 404 (fara leak).""" + acct1 = _create_account_user("ca1@test.com", name="C1") + _create_account_user("ca2@test.com", name="C2") + sid1 = _insert(acct1, status="needs_data", payload=_payload("WVWZZZ1JZXW0CA001", odo="")) + + _login(client, "ca2@test.com") + csrf = _csrf(client) + resp = client.post(f"/trimitere/{sid1}/corecteaza", data={ + "odometru_final": "10000", "csrf_token": csrf, + }) + assert resp.status_code == 404 + assert _row(sid1)["status"] == "needs_data" # neatins diff --git a/tests/test_web_dashboard.py b/tests/test_web_dashboard.py new file mode 100644 index 0000000..2b5e972 --- /dev/null +++ b/tests/test_web_dashboard.py @@ -0,0 +1,100 @@ +"""Teste US-002 (PRD 3.5): Acasa devine ecranul de import. + +Upload direct pe prima pagina (importul = operatia principala); tab-ul "Import" +separat dispare, dar ?tab=import ramane valid (echivalent Acasa, fara 404). +""" + +from __future__ import annotations + +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str = "dash@test.com", password: str = "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 Dash", active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> 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 + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "dash.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 test_acasa_contine_upload(client): + """Fragmentul /_fragments/acasa contine formularul de upload (hx-post import).""" + _create_account_user("acasaup@test.com") + _login(client, "acasaup@test.com") + + resp = client.get("/_fragments/acasa") + assert resp.status_code == 200 + html = resp.text + assert 'hx-post="/_import/upload"' in html, "Acasa nu contine formularul de upload" + assert 'id="import-section"' in html, "Acasa nu contine zona de import" + + +def test_acasa_full_load_contine_upload(client): + """La full load pe / (tab implicit Acasa) caseta de upload e vizibila direct.""" + _create_account_user("acasafull@test.com") + _login(client, "acasafull@test.com") + + resp = client.get("/") + assert resp.status_code == 200 + assert 'hx-post="/_import/upload"' in resp.text + + +def test_tab_import_redirect(client): + """?tab=import nu da 404; randeaza Acasa (echivalent), care contine upload-ul.""" + _create_account_user("redir@test.com") + _login(client, "redir@test.com") + + resp = client.get("/?tab=import") + assert resp.status_code == 200 + html = resp.text + # Echivalent Acasa: contine upload-ul (import-section) + assert 'id="import-section"' in html + # Acasa e tab-ul activ (import nu mai e tab valid separat) + assert re.search(r'id="tab-acasa"[^>]*aria-selected="true"', html), \ + "?tab=import ar trebui sa cada pe Acasa activ" + + +def test_tab_bar_fara_import(client): + """Tab-bar-ul nu mai contine un tab 'Import' separat.""" + _create_account_user("notab@test.com") + _login(client, "notab@test.com") + + resp = client.get("/") + assert resp.status_code == 200 + assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', resp.text) diff --git a/tests/test_web_filtrare.py b/tests/test_web_filtrare.py new file mode 100644 index 0000000..b976c69 --- /dev/null +++ b/tests/test_web_filtrare.py @@ -0,0 +1,141 @@ +"""Teste US-009 (PRD 3.5): filtrare/cautare in Trimiteri (stare/vehicul/data). + +Toate scoped pe cont, fara leak cross-account. +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> 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 + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _ins(acct: int, *, status: str, vin: str, nr: str, data: str) -> int: + from app.db import get_connection + conn = get_connection() + try: + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)", + ( + f"k-{os.urandom(5).hex()}", acct, status, + json.dumps({"vin": vin, "nr_inmatriculare": nr, "data_prestatie": data, + "odometru_final": "100", "prestatii": [{"cod_prestatie": "R-X"}]}), + ), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "filtre.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 test_filtru_stare(client): + acct = _create_account_user("fs@test.com") + sid_q = _ins(acct, status="queued", vin="WVWZZZ1JZXW000001", nr="B001AAA", data="2026-06-10") + sid_nd = _ins(acct, status="needs_data", vin="WVWZZZ1JZXW000002", nr="B002BBB", data="2026-06-11") + _login(client, "fs@test.com") + + resp = client.get("/_fragments/submissions?status=needs_data") + assert resp.status_code == 200 + html = resp.text + assert f'id="trimitere-row-{sid_nd}"' in html + assert f'id="trimitere-row-{sid_q}"' not in html + + +def test_filtru_vehicul(client): + acct = _create_account_user("fv@test.com") + sid_a = _ins(acct, status="sent", vin="WVWZZZ1JZXW000111", nr="CJ77ABC", data="2026-06-10") + sid_b = _ins(acct, status="sent", vin="WVWZZZ1JZXW000222", nr="TM01XYZ", data="2026-06-11") + _login(client, "fv@test.com") + + # cautare pe nr (case-insensitive) + resp = client.get("/_fragments/submissions?vehicul=cj77") + assert resp.status_code == 200 + assert f'id="trimitere-row-{sid_a}"' in resp.text + assert f'id="trimitere-row-{sid_b}"' not in resp.text + + # cautare pe fragment VIN + resp = client.get("/_fragments/submissions?vehicul=000222") + assert f'id="trimitere-row-{sid_b}"' in resp.text + assert f'id="trimitere-row-{sid_a}"' not in resp.text + + +def test_filtru_data(client): + acct = _create_account_user("fd@test.com") + sid_old = _ins(acct, status="sent", vin="WVWZZZ1JZXW000333", nr="B1", data="2026-06-01") + sid_new = _ins(acct, status="sent", vin="WVWZZZ1JZXW000444", nr="B2", data="2026-06-20") + _login(client, "fd@test.com") + + resp = client.get("/_fragments/submissions?data_de=2026-06-15") + assert resp.status_code == 200 + assert f'id="trimitere-row-{sid_new}"' in resp.text + assert f'id="trimitere-row-{sid_old}"' not in resp.text + + resp = client.get("/_fragments/submissions?data_pana=2026-06-10") + assert f'id="trimitere-row-{sid_old}"' in resp.text + assert f'id="trimitere-row-{sid_new}"' not in resp.text + + +def test_filtru_scoped_cross_account(client): + acct1 = _create_account_user("fc1@test.com", name="C1") + acct2 = _create_account_user("fc2@test.com", name="C2") + sid1 = _ins(acct1, status="needs_data", vin="WVWZZZ1JZXW000555", nr="B1", data="2026-06-10") + sid2 = _ins(acct2, status="needs_data", vin="WVWZZZ1JZXW000666", nr="B2", data="2026-06-10") + + _login(client, "fc2@test.com") + resp = client.get("/_fragments/submissions?status=needs_data") + assert f'id="trimitere-row-{sid2}"' in resp.text + assert f'id="trimitere-row-{sid1}"' not in resp.text + + +def test_empty_state_filtru_are_buton_sterge(client): + acct = _create_account_user("fe@test.com") + _ins(acct, status="sent", vin="WVWZZZ1JZXW000777", nr="B1", data="2026-06-10") + _login(client, "fe@test.com") + + resp = client.get("/_fragments/submissions?status=needs_data") + assert resp.status_code == 200 + assert "Nimic pe filtrul curent" in resp.text + assert "sterge filtrele" in resp.text diff --git a/tests/test_web_formate_coloane.py b/tests/test_web_formate_coloane.py new file mode 100644 index 0000000..c517ab7 --- /dev/null +++ b/tests/test_web_formate_coloane.py @@ -0,0 +1,145 @@ +"""Teste US-006 (PRD 3.5): listare + editare/stergere formate de coloane salvate. + +Scoped pe cont (fara leak cross-account). Coloanele afisate = cheile json_mapare. +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> 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 + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _csrf(client) -> str: + resp = client.get("/?tab=mapari") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \ + re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text) + assert m + return m.group(1) + + +def _seed_format(acct: int, sig: str, mapare: dict, fmt: str | None) -> int: + from app.db import get_connection + conn = get_connection() + try: + cur = conn.execute( + "INSERT INTO column_mappings (account_id, signature_coloane, json_mapare, format_data) " + "VALUES (?, ?, ?, ?)", + (acct, sig, json.dumps(mapare, ensure_ascii=False), fmt), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "formate.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 test_lista_formate_coloane(client): + """Listarea intoarce formatele contului cu coloane + format_data.""" + acct = _create_account_user("lf@test.com") + _seed_format(acct, "sig-1", {"Serie sasiu": "vin", "Nr auto": "nr_inmatriculare"}, "DD.MM.YYYY") + + from app.db import get_connection + from app.web.routes import _load_column_formats + conn = get_connection() + try: + rows = _load_column_formats(conn, acct) + finally: + conn.close() + + assert len(rows) == 1 + assert rows[0]["format_data"] == "DD.MM.YYYY" + assert "Serie sasiu" in rows[0]["columns"] + assert rows[0]["mappings"]["Serie sasiu"] == "vin" + + +def test_editeaza_format_coloane(client): + """POST schimba format_data pentru un format, scoped pe cont.""" + acct = _create_account_user("ef@test.com") + fid = _seed_format(acct, "sig-2", {"Data": "data_prestatie"}, "DD.MM.YYYY") + + _login(client, "ef@test.com") + csrf = _csrf(client) + resp = client.post("/formate-coloane/editeaza", data={ + "format_id": str(fid), "format_data": "YYYY-MM-DD", "csrf_token": csrf, + }) + assert resp.status_code == 200 + + from app.db import get_connection + conn = get_connection() + try: + row = conn.execute("SELECT format_data FROM column_mappings WHERE id=?", (fid,)).fetchone() + finally: + conn.close() + assert row["format_data"] == "YYYY-MM-DD" + + +def test_sterge_format_coloane_scoped(client): + """DELETE scoped pe cont: formatul altui cont ramane neatins (id strain ignorat).""" + acct1 = _create_account_user("sf1@test.com", name="C1") + acct2 = _create_account_user("sf2@test.com", name="C2") + fid1 = _seed_format(acct1, "sig-a", {"A": "vin"}, None) + fid2 = _seed_format(acct2, "sig-b", {"B": "vin"}, None) + + _login(client, "sf1@test.com") + csrf = _csrf(client) + + # Incearca sa stearga formatul altui cont -> ignorat (scoped pe id+account) + resp = client.post("/formate-coloane/sterge", data={"format_id": str(fid2), "csrf_token": csrf}) + assert resp.status_code == 200 + + # Sterge formatul propriu -> ok + resp = client.post("/formate-coloane/sterge", data={"format_id": str(fid1), "csrf_token": csrf}) + assert resp.status_code == 200 + + from app.db import get_connection + conn = get_connection() + try: + r1 = conn.execute("SELECT 1 FROM column_mappings WHERE id=?", (fid1,)).fetchone() + r2 = conn.execute("SELECT 1 FROM column_mappings WHERE id=?", (fid2,)).fetchone() + finally: + conn.close() + assert r1 is None, "formatul propriu trebuia sters" + assert r2 is not None, "formatul altui cont NU trebuia sters (leak)" diff --git a/tests/test_web_labels.py b/tests/test_web_labels.py index 2d139d4..53f8323 100644 --- a/tests/test_web_labels.py +++ b/tests/test_web_labels.py @@ -46,7 +46,12 @@ def _starile_din_schema() -> list[str]: # Import modulul de etichete (va esua la RED, inainte de implementare) # --------------------------------------------------------------------------- -from app.web.labels import eticheta_stare, eticheta_worker, eticheta_rar # noqa: E402 +from app.web.labels import ( # noqa: E402 + eticheta_stare, + eticheta_worker, + eticheta_rar, + format_data_rar, +) # --------------------------------------------------------------------------- @@ -128,6 +133,35 @@ def test_eticheta_stare_submission(): _STARI_SCHEMA = _starile_din_schema() +# --------------------------------------------------------------------------- +# Test format_data_rar (US-001, PRD 3.5) +# --------------------------------------------------------------------------- + +def test_format_data_rar(): + """`2026-06-18T14:30:22` -> `18.06.2026 14:30:22`.""" + assert format_data_rar("2026-06-18T14:30:22") == "18.06.2026 14:30:22" + + +def test_format_data_rar_cu_timezone(): + """Timezone si microsecunde nu strica formatarea; fractiunile cad.""" + assert format_data_rar("2026-06-18T14:30:22.123456+00:00") == "18.06.2026 14:30:22" + assert format_data_rar("2026-06-18T14:30:22Z") == "18.06.2026 14:30:22" + + +def test_format_data_rar_lipsa(): + """Valoare lipsa -> em-dash, nu exceptie.""" + assert format_data_rar(None) == "—" + assert format_data_rar("") == "—" + assert format_data_rar(" ") == "—" + + +def test_format_data_rar_invalid(): + """Format invalid -> fallback grijuliu (intoarce brutul, nu arunca).""" + # Nu trebuie sa arunce + assert format_data_rar("nu-e-data") == "nu-e-data" + assert format_data_rar(12345) == "12345" + + @pytest.mark.parametrize("status", _STARI_SCHEMA) def test_toate_starile_au_eticheta(status: str): """ diff --git a/tests/test_web_mapari_salvate.py b/tests/test_web_mapari_salvate.py new file mode 100644 index 0000000..7006781 --- /dev/null +++ b/tests/test_web_mapari_salvate.py @@ -0,0 +1,219 @@ +"""Teste US-005 (PRD 3.5): listare + editare/stergere mapari operatii salvate. + +Scoped pe cont (fara leak cross-account). Editarea respinge cod inexistent in +nomenclator si re-rezolva submission-urile blocate pe acel cod_op_service. +""" + +from __future__ import annotations + +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> 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 + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _csrf(client) -> str: + resp = client.get("/?tab=mapari") + 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" + return m.group(1) + + +def _seed_nomenclator(cod: str, nume: str = "Test prestatie") -> None: + from app.db import get_connection + conn = get_connection() + try: + conn.execute( + "INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)", + (cod, nume), + ) + conn.commit() + finally: + conn.close() + + +def _seed_op_mapping(acct: int, op: str, cod: str, auto_send: int = 1) -> None: + from app.db import get_connection + conn = get_connection() + try: + conn.execute( + "INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) " + "VALUES (?, ?, ?, ?)", + (acct, op, cod, auto_send), + ) + conn.commit() + finally: + conn.close() + + +def _seed_needs_mapping(acct: int, op: str) -> int: + """Submission needs_mapping pe canal API (batch_id NULL) cu o operatie nemapata.""" + from app.db import get_connection + import json + conn = get_connection() + try: + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES (?, ?, 'needs_mapping', ?)", + ( + f"k-{op}-{os.urandom(4).hex()}", + acct, + json.dumps({ + "vin": "WVWZZZ1JZXW000111", + "nr_inmatriculare": "B11AAA", + "data_prestatie": "2026-06-18", + "odometru_final": "12345", + "prestatii": [{"cod_op_service": op, "denumire": "ceva"}], + }), + ), + ) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def _status_of(sid: int) -> str: + from app.db import get_connection + conn = get_connection() + try: + return conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"] + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_salvate.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 test_lista_mapari_salvate(client): + """Listarea intoarce randurile operations_mapping ale contului cu nume_prestatie.""" + acct = _create_account_user("lista@test.com") + _seed_nomenclator("R-FRANE", "Reparatie frane") + _seed_op_mapping(acct, "OP-100", "R-FRANE") + + from app.db import get_connection + from app.web.routes import _load_saved_op_mappings + conn = get_connection() + try: + rows = _load_saved_op_mappings(conn, acct) + finally: + conn.close() + + assert len(rows) == 1 + assert rows[0]["cod_op_service"] == "OP-100" + assert rows[0]["cod_prestatie"] == "R-FRANE" + assert rows[0]["nume_prestatie"] == "Reparatie frane" + assert rows[0]["auto_send"] is True + + +def test_editeaza_mapare_salvata(client): + """POST schimba cod_prestatie; respinge cod inexistent; scoped pe cont.""" + acct = _create_account_user("edit@test.com") + _seed_nomenclator("R-FRANE", "Reparatie frane") + _seed_nomenclator("R-MOTOR", "Reparatie motor") + _seed_op_mapping(acct, "OP-100", "R-FRANE") + _login(client, "edit@test.com") + csrf = _csrf(client) + + # Cod inexistent -> respins + resp = client.post("/mapari/salvate", data={ + "cod_op_service": "OP-100", "cod_prestatie": "NU-EXISTA", "csrf_token": csrf, + }) + assert resp.status_code == 200 + assert "necunoscut" in resp.text.lower() + + # Cod valid -> actualizat + resp = client.post("/mapari/salvate", data={ + "cod_op_service": "OP-100", "cod_prestatie": "R-MOTOR", "auto_send": "true", "csrf_token": csrf, + }) + assert resp.status_code == 200 + + from app.db import get_connection + conn = get_connection() + try: + row = conn.execute( + "SELECT cod_prestatie FROM operations_mapping WHERE account_id=? AND cod_op_service=?", + (acct, "OP-100"), + ).fetchone() + finally: + conn.close() + assert row["cod_prestatie"] == "R-MOTOR" + + +def test_editeaza_deblocheaza_submissions(client): + """La editarea unui cod, submission-urile needs_mapping pe acel op se deblocheaza.""" + acct = _create_account_user("debloc@test.com") + _seed_nomenclator("R-FRANE", "Reparatie frane") + sid = _seed_needs_mapping(acct, "OP-200") + assert _status_of(sid) == "needs_mapping" + + _login(client, "debloc@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/salvate", data={ + "cod_op_service": "OP-200", "cod_prestatie": "R-FRANE", "auto_send": "true", "csrf_token": csrf, + }) + assert resp.status_code == 200 + assert _status_of(sid) != "needs_mapping" # deblocat (queued sau needs_data) + + +def test_sterge_mapare_salvata_scoped(client): + """DELETE scoped pe cont: maparea altui cont ramane neatinsa.""" + acct1 = _create_account_user("st1@test.com", name="Cont1") + acct2 = _create_account_user("st2@test.com", name="Cont2") + _seed_nomenclator("R-FRANE") + _seed_op_mapping(acct1, "OP-X", "R-FRANE") + _seed_op_mapping(acct2, "OP-X", "R-FRANE") + + _login(client, "st1@test.com") + csrf = _csrf(client) + resp = client.post("/mapari/salvate/sterge", data={"cod_op_service": "OP-X", "csrf_token": csrf}) + assert resp.status_code == 200 + + from app.db import get_connection + conn = get_connection() + try: + r1 = conn.execute("SELECT 1 FROM operations_mapping WHERE account_id=? AND cod_op_service='OP-X'", (acct1,)).fetchone() + r2 = conn.execute("SELECT 1 FROM operations_mapping WHERE account_id=? AND cod_op_service='OP-X'", (acct2,)).fetchone() + finally: + conn.close() + assert r1 is None, "maparea contului propriu trebuia stearsa" + assert r2 is not None, "maparea altui cont NU trebuia atinsa (leak)" diff --git a/tests/test_web_mapari_ui.py b/tests/test_web_mapari_ui.py new file mode 100644 index 0000000..60b23eb --- /dev/null +++ b/tests/test_web_mapari_ui.py @@ -0,0 +1,116 @@ +"""Teste US-007 (PRD 3.5): pagina "Mapari" cu 3 sectiuni; "Cont" fara mapari.""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> 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 + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _seed(acct: int) -> None: + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')") + conn.execute( + "INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) VALUES (?, 'OP-1', 'R-FRANE', 1)", + (acct,), + ) + conn.execute( + "INSERT INTO column_mappings (account_id, signature_coloane, json_mapare, format_data) VALUES (?, 'sig-x', ?, 'DD.MM.YYYY')", + (acct, json.dumps({"Serie sasiu": "vin"})), + ) + conn.commit() + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_ui.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 test_mapari_trei_sectiuni(client): + """Fragmentul /_fragments/mapari contine cele 3 sectiuni.""" + acct = _create_account_user("m3@test.com") + _seed(acct) + _login(client, "m3@test.com") + + resp = client.get("/_fragments/mapari") + assert resp.status_code == 200 + html = resp.text + assert "De rezolvat" in html + assert "Mapari operatii salvate" in html + assert "Formate de coloane salvate" in html + # Maparea salvata si formatul apar + assert "OP-1" in html + assert "Serie sasiu" in html + + +def test_mapari_sectiuni_goale_au_mesaj(client): + """Sectiunile goale au mesaj prietenos, nu lipsesc tacit.""" + _create_account_user("mgol@test.com") + _login(client, "mgol@test.com") + + resp = client.get("/_fragments/mapari") + assert resp.status_code == 200 + html = resp.text + # Toate cele 3 titluri prezente chiar si cand sunt goale + assert "De rezolvat" in html + assert "Mapari operatii salvate" in html + assert "Formate de coloane salvate" in html + assert "Nicio mapare salvata" in html + assert "Niciun format de coloane salvat" in html + + +def test_cont_fara_mapari(client): + """/_fragments/cont nu mai contine sectiuni de mapari.""" + _create_account_user("cfm@test.com") + _login(client, "cfm@test.com") + + resp = client.get("/_fragments/cont") + assert resp.status_code == 200 + html = resp.text + assert "Mapari operatii salvate" not in html + assert "Formate de coloane salvate" not in html + # Cont contine doar cheie API + creds RAR + assert "Cheia mea API" in html + assert "Credentiale RAR" in html diff --git a/tests/test_web_onboarding.py b/tests/test_web_onboarding.py index 566250e..b35971b 100644 --- a/tests/test_web_onboarding.py +++ b/tests/test_web_onboarding.py @@ -151,13 +151,10 @@ def test_checklist_pas_creds_bifat_cand_exista(client): assert resp.status_code == 200 html = resp.text - # Cand exista creds, pasul trebuie sa fie bifat - # Verificam prezenta unui indicator de bifat (clasa 'bifat' sau 'pas-bifat' sau 'done') - # Cel putin unul dintre pattern-urile de bifat trebuie sa apara - assert re.search( - r'pas-bifat|class="[^"]*bifat|done.*RAR|RAR.*done|checkmark.*RAR|RAR.*checkmark', - html, re.DOTALL | re.IGNORECASE - ), "Pasul RAR trebuie sa fie bifat cand contul are creds configurate" + # Cand exista creds, pasul "Cont RAR" e bifat: glifa ✓ (s-sent) langa link-ul Cont RAR + # (Acasa compacta PRD 3.5 — checklist pe un rand, bife cu glifa). + assert "✓" in html, "Lipseste glifa de bifat cand contul are creds" + assert "Cont RAR" in html, "Lipseste pasul 'Cont RAR' din checklist" # ============================================================ @@ -175,17 +172,12 @@ def test_checklist_ascuns_cand_totul_gata(client): assert resp.status_code == 200 html = resp.text - # Cand totul e gata, ghidul compact/discret trebuie sa apara - # Fie "Totul e configurat" fie un link discret catre coada - assert "Totul e configurat" in html or "totul e configurat" in html.lower(), \ - "Cand toti pasii sunt gata, trebuie sa apara mesajul discret 'Totul e configurat'" - - # Cardul mare de pasi nu trebuie sa ocupe ecranul - # Verificam ca nu mai apare titlul mare al ghidului (Primii pasi) - # SAU ca ghidul e marcat ca colapsat (clasa 'ghid-complet' sau similar) - # Pattern: fie ghid-complet, fie lipsa titlului complet "Primii pasi" in forma de card mare - assert "ghid-complet" in html or "Totul e configurat" in html, \ - "Ghidul trebuie sa se colapseze cand toti pasii esentiali sunt finalizati" + # Cand toti pasii esentiali sunt gata, checklist-ul "Primii pasi" dispare + # (Acasa compacta PRD 3.5: nu mai concureaza cu caseta de upload). + assert "Primii pasi" not in html, \ + "Checklist-ul 'Primii pasi' trebuie sa dispara cand toti pasii esentiali sunt gata" + # Upload-ul ramane dominant pe pagina chiar si dupa setup complet + assert 'hx-post="/_import/upload"' in html # ============================================================ @@ -193,7 +185,7 @@ def test_checklist_ascuns_cand_totul_gata(client): # ============================================================ def test_linkuri_ghid_duc_la_taburi(client): - """Link-urile din ghid contin ?tab=cont si ?tab=import.""" + """Ghidul Acasa duce la Cont; importul e direct pe pagina (nu mai e tab separat).""" acct_id, _ = _create_account_user("links@test.com") _login(client, "links@test.com") @@ -205,9 +197,9 @@ def test_linkuri_ghid_duc_la_taburi(client): assert "?tab=cont" in html, \ "Ghidul nu contine link catre tab-ul Cont (?tab=cont)" - # Ghidul trebuie sa contina link catre tab-ul Import - assert "?tab=import" in html, \ - "Ghidul nu contine link catre tab-ul Import (?tab=import)" + # Importul e acum direct pe Acasa (caseta de upload), nu un link catre alt tab + assert 'hx-post="/_import/upload"' in html, \ + "Acasa trebuie sa contina caseta de upload (importul e operatia principala)" # ============================================================ @@ -227,13 +219,13 @@ def test_empty_state_coada_gol(client): assert "POST /v1/prezentari" not in html, \ "Empty state coada nu trebuie sa contina mesajul tehnic vechi 'POST /v1/prezentari'" - # Trebuie sa contina un indemn catre Import + # Trebuie sa contina un indemn catre Import (acum pe Acasa) assert "import" in html.lower() or "Import" in html, \ "Empty state coada trebuie sa contina indemn catre Import" - # Trebuie sa contina link catre ?tab=import - assert "?tab=import" in html, \ - "Empty state coada trebuie sa contina link ?tab=import" + # Trebuie sa contina link catre Acasa (unde traieste importul acum) + assert "?tab=acasa" in html, \ + "Empty state coada trebuie sa contina link catre Acasa (importul e acolo)" # ============================================================ diff --git a/tests/test_web_preview_motive.py b/tests/test_web_preview_motive.py new file mode 100644 index 0000000..144444b --- /dev/null +++ b/tests/test_web_preview_motive.py @@ -0,0 +1,88 @@ +"""Teste US-008 (PRD 3.5): preview-ul de import arata MOTIVUL randurilor respinse. + +Un rand needs_data (ex. lipsa odometru) trebuie sa apara cu motivul explicit +(mesajul de validare), nu doar numarat la "blocate". +""" + +from __future__ import annotations + +import csv +import io +import os +import re +import tempfile + +import pytest + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "prev.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_op1() -> None: + """Mapeaza OP-1 -> R-FRANE (cont dev id=1) ca randurile sa nu fie needs_mapping.""" + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')") + conn.execute( + "INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) " + "VALUES (1, 'OP-1', 'R-FRANE', 1)" + ) + conn.commit() + finally: + conn.close() + + +def test_preview_arata_motiv_needs_data(client): + """Un rand fara odometru apare in preview cu motivul, nu doar numarat la blocate.""" + _seed_mapping_op1() + rows = [ + # rand valid + {"VIN": "WVWZZZ1KZAW000123", "Nr inmatriculare": "B001TST", + "Data prestatie": "15.06.2026", "Odometru final": "123456", "Operatie": "OP-1"}, + # rand fara odometru -> needs_data + {"VIN": "WVWZZZ1KZAW000456", "Nr inmatriculare": "B002TST", + "Data prestatie": "15.06.2026", "Odometru final": "", "Operatie": "OP-1"}, + ] + data = _csv_bytes(rows) + + # Upload -> formular mapare + 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, "Nu am gasit import_id in formularul de mapare" + import_id = int(m.group(1)) + + # Salveaza maparea -> preview + 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 + html = r.text + + # Trebuie sa apara starea needs_data si MOTIVUL (mesajul de validare odometru) + assert "needs_data" in html, "Randul fara odometru trebuia marcat needs_data" + assert "odometruFinal" in html, ( + "Preview-ul nu arata motivul (mesajul de validare) pentru randul fara odometru" + ) diff --git a/tests/test_web_status.py b/tests/test_web_status.py new file mode 100644 index 0000000..39a35f8 --- /dev/null +++ b/tests/test_web_status.py @@ -0,0 +1,129 @@ +"""Teste US-001 (PRD 3.5): bara de status compacta cu bife accesibile + data formatata. + +Bifa = glifa distincta (✓ / ✗) + text, NU doar culoare (daltonism, design review). +Verde/✓ cand worker viu + RAR ok; rosu/✗ cand oprit/indisponibil. +""" + +from __future__ import annotations + +import os +import re +import tempfile +from datetime import datetime, timezone + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, password: str = "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 Bife", active=True) + user_id = create_user(conn, acct_id, email, password) + return acct_id, user_id + finally: + conn.close() + + +def _login(client, email: str, password: str) -> None: + resp = client.get("/login") + assert resp.status_code == 200 + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + if not m: + m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text) + assert m, "csrf_token negasit pe /login" + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}" + + +def _set_heartbeat(last_beat: str | None, last_rar_login_ok: str | None) -> None: + from app.db import get_connection + + conn = get_connection() + try: + conn.execute( + "UPDATE worker_heartbeat SET last_beat=?, last_rar_login_ok=? WHERE id=1", + (last_beat, last_rar_login_ok), + ) + conn.commit() + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "bife_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 test_status_are_bife_verzi_cand_totul_ok(client): + """Worker viu + RAR login recent -> bifa verde ✓ pentru ambele stari binare.""" + _create_account_user("bifeok@test.com") + _login(client, "bifeok@test.com", "parolasecreta10") + + now = datetime.now(timezone.utc).isoformat() + _set_heartbeat(last_beat=now, last_rar_login_ok=now) + + resp = client.get("/_fragments/status") + assert resp.status_code == 200 + html = resp.text + # Glifa de OK prezenta (accesibilitate: nu doar culoare) + assert "✓" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}" + # Texte umane de OK + assert "activa" in html.lower() + assert "functionala" in html.lower() + + +def test_status_are_bife_rosii_cand_worker_oprit(client): + """Fara heartbeat -> worker oprit -> bifa rosie ✗ + text 'oprita'.""" + _create_account_user("biferosu@test.com") + _login(client, "biferosu@test.com", "parolasecreta10") + + _set_heartbeat(last_beat=None, last_rar_login_ok=None) + + resp = client.get("/_fragments/status") + assert resp.status_code == 200 + html = resp.text + assert "✗" in html, f"Lipseste glifa ✗ cand worker oprit. HTML: {html[:600]}" + assert "oprita" in html.lower() + + +def test_status_data_formatata_romaneste(client): + """Ultima autentificare RAR apare ca dd.mm.yyyy hh24:mi:ss.""" + _create_account_user("bifedata@test.com") + _login(client, "bifedata@test.com", "parolasecreta10") + + now = datetime.now(timezone.utc).isoformat() + _set_heartbeat(last_beat=now, last_rar_login_ok="2026-06-18T14:30:22") + + resp = client.get("/_fragments/status") + assert resp.status_code == 200 + assert "18.06.2026 14:30:22" in resp.text, ( + f"Data nu e formatata romaneste. HTML: {resp.text[:800]}" + ) + + +def test_status_fara_fonturi_minuscule(client): + """Niciun text din bara nu mai foloseste font-size sub 13px (US-001 AC).""" + _create_account_user("bifefont@test.com") + _login(client, "bifefont@test.com", "parolasecreta10") + + resp = client.get("/_fragments/status") + assert resp.status_code == 200 + html = resp.text + for bad in ("font-size:11px", "font-size:12px", "font-size: 11px", "font-size: 12px"): + assert bad not in html, f"Bara de status foloseste {bad} (sub 13px)." diff --git a/tests/test_web_submissions.py b/tests/test_web_submissions.py new file mode 100644 index 0000000..04899a6 --- /dev/null +++ b/tests/test_web_submissions.py @@ -0,0 +1,150 @@ +"""Teste US-004 (PRD 3.5): "Coada" -> "Trimiteri" tabel lizibil + detaliu la click. + +Coloane umane (RO), stare via labels (nu "sent" brut), vehicul/operatie/data din +payload, motiv uman. Detaliu scoped pe cont (404 cross-account). +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +def _create_account_user(email: str, name: str = "Service", password: str = "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, name, active=True) + create_user(conn, acct_id, email, password) + return acct_id + finally: + conn.close() + + +def _login(client, email: str, password: str = "parolasecreta10") -> 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 + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _insert_submission(acct: int, status: str = "sent", *, payload: dict | None = None, + rar_error: str | None = None, id_prezentare=None) -> int: + from app.db import get_connection + conn = get_connection() + try: + p = payload if payload is not None else { + "vin": "WVWZZZ1JZXW000777", + "nr_inmatriculare": "B777ZZZ", + "data_prestatie": "2026-06-18", + "odometru_final": "55000", + "prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}], + } + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, id_prezentare) " + "VALUES (?, ?, ?, ?, ?, ?)", + (f"k-{status}-{os.urandom(4).hex()}", acct, status, json.dumps(p), rar_error, id_prezentare), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "subm.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 test_submissions_coloane_umane(client): + """Antete RO; stare umana (nu 'sent'); vehicul/operatie din payload; fara 'HTTP RAR' ca antet.""" + acct = _create_account_user("col@test.com") + _insert_submission(acct, "sent", id_prezentare=68516) + _login(client, "col@test.com") + + resp = client.get("/_fragments/submissions") + assert resp.status_code == 200 + html = resp.text + # Antete romanesti + for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR", "Motiv"): + assert antet in html, f"Lipseste antetul '{antet}'" + # "HTTP RAR" NU mai e antet principal de coloana + assert "HTTP RAR" not in html + # Starea afisata e text uman, nu 'sent' brut intr-un pill + assert ">sent<" not in html, "Starea bruta 'sent' nu ar trebui afisata" + assert "Declarate la RAR" in html, "Starea umana lipseste" + # Vehicul + operatie din payload, nu doar idPrezentare + assert "B777ZZZ" in html + assert "Reparatie frane" in html + + +def test_tab_eticheta_trimiteri(client): + """Eticheta tab e 'Trimiteri' dar deep-link ?tab=coada ramane valid.""" + _create_account_user("et@test.com") + _login(client, "et@test.com") + resp = client.get("/?tab=coada") + assert resp.status_code == 200 + assert "Trimiteri" in resp.text + assert 'id="tab-coada"' in resp.text + + +def test_motiv_needs_data_afisat(client): + """Pentru needs_data, coloana Motiv arata motivul (nu gol cand exista rar_error).""" + acct = _create_account_user("motiv@test.com") + _insert_submission( + acct, "needs_data", + rar_error=json.dumps([{"field": "odometru_final", "message": "lipsa odometru"}]), + ) + _login(client, "motiv@test.com") + resp = client.get("/_fragments/submissions") + assert resp.status_code == 200 + assert "lipsa odometru" in resp.text + + +def test_detaliu_trimitere(client): + """/_fragments/trimitere/{id} intoarce detaliul complet scoped pe cont.""" + acct = _create_account_user("det@test.com") + sid = _insert_submission(acct, "sent", id_prezentare=99001) + _login(client, "det@test.com") + + resp = client.get(f"/_fragments/trimitere/{sid}") + assert resp.status_code == 200 + html = resp.text + assert f"Detaliu trimitere #{sid}" in html + assert "WVWZZZ1JZXW000777" in html # VIN integral in detaliu + assert "99001" in html # nr prezentare RAR + + +def test_detaliu_trimitere_404_cross_account(client): + """Detaliul altui cont -> 404 (fara leak).""" + acct1 = _create_account_user("d1@test.com", name="C1") + _create_account_user("d2@test.com", name="C2") + sid1 = _insert_submission(acct1, "sent") + + _login(client, "d2@test.com") + resp = client.get(f"/_fragments/trimitere/{sid1}") + assert resp.status_code == 404 + # acelasi 404 pentru un id inexistent + resp2 = client.get("/_fragments/trimitere/999999") + assert resp2.status_code == 404 diff --git a/tests/test_web_tabs.py b/tests/test_web_tabs.py index e2474b4..97121bc 100644 --- a/tests/test_web_tabs.py +++ b/tests/test_web_tabs.py @@ -83,9 +83,12 @@ def test_dashboard_are_tabbar(client): html = resp.text assert 'role="tablist"' in html, "Lipseste role=tablist" - # Cele 6 tab-uri trebuie sa fie prezente - for label in ("Acasa", "Import", "Coada", "Mapari", "Cont", "Nomenclator"): + # 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)" # ============================================================