feat(web): dashboard compact — import pe Acasa, status cu bife, Trimiteri lizibile, Mapari complete (3.5)

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) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-19 08:56:45 +00:00
parent d10e9db998
commit d7ba1195d4
29 changed files with 3241 additions and 233 deletions

View File

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

112
app/payload_view.py Normal file
View File

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

View File

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

View File

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

View File

@@ -1,81 +1,56 @@
<div id="acasa-section">
{% 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 #}
<div class="ghid-complet" style="margin-bottom:12px; font-size:13px; color:var(--muted);">
Totul e configurat —
<a href="/?tab=coada">vezi coada</a>
</div>
{% else %}
{# Card ghid de pornire vizibil cand nu toti pasii sunt finalizati #}
<div class="card" style="margin-bottom:16px;">
<h2 style="font-size:15px; margin:0 0 12px;">Primii pasi</h2>
<ul style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px;">
{# === Subordonat: primii pasi pe un singur rand compact === #}
{% set toti_esentiali = are_creds and are_trimiteri %}
{% if not toti_esentiali %}
<div class="card" style="margin-top:14px; padding:12px 16px;">
<div style="display:flex; gap:20px; flex-wrap:wrap; align-items:center; font-size:13px;">
<span class="muted" style="font-weight:600;">Primii pasi:</span>
{# Pas 1: Conecteaza contul RAR (esential) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{# Pas 1: Cont RAR (esential) #}
<span style="display:inline-flex; align-items:center; gap:6px;">
{% if are_creds %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
<span class="muted" aria-hidden="true">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=cont">Conecteaza-ti contul RAR</a>
<span class="muted" style="font-size:12px; display:block;">
Email + parola portal AUTOPASS RAR
<a href="/?tab=cont">Cont RAR</a>
</span>
</span>
</li>
{# Pas 2: Cheie API (optional) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
<span style="display:inline-flex; align-items:center; gap:6px;">
{% if are_cheie_folosita %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
<span class="muted" aria-hidden="true">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=cont">Ia-ti cheia API</a>
<span class="muted" style="font-size:12px; display:block;">
<em>Optional</em> — doar daca trimiti din soft propriu prin API
<a href="/?tab=cont">Cheie API</a>
<span class="muted">(optional)</span>
</span>
</span>
</li>
{# Pas 3: Import primul fisier (esential) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{# Pas 3: Import (esential) — marcat ca pas curent #}
<span style="display:inline-flex; align-items:center; gap:6px;">
{% if are_trimiteri %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
<span class="s-queued" aria-hidden="true" style="font-weight:bold;">&#9679;</span>
{% endif %}
<span>
<a href="/?tab=import">Importa primul fisier</a>
<span class="muted" style="font-size:12px; display:block;">
Incarca un fisier xlsx/csv cu prezentarile de declarat la RAR
<strong>Import</strong> <span class="muted">(incarca fisierul sus)</span>
</span>
</span>
</li>
</ul>
</div>
{% endif %}
{# Rezumat si scurtaturi rapide (mereu vizibile) #}
<div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Bun venit la Gateway RAR AUTOPASS</h2>
<p class="muted" style="margin:0 0 10px; font-size:13px;">
Importa fisiere din tab-ul <strong><a href="/?tab=import">Import</a></strong>,
urmareste coada in tab-ul <strong><a href="/?tab=coada">Coada</a></strong>
si rezolva mapari lipsa in tab-ul <strong><a href="/?tab=mapari">Mapari</a></strong>.
</p>
<div style="display:flex; gap:12px; flex-wrap:wrap; font-size:13px;">
<a href="/?tab=coada" class="cardlink">Coada submissions</a>
<a href="/?tab=import" class="cardlink">Import fisier nou</a>
<a href="/?tab=mapari" class="cardlink">Mapari operatii</a>
</div>
</div>
</div>
{% endif %}
{# === Subordonat: ajutor rapid pe un rand discret === #}
<div style="margin-top:10px; font-size:13px; color:var(--muted);
display:flex; gap:16px; flex-wrap:wrap; align-items:center;">
<span>Ajutor:</span>
<a href="/?tab=coada">Trimiteri</a>
<a href="/?tab=mapari">Mapari</a>
<a href="/?tab=nomenclator">Coduri RAR</a>
</div>
</div>

View File

@@ -1,14 +1,55 @@
<div id="coada-section">
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>
<h2 style="font-size:15px; margin:0;">Trimiteri catre RAR</h2>
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
</span>
</div>
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
<!-- Filtre (US-009): reincarca tabelul; poll-ul re-trimite filtrul curent prin hx-include -->
<form id="filtre-trimiteri"
hx-get="/_fragments/submissions"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div>
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label>
<select id="f-status" name="status">
<option value="">toate</option>
<option value="queued">in asteptare</option>
<option value="sent">declarate la RAR</option>
<option value="needs_mapping">lipsa cod</option>
<option value="needs_data">date incomplete</option>
<option value="error">eroare</option>
<option value="sending">se trimite</option>
</select>
</div>
<div>
<label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label>
<input id="f-vehicul" type="text" name="vehicul" placeholder="ex. B123 sau VIN" style="max-width:180px;">
</div>
<div>
<label for="f-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label>
<input id="f-data-de" type="date" name="data_de">
</div>
<div>
<label for="f-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label>
<input id="f-data-pana" type="date" name="data_pana">
</div>
<button type="submit">Filtreaza</button>
</form>
<div id="submissions-wrap"
hx-get="/_fragments/submissions" hx-trigger="load, every 10s"
hx-include="#filtre-trimiteri" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>
</div>
<!-- Panou dedicat pentru detaliul trimiterii (NU inline in tabel: poll-ul de 10s
din tabel ar sterge un expand inline). Gol pana la click pe un rand. -->
<div id="trimitere-detaliu"></div>
</div>

View File

@@ -1,14 +1,19 @@
<div id="mapari-section" class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Mapari de rezolvat</h2>
<div id="mapari-section">
{% if message %}
<div class="flash">{{ message }}</div>
<div class="flash" style="margin-bottom:12px;">{{ message }}</div>
{% endif %}
<!-- ============================================================ -->
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">De rezolvat</h2>
{% if not pending %}
<div class="empty">
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
<a href="/?tab=import">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
@@ -58,4 +63,120 @@
</form>
{% endfor %}
{% endif %}
</div>
<!-- ============================================================ -->
<!-- Sectiunea 2: Mapari operatii salvate (operations_mapping) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Mapari operatii salvate</h2>
{% if not saved_mappings %}
<div class="empty">
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau auto-send si salveaza;
la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
</p>
{% for m in saved_mappings %}
<form class="maprow" hx-post="/mapari/salvate" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ m.cod_op_service }}</strong></div>
<div class="muted" style="font-size:12px;">
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
</div>
</div>
<div class="mapcol">
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ m.cod_op_service }}">
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == m.cod_prestatie %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<label class="chk"><input type="checkbox" name="auto_send" value="true"
{% if m.auto_send %}checked{% endif %}> auto-send</label>
</div>
<div class="mapcol" style="display:flex; gap:6px;">
<button type="submit">Salveaza</button>
</div>
<div class="mapcol">
<button type="submit"
hx-post="/mapari/salvate/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi maparea pentru {{ m.cod_op_service }}?"
style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</div>
</form>
{% endfor %}
{% endif %}
</div>
<!-- ============================================================ -->
<!-- Sectiunea 3: Formate de coloane salvate (column_mappings) -->
<!-- ============================================================ -->
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
{% if not column_formats %}
<div class="empty">
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
se retine aici si se reaplica automat la fisierele cu acelasi antet.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat (nu suprascrie).
</p>
{% for f in column_formats %}
<div class="maprow" style="align-items:flex-start;">
<div class="mapcol grow">
<div style="font-size:13px; margin-bottom:4px;">
<strong>{{ f.columns | length }} coloane recunoscute</strong>
{% if f.format_data %}
<span class="pill" title="format data">data: {{ f.format_data }}</span>
{% endif %}
</div>
<div class="muted" style="font-size:12px;">
{% for col, camp in f.mappings.items() %}
<span class="sugg">{{ col }}</span> &rarr; {{ camp }}{% if not loop.last %}; {% endif %}
{% endfor %}
</div>
</div>
<form class="mapcol" style="display:flex; gap:6px; align-items:center;"
hx-post="/formate-coloane/editeaza" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
<button type="submit">Salveaza data</button>
</form>
<form class="mapcol"
hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
hx-confirm="Stergi acest format de coloane?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</form>
</div>
{% endfor %}
{% endif %}
</div>
</div>

View File

@@ -156,12 +156,15 @@
{% elif row.flags %}
{{ row.flags[0] }}
{% elif row.errors %}
{%- set e = row.errors[0] -%}
{# US-008: arata MOTIVUL (mesajul de validare), nu numele campului #}
{%- for e in row.errors -%}
{%- if e is mapping -%}
{{ e.values() | list | first }}
{{ e.get('message') or e.get('msg') or (e.values() | list | first) }}
{%- else -%}
{{ e }}
{%- endif -%}
{%- if not loop.last %}; {% endif -%}
{%- endfor -%}
{% endif %}
</td>
<td style="text-align:center;">
@@ -242,7 +245,8 @@
</form>
<div style="padding:8px 0 4px;">
<a href="/" class="muted" style="font-size:13px;">Incarca alt fisier</a>
<a href="#" class="muted" style="font-size:13px;"
hx-get="/_import/reset" hx-target="#import-section" hx-swap="outerHTML">Incarca alt fisier</a>
</div>
</div>

View File

@@ -13,62 +13,52 @@
</div>
{% endif %}
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
<!-- Rand 1: doua bife binare + ultima autentificare -->
<div style="display:flex; gap:28px; flex-wrap:wrap; align-items:center; font-size:14px;">
<!-- Starea worker (Trimitere automata) -->
<div>
<div class="muted" style="font-size:12px;">{{ worker_lbl[0] }}</div>
<div class="{{ worker_lbl[2] }}" title="{{ worker_lbl[1] }}">
{{ worker_lbl[0].split(':')[1].strip() if ':' in worker_lbl[0] else worker_lbl[0] }}
</div>
{% if worker_lbl[1] %}
<div class="muted" style="font-size:11px; max-width:220px;">{{ worker_lbl[1] }}</div>
{# Bifa: glifa (✓/✗) + culoare + text — accesibil (nu doar culoare, design review) #}
{% macro bifa(ok, text, tip) %}
<span title="{{ tip }}" style="display:inline-flex; align-items:center; gap:7px;">
{% if ok %}
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">&#10003;</span>
<span class="s-sent">{{ text }}</span>
{% else %}
<span class="s-error" aria-hidden="true" style="font-weight:bold;">&#10007;</span>
<span class="s-error">{{ text }}</span>
{% endif %}
</span>
{% endmacro %}
{{ bifa(worker_ok, worker_lbl[0], worker_lbl[1]) }}
{{ bifa(rar_ok, rar_lbl[0], rar_lbl[1]) }}
<span style="display:inline-flex; align-items:center; gap:6px;">
<span class="muted">{{ eticheta_ultima_auth }}:</span>
<span>{{ last_login }}</span>
</span>
</div>
<!-- Legatura RAR -->
<div>
<div class="muted" style="font-size:12px;">Legatura RAR</div>
<div class="{{ rar_lbl[2] }}" title="{{ rar_lbl[1] }}">
{{ rar_lbl[0].split(':')[1].strip() if ':' in rar_lbl[0] else rar_lbl[0] }}
</div>
{% if rar_lbl[1] and rar_lbl[2] != 's-sent' %}
<div class="muted" style="font-size:11px; max-width:220px;">{{ rar_lbl[1] }}</div>
{% endif %}
</div>
<!-- Ultima autentificare RAR -->
<div>
<div class="muted" style="font-size:12px;">{{ eticheta_ultima_auth }}</div>
<div>{{ last_login or '—' }}</div>
</div>
<!-- In asteptare (queued) -->
<div>
<div class="muted" style="font-size:12px;">In asteptare</div>
<div class="s-queued">{{ counts_queued }}</div>
</div>
<!-- Declarate la RAR (sent) -->
<div>
<div class="muted" style="font-size:12px;">Declarate la RAR</div>
<div class="s-sent">{{ counts_sent }}</div>
</div>
<!-- Rand 2: contoare coada -->
<div style="margin-top:10px; display:flex; gap:20px; flex-wrap:wrap; font-size:14px;">
<span><span class="muted">In asteptare:</span> <span class="s-queued">{{ counts_queued }}</span></span>
<span><span class="muted">Declarate la RAR:</span> <span class="s-sent">{{ counts_sent }}</span></span>
<span><span class="muted">Blocate:</span>
<span class="{{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</span>
</span>
</div>
<!-- Defalcare blocate pe motiv (doar daca exista) -->
{% if blocate_defalcat %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div style="font-size:12px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
<div style="font-size:13px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
<div style="display:flex; gap:16px; flex-wrap:wrap;">
{% for eticheta, n in blocate_defalcat %}
{% if n > 0 %}
<div>
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span>
<span class="muted" style="font-size:12px; margin-left:4px;">({{ n }})</span>
<span class="muted" style="font-size:13px; margin-left:4px;">({{ n }})</span>
{% if eticheta[1] %}
<div class="muted" style="font-size:11px; max-width:200px;">{{ eticheta[1] }}</div>
<div class="muted" style="font-size:13px; max-width:240px;">{{ eticheta[1] }}</div>
{% endif %}
</div>
{% endif %}

View File

@@ -1,26 +1,55 @@
{% if rows %}
<div class="tablewrap">
<table>
<thead><tr><th>#</th><th>Stare</th><th>idPrezentare</th><th>HTTP RAR</th><th>Retry</th><th>Actualizat</th><th>Motiv</th></tr></thead>
<thead><tr>
<th>#</th>
<th>Stare</th>
<th>Vehicul</th>
<th>Operatie</th>
<th>Data prestatie</th>
<th>Nr. prezentare RAR</th>
<th>Actualizat</th>
<th>Motiv</th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.id }}</td>
<td><span class="pill s-{{ r.status }}">{{ r.status }}</span></td>
<tr id="trimitere-row-{{ r.id }}"
hx-get="/_fragments/trimitere/{{ r.id }}"
hx-target="#trimitere-detaliu"
hx-swap="innerHTML"
style="cursor:pointer;"
title="Click pentru detaliul complet">
<td class="muted">{{ r.id }}</td>
<td><span class="pill {{ r.stare_css }}">{{ r.stare_text }}</span></td>
<td>
{{ r.prez.vehicul_nr }}
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
<span class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</span>
{% endif %}
</td>
<td>{{ r.prez.operatie }}</td>
<td>{{ r.prez.data_prestatie }}</td>
<td>{{ r.id_prezentare or '—' }}</td>
<td>{{ r.rar_status_code or '—' }}</td>
<td>{{ r.retry_count }}</td>
<td class="muted">{{ r.updated_at }}</td>
<td class="muted">{{ (r.rar_error or '')[:80] }}</td>
<td class="muted" style="white-space:normal; max-width:280px;">{{ r.motiv }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif filtru_activ %}
<div class="empty">
Nimic pe filtrul curent.
<a href="#"
onclick="var f=document.getElementById('filtre-trimiteri'); if(f) f.reset(); return true;"
hx-get="/_fragments/submissions" hx-target="#submissions-wrap" hx-swap="innerHTML">
sterge filtrele
</a>
</div>
{% else %}
<div class="empty">
Nicio trimitere inca —
<a href="/?tab=import">incepe cu Import</a>
<a href="/?tab=acasa">incepe cu un import</a>
sau trimite o prezentare prin API.
</div>
{% endif %}

View File

@@ -0,0 +1,98 @@
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--accent);">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
<button type="button" style="margin-left:auto; background:var(--card); color:var(--muted); border-color:var(--line);"
onclick="document.getElementById('trimitere-detaliu').innerHTML='';">
Inchide
</button>
</div>
{% if stare_subtext %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
{% endif %}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:12px 24px;">
<div><div class="muted" style="font-size:12px;">Numar inmatriculare</div><div>{{ prez.vehicul_nr }}</div></div>
<div><div class="muted" style="font-size:12px;">VIN (serie sasiu)</div><div style="word-break:break-all;">{{ prez.vin }}</div></div>
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }}</div></div>
<div><div class="muted" style="font-size:12px;">Cod RAR</div><div>{{ prez.cod }}</div></div>
<div><div class="muted" style="font-size:12px;">Data prestatie</div><div>{{ prez.data_prestatie }}</div></div>
<div><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
<div><div class="muted" style="font-size:12px;">Nr. prezentare RAR</div><div>{{ id_prezentare or '—' }}</div></div>
<div><div class="muted" style="font-size:12px;">Cod HTTP RAR</div><div>{{ rar_status_code or '—' }}</div></div>
<div><div class="muted" style="font-size:12px;">Reincercari</div><div>{{ retry_count }}</div></div>
<div><div class="muted" style="font-size:12px;">Creat</div><div>{{ created_at }}</div></div>
<div><div class="muted" style="font-size:12px;">Actualizat</div><div>{{ updated_at }}</div></div>
<div><div class="muted" style="font-size:12px;">Urmatoarea incercare</div><div>{{ next_attempt_at }}</div></div>
</div>
{% if motiv %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div class="muted" style="font-size:12px;">Motiv</div>
<div>{{ motiv }}</div>
</div>
{% endif %}
{% if rar_error %}
<details style="margin-top:10px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">Mesaj tehnic RAR (integral)</summary>
<pre style="white-space:pre-wrap; word-break:break-all; font-size:12px; margin:6px 0 0; color:var(--muted);">{{ rar_error }}</pre>
</details>
{% 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 %}
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
<h3 style="font-size:14px; margin:0 0 8px;">Corecteaza si re-trimite</h3>
{% if corectie_msg %}
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:10px;"
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %}
<form hx-post="/trimitere/{{ id }}/corecteaza"
hx-target="#trimitere-detaliu" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 16px;">
{% macro camp(nume, eticheta, valoare, tip='text') %}
<div>
<label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
<input id="c-{{ nume }}" type="{{ tip }}" name="{{ nume }}" value="{{ valoare }}"
style="width:100%; {% if err_map.get(nume) %}border-color:var(--err);{% endif %}"
{% if err_map.get(nume) %}aria-invalid="true"{% endif %}>
{% if err_map.get(nume) %}
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
{% endif %}
</div>
{% 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) }}
</div>
<div style="margin-top:10px;">
<button type="submit">Salveaza si re-pune in coada</button>
</div>
</form>
</div>
{% endif %}
</div>
<script>
(function() {
/* Vizibilitate (design review): scroll la panou + evidentiaza randul selectat. */
var panou = document.getElementById('trimitere-detaliu');
if (panou) panou.scrollIntoView({behavior: 'smooth', block: 'nearest'});
document.querySelectorAll('tr[id^="trimitere-row-"]').forEach(function(tr) {
tr.style.outline = '';
});
var rand = document.getElementById('trimitere-row-{{ id }}');
if (rand) rand.style.outline = '2px solid var(--accent)';
})();
</script>

View File

@@ -22,24 +22,25 @@
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
{% 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) %}
<a id="{{ tab_elem_id }}"
role="tab"
href="/?tab={{ tab_id }}"
aria-selected="{{ 'true' if active_tab == tab_id else 'false' }}"
aria-controls="tab-panel"
{% if badge %}aria-label="{{ tab_label }}, {{ badge }} necesita atentie"{% endif %}
class="tab-link{% if active_tab == tab_id %} tab-activ{% endif %}"
tabindex="{{ '0' if active_tab == tab_id else '-1' }}"
hx-get="/_fragments/{{ tab_id }}"
hx-target="#tab-panel"
hx-swap="innerHTML"
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}</a>
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}{% if badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ badge }}</span>{% endif %}</a>
{% endfor %}
</div>

View File

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

View File

@@ -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: <data>`; 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 —
`&#10003;` (✓) pentru activ/OK, `&#10007;` (✗) 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 `&#10003;`/`&#10007;` + 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_dataqueued 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 importcommitcoada + 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.

View File

@@ -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"<td>{sub_a}</td>" in r.text
assert f"<td>{sub_b}</td>" 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"<td>{sub_b}</td>" in r.text
assert f"<td>{sub_a}</td>" 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):

View File

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

123
tests/test_web_badge.py Normal file
View File

@@ -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 <a ...>...</a> al tab-ului cu id-ul dat."""
m = re.search(rf'<a id="{elem_id}".*?</a>', 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

205
tests/test_web_corectie.py Normal file
View File

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

100
tests/test_web_dashboard.py Normal file
View File

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

141
tests/test_web_filtrare.py Normal file
View File

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

View File

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

View File

@@ -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):
"""

View File

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

116
tests/test_web_mapari_ui.py Normal file
View File

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

View File

@@ -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 "&#10003;" 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)"
# ============================================================

View File

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

129
tests/test_web_status.py Normal file
View File

@@ -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 "&#10003;" 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 "&#10007;" 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)."

View File

@@ -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 "<th>HTTP RAR</th>" 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

View File

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