feat(5.12): modal editare + cont obligatoriu la import; design.md + PRD 5.13 revizuit (/autoplan)

5.12 (livrat): editare in modal a randurilor de preview, cont obligatoriu inainte de
import, formular editare extras (_form_editare, _editare_preview_modal), plus suita de
teste aferenta (preview edit/compact, mapare op, form editare, signup, admin panel).

Design + planificare:
- docs/design.md: sistem de design (tokeni, breakpoints, scara control, componente, a11y).
- docs/prd/prd-5.12-* si prd-5.13-* (5.13 cu raport /autoplan: CEO+Design+Eng, audit trail).

Curatare: sterse PNG-urile de test/mockup temporare din radacina.

Nota: implementarea CSS 5.13 (responsive compact + sistem butoane) NU e inca facuta —
planul revizuit cere refactorul testelor fragile din test_web_responsive.py INAINTE de CSS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-27 18:52:20 +00:00
parent 283299ff20
commit b26dbb79e1
44 changed files with 4852 additions and 305 deletions

View File

@@ -2,6 +2,15 @@
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
## Din PRD 5.12 (2026-06-26)
- [ ] **Mai multi utilizatori per firma (flux de invitatie / alaturare la cont)** — azi CUI e unic, deci
al doilea email care vrea pe aceeasi firma e respins la signup (nu exista flux de „alatura-te firmei").
`users` permite tehnic mai multe loginuri per `account_id`, dar nu exista UI. Daca apare nevoia reala
(mai multe persoane dintr-o firma), construieste: admin-ul firmei invita un email SAU al doilea cere
acces si admin-ul aproba; membership pe `account_id`. Decizie user (2026-06-26): in 5.12 ramane
**1 firma = 1 cont = 1 login** + mesaj prietenos la CUI duplicat (US-001); acest flux = livrabila separata.
## Din /autoplan PRD 5.11 (2026-06-26)
- [ ] **E2E smoke de first-run ca poarta de release** — codifica scriptul de dogfooding
@@ -16,3 +25,16 @@ Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand de
remapare inline (fara gate de preview). Daca apar integratori reali, evalueaza un throttle
„primele N auto-trimiteri pe o regula text noua cer confirmare" sau un kill-switch per cont.
(CEO F5/F6, severitate critical ca risc, dar pre-launch exposure ~zero acum.)
## Din /autoplan PRD 5.13 (2026-06-27)
- [ ] **Filtre de data 2x2 pe mobil** — Azi/7zile/30zile/Custom stivuiesc full-width (4 randuri)
pe mobil; grid 2x2 ar fi mai compact. Imbunatatire viitoare. (Design, low.)
- [ ] **Sprite `<use href="#...">` pentru iconitele Lucide**`act_btn` randeaza SVG inline pe
fiecare rand (bloat DOM pe toate viewporturile, ascuns pe desktop). Optimizare deferata; inline
acum (P5 simplu > optim prematur). (Eng §1, medium.)
- [ ] **"Eroare/Eroare" la nivel routes.py/labels.py** — guard-ul de template (pill-only cand
eticheta==stare) acopera cazul vizibil; curatarea logicii de continut ramane debt. (Design §2.)
- [ ] **Validare premisa "utilizare mobil reala"** — inainte de orice extindere responsive viitoare,
confirma device-mix-ul (analytics/cerere user). Daca ~95% desktop, nu mai investi in cardificare
mobil. (CEO F1, high — premisa nedovedita acum.)

View File

@@ -18,31 +18,49 @@ import sqlite3
def _norm_cui(cui: str | None) -> str | None:
"""trim + upper; sir gol -> None (tratat ca „fara CUI")."""
"""trim + upper; sir gol -> ValueError daca e string gol, None daca e None."""
if cui is None:
return None
cui = cui.strip().upper()
return cui or None
if cui == "":
raise ValueError("CUI gol (un CUI trebuie sa fie un sir nevid)")
return cui
def _norm_email(email: str | None) -> str | None:
"""trim + lower; sir gol -> ValueError daca e string gol, None daca e None."""
if email is None:
return None
email = email.strip().lower()
if email == "":
raise ValueError("email gol (un email trebuie sa fie un sir nevid)")
return email
def create_account(
conn: sqlite3.Connection, name: str, cui: str | None = None, active: bool = True
conn: sqlite3.Connection,
name: str,
cui: str | None = None,
email: str | None = None,
active: bool = True,
) -> int:
"""Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1).
`name` gol/whitespace -> ValueError. `cui` se normalizeaza (trim+upper); un CUI
deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial
`name` gol/whitespace -> ValueError. `cui` se normalizeaza (trim+upper); sir gol -> ValueError.
`email` se normalizeaza (trim+lower); sir gol -> ValueError.
Un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial
`ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta.
"""
name = (name or "").strip()
if not name:
raise ValueError("name gol (un cont are nevoie de nume)")
cui = _norm_cui(cui)
email = _norm_email(email)
try:
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
cur = conn.execute(
"INSERT INTO accounts (name, cui, active, status) VALUES (?, ?, ?, ?)",
(name, cui, 1 if active else 0, "active" if active else "pending"),
"INSERT INTO accounts (name, cui, email, active, status) VALUES (?, ?, ?, ?, ?)",
(name, cui, email, 1 if active else 0, "active" if active else "pending"),
)
except sqlite3.IntegrityError:
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
@@ -54,6 +72,21 @@ def create_account(
return int(cur.lastrowid or 0)
def account_is_complete(row: sqlite3.Row | dict) -> bool:
"""Returneaza True daca contul are companie (name), email si CUI ne-goale.
Contul de sistem id=1 (default) este EXCEPTAT si returneaza intotdeauna True
(nu are sens sa-l marcam ca incomplet — nu e un cont de client).
"""
acct_id = row["id"] if "id" in row.keys() else None
if acct_id == 1:
return True
name = (row["name"] or "").strip()
cui = (row["cui"] or "").strip()
email_val = (row["email"] or "").strip() if "email" in row.keys() else ""
return bool(name and cui and email_val)
def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
Cont inexistent -> ValueError.
@@ -121,7 +154,7 @@ def list_accounts(conn: sqlite3.Connection) -> list[dict]:
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
(stergere soft -> invizibile in panou)."""
rows = conn.execute(
"SELECT id, name, cui, active, status, created_at FROM accounts "
"SELECT id, name, cui, email, active, status, created_at FROM accounts "
"WHERE status != 'deleted' ORDER BY id"
).fetchall()
return [dict(r) for r in rows]

View File

@@ -130,6 +130,7 @@ def _resolve_row_for_preview(
override: dict[str, Any] | None = None,
valid_codes: set[str] | None = None,
text_rules: list[dict] | None = None,
reviewed: bool = False,
) -> dict[str, Any]:
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
@@ -220,8 +221,10 @@ def _resolve_row_for_preview(
# Validare continut
errors = validate_prezentare(mapped)
if all_flags:
# needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala
if all_flags and not reviewed:
# needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala.
# Daca reviewed=True (operatorul a confirmat explicit valorile in modal), sarim
# acest return si continuam spre ok/needs_data (US-007, PRD 5.12).
return {
"resolved_status": "needs_review",
"resolved": mapped,
@@ -337,7 +340,10 @@ def apply_row_override(
new_override = _merge_override(current, fields)
enc = encrypt_creds(new_override) if new_override else None
conn.execute("UPDATE import_rows SET override_json=? WHERE id=?", (enc, row["rid"]))
# D#9 (PRD 5.12): resetam reviewed=0 la orice schimbare de valoare — operatorul
# trebuie sa reconfirme dupa editare. NU conditionam pe reviewed curent: orice override
# (chiar si revert la valoarea initiala) anuleaza confirmarea implicita.
conn.execute("UPDATE import_rows SET override_json=?, reviewed=0 WHERE id=?", (enc, row["rid"]))
return new_override
@@ -932,10 +938,30 @@ def commit_import(
if batch["status"] == "committed":
raise HTTPException(status_code=409, detail="batch deja comis")
# Incarca randurile cu stare ok sau needs_review
# D#8 (PRD 5.12): gate commit derivat din DB `reviewed` pe AMBELE canale.
# API: reviewed_rows pastrat (contract stabil) dar seteaza reviewed=1 in DB inainte
# de interogare. Randurile needs_review cu reviewed=1 sunt incluse in comit.
if req.reviewed_rows:
conn.execute("BEGIN IMMEDIATE")
try:
for idx in req.reviewed_rows:
conn.execute(
"UPDATE import_rows SET reviewed=1 "
"WHERE batch_id=? AND row_index=? AND resolved_status='needs_review'",
(import_id, idx),
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
# Incarca randurile ok + needs_review confirmate (reviewed=1)
ok_rows_db = conn.execute(
"SELECT row_index, raw_json, override_json, resolved_status FROM import_rows "
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index",
"SELECT row_index, raw_json, override_json, resolved_status, reviewed "
"FROM import_rows "
"WHERE batch_id=? AND (resolved_status='ok' OR "
"(resolved_status='needs_review' AND reviewed=1)) "
"ORDER BY row_index",
(import_id,),
).fetchall()
@@ -947,8 +973,6 @@ def commit_import(
# Decripteaza randurile ok
ok_rows: list[dict] = []
ok_indices: list[int] = []
review_indices: set[int] = set()
for r in ok_rows_db:
try:
@@ -957,28 +981,12 @@ def commit_import(
continue
except Exception:
continue
if r["resolved_status"] == "ok":
ok_rows.append({"row_index": r["row_index"], "data": row_data,
"override": _override_of(r), "status": "ok"})
ok_indices.append(r["row_index"])
elif r["resolved_status"] == "needs_review":
review_indices.add(r["row_index"])
# needs_review bifate explicit (atestare pe valori)
confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices]
for idx in confirmed_review:
# Gaseste randul needs_review si il adauga la ok_rows
for r in ok_rows_db:
if r["row_index"] == idx and r["resolved_status"] == "needs_review":
try:
row_data = decrypt_creds(r["raw_json"])
if row_data:
ok_rows.append({"row_index": idx, "data": row_data,
"override": _override_of(r), "status": "needs_review"})
ok_indices.append(idx)
except Exception:
pass
ok_rows.append({
"row_index": r["row_index"],
"data": row_data,
"override": _override_of(r),
"status": r["resolved_status"],
})
# Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok
n_total_ok = len(ok_rows)

View File

@@ -61,6 +61,11 @@ class Settings(BaseSettings):
# False (dev): cookie fara Secure, functioneaza pe HTTP.
session_https_only: bool = False
# --- Contact suport (US-001, PRD 5.12) ---
# Email/canal de contact afisat in mesaje catre utilizatori (ex. CUI duplicat la signup).
# Nesetat -> fallback la formularea generica fara canal concret.
support_email: str | None = None
# --- Notificare email admin la signup ---
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP).
smtp_host: str | None = None

View File

@@ -81,6 +81,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
"CHECK (on_unmapped_error_default IN (0, 1))"
)
if "email" not in acc_cols:
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT")
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
@@ -105,6 +108,12 @@ def _migrate(conn: sqlite3.Connection) -> None:
irows_cols = {r["name"] for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()}
if "override_json" not in irows_cols:
conn.execute("ALTER TABLE import_rows ADD COLUMN override_json TEXT")
if "reviewed" not in irows_cols:
# Marcaj confirmare umana (US-007, PRD 5.12). NU intra in payload/idempotenta.
# NOT NULL DEFAULT 0: valoare clara (0=neconfirmat), fara ambiguitate NULL vs 0.
conn.execute(
"ALTER TABLE import_rows ADD COLUMN reviewed INTEGER NOT NULL DEFAULT 0"
)
# Index batch_id pe submissions (poate lipsi pe DB veche)
existing_idx = {r["name"] for r in conn.execute(

View File

@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cui TEXT,
email TEXT, -- email canonic de contact al firmei (US-001, PRD 5.12); nullable pt. conturi legacy
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
-- Stare de ciclu de viata explicita (5.5). Superset al lui `active`: mentinem invariantul
-- active=1 <=> status='active' (vezi accounts.set_status / set_active). Worker gate-uieste pe status.
@@ -125,6 +126,7 @@ CREATE TABLE IF NOT EXISTS import_rows (
row_index INTEGER NOT NULL,
raw_json TEXT NOT NULL, -- PII criptat (Fernet, ca submissions)
override_json TEXT, -- patch CANONIC editat in preview, criptat Fernet (3.6, Approach B); NULL = fara editare
reviewed INTEGER NOT NULL DEFAULT 0, -- US-007 (PRD 5.12): 0=neconfirmat, 1=confirmat de operator; NU intra in payload/idempotenta
resolved_status TEXT NOT NULL DEFAULT 'pending'
CHECK (resolved_status IN (
'pending','ok','needs_mapping','needs_data',

View File

@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..accounts import list_accounts, set_active, set_status, delete_account
from ..accounts import account_is_complete, list_accounts, set_active, set_status, delete_account
from ..config import get_settings
from ..db import get_connection
from ..web.csrf import get_csrf_token, verify_csrf
@@ -48,6 +48,8 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
accounts = list_accounts(conn)
emails = _emails_by_account(conn)
for acct in accounts:
# Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului
acct["is_complete"] = account_is_complete(acct)
acct["email"] = emails.get(acct["id"])
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
@@ -85,6 +87,12 @@ def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
for aid in ids:
try:
if action == "activate":
# Gate US-002: nu activam conturi fara identitate completa (companie+email+CUI)
acct_row = conn.execute(
"SELECT id, name, cui, email FROM accounts WHERE id=?", (aid,)
).fetchone()
if acct_row and not account_is_complete(acct_row):
continue # sarim activarea — contul incomplet ramane pending
set_status(conn, aid, "active")
elif action == "block":
set_status(conn, aid, "blocked")

View File

@@ -69,6 +69,16 @@ async def signup_post(
name=name, cui=cui, email=email,
), status_code=422)
# CUI obligatoriu la signup (US-001, PRD 5.12)
cui_norm = cui.strip().upper() if cui else ""
if not cui_norm:
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error="CUI-ul firmei este obligatoriu.",
name=name, cui=cui, email=email,
), status_code=422)
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
conn = get_connection()
@@ -76,10 +86,43 @@ async def signup_post(
conn.execute("BEGIN IMMEDIATE")
try:
is_first = count_admins(conn) == 0
account_id = create_account(conn, name, cui.strip() or None, active=False)
account_id = create_account(conn, name, cui=cui_norm, email=email, active=False)
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
api_key = create_api_key(conn, account_id)
conn.execute("COMMIT")
except ValueError as exc:
conn.execute("ROLLBACK")
exc_msg = str(exc)
# Ordinea conteaza: verifica EMAIL inainte de CUI (ambele contin 'deja folosit').
# create_user ridica exact "email deja folosit"; create_account ridica "CUI X e deja folosit".
if "email deja folosit" in exc_msg:
# Email duplicat -> mesaj specific emailului (T3, D#14-email)
error_msg = (
"Acest email este deja folosit. "
"Daca ai deja cont, autentifica-te."
)
elif "deja folosit" in exc_msg or "IntegrityError" in exc_msg:
# CUI duplicat -> mesaj prietenos, NU mesajul tehnic cu 'activate --account' (T3, D#14)
settings = get_settings()
if settings.support_email:
error_msg = (
f"Aceasta firma (CUI {cui_norm}) e deja inregistrata. "
f"Cere accesul de la administratorul contului sau contacteaza suportul: "
f"{settings.support_email}"
)
else:
error_msg = (
f"Aceasta firma (CUI {cui_norm}) e deja inregistrata. "
f"Cere accesul de la administratorul contului."
)
else:
error_msg = exc_msg
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error=error_msg,
name=name, cui=cui, email=email,
), status_code=422)
except Exception as exc:
conn.execute("ROLLBACK")
return _TMPL.TemplateResponse(request, "signup.html", _ctx(

View File

@@ -174,13 +174,16 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
"""
from ..mapping import account_or_default
from ..accounts import account_is_complete as _acct_is_complete
acct = account_or_default(account_id)
# Pas 1: are credentiale RAR configurate?
# Pas 1: are credentiale RAR configurate? + metadate cont (pentru banner incomplet)
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
# Banner cont incomplet (US-002): contul nu are companie + email + CUI complete
cont_incomplet = not _acct_is_complete(row) if row else False
# Pas 3: are cel putin un submission (trimis sau in coada)?
row_sub = conn.execute(
@@ -214,6 +217,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
# Acasa include caseta de upload -> are nevoie de csrf_token
"csrf_token": get_csrf_token(request),
# Banner ne-blocant (US-002): contul nu are identitate completa (companie+email+CUI)
"cont_incomplet": cont_incomplet,
}
@@ -266,8 +271,11 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
"""Randeaza panoul Cont ca string HTML."""
from ..mapping import account_or_default
acct = account_or_default(account_id)
row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
row = conn.execute(
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
account_meta = _fetch_account_meta(conn, acct)
return templates.get_template("_cont.html").render({
"request": request,
"csrf_token": get_csrf_token(request),
@@ -276,6 +284,9 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
"creds_mesaj": None,
"creds_eroare": None,
"rot_eroare": None,
"account_meta": account_meta,
"date_firma_mesaj": None,
"date_firma_eroare": None,
})
@@ -1865,7 +1876,8 @@ def _web_compute_preview(
return "Batch de import inexistent sau inaccesibil."
raw_rows_db = conn.execute(
"SELECT row_index, raw_json, override_json FROM import_rows WHERE batch_id=? ORDER BY row_index",
"SELECT row_index, raw_json, override_json, reviewed FROM import_rows "
"WHERE batch_id=? ORDER BY row_index",
(import_id,),
).fetchall()
if not raw_rows_db:
@@ -1874,6 +1886,7 @@ def _web_compute_preview(
# Decripteaza randurile + override-urile editate
rows: list[dict[str, Any]] = []
overrides: list[dict[str, Any]] = []
reviewed_flags: list[bool] = []
for r in raw_rows_db:
try:
row_data = decrypt_creds(r["raw_json"]) or {}
@@ -1885,6 +1898,7 @@ def _web_compute_preview(
except Exception:
ov = None
overrides.append(ov or {})
reviewed_flags.append(bool(r["reviewed"]))
col_names = list(rows[0].keys()) if rows else []
sig = _signature(col_names)
@@ -1952,6 +1966,7 @@ def _web_compute_preview(
override=overrides[i] or None,
valid_codes=valid_codes,
text_rules=text_rules,
reviewed=reviewed_flags[i],
)
key: str | None = None
@@ -2161,12 +2176,14 @@ async def web_upload_import(
if sugg:
fuzzy_suggestions[col] = sugg
_sample = parsed.rows[:3]
return templates.TemplateResponse("_mapcoloane.html", {
"request": request,
"import_id": batch_id_int,
"filename": filename,
"columns": parsed.columns,
"sample_rows": parsed.rows[:3],
"sample_rows": _sample,
"prima_inregistrare": _sample[0] if _sample else None,
"fuzzy_suggestions": fuzzy_suggestions,
"canonical_fields": _CANONICAL_FIELDS,
"format_data": None,
@@ -2379,23 +2396,63 @@ def _render_preview_rand(
})
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Intra in mod editare pe un rand de preview (randul devine FORM propriu)."""
@router.get("/_import/{import_id}/rand/{row_index}/editare-modal", response_class=HTMLResponse)
def web_rand_editare_modal(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Fragment editare rand preview in modalul global (#detaliu-modal-body).
US-006 (PRD 5.12): inlocuieste editarea inline (tr.preview-edit) care cauza
colapsare vizuala si eroare JS la Anuleaza (R5). Randeaza _editare_preview_modal.html.
Campurile vehicul/data/odometru sunt preluate din starea curenta (resolved + override).
"""
account_id = require_login(request)
conn = get_connection()
try:
result, row = _preview_one_row(conn, import_id, account_id, row_index)
if row is None or isinstance(result, str):
raise HTTPException(status_code=404, detail="rand de import inexistent")
return _render_preview_rand(
request, import_id=import_id, row=row, editing=True,
include_oob=False, summary=result["summary"],
)
res = row.get("resolved") or {}
err_map: dict[str, str] = {}
fix_map: dict[str, str] = {}
for e in (row.get("errors") or []):
if isinstance(e, dict) and e.get("field"):
err_map[e["field"]] = e.get("message") or e.get("msg") or ""
if e.get("fix"):
fix_map[e["field"]] = e["fix"]
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
"row_index": row_index,
"csrf_token": get_csrf_token(request),
"vin": res.get("vin") or "",
"stare_css": row.get("stare_css") or "",
"stare_eticheta": row.get("stare_eticheta") or "",
"form_nr": res.get("nr_inmatriculare") or "",
"form_vin": res.get("vin") or "",
"form_data": res.get("data_prestatie") or "",
"form_odo_final": str(res.get("odometru_final") or ""),
"form_odo_initial": str(res.get("odometru_initial") or ""),
"err_map": err_map,
"fix_map": fix_map,
"vin_context": res.get("vin") or "",
"btn_label": "Salveaza",
"message": None,
# T2 (US-007): butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
"is_needs_review": row.get("resolved_status") == "needs_review",
})
finally:
conn.close()
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Fragment editare rand preview in modal — alias al /editare-modal.
US-006: editarea inline eliminata; ruta pastrata pentru compatibilitate cu
apeluri externe / teste existente. Delega la web_rand_editare_modal.
"""
return web_rand_editare_modal(request, import_id, row_index)
@router.get("/_import/{import_id}/rand/{row_index}", response_class=HTMLResponse)
def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Iese din mod editare (Anuleaza) — re-randeaza randul read-only + OOB contoare."""
@@ -2415,11 +2472,17 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Persista override (mutatie pura) + re-randeaza DOAR randul.
"""Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal.
Statusul e rederivat prin `_resolve_row_for_preview`. Swap pe rand + OOB contoare.
Daca raman erori de continut pe camp, randul ramane in editare cu valorile pastrate
si mesajul pe campul vinovat."""
US-006 (PRD 5.12):
- Succes: raspuns cu OOB pe rand (#preview-row-N) + OOB contoare (#preview-rezumat) +
header HX-Trigger-After-Settle:inchideModal (modalul se inchide, OOB se aplica).
- Eroare camp: re-randeaza _editare_preview_modal.html cu valorile introduse + erorile
per-camp; modalul RAMANE DESCHIS; NU se emite inchideModal.
INVARIANT CRITIC (R2): submissions NEATINS — override-only pe import_rows.override_json,
NU re-queue, NU insereaza in submissions.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
@@ -2430,7 +2493,7 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
conn = get_connection()
try:
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
# pastreaza randul + valorile la 4xx/5xx).
# pastreaza formularul modal cu valorile la 4xx/5xx).
apply_row_override(
conn, import_id=import_id, account_id=account_id,
row_index=row_index, fields=fields,
@@ -2443,15 +2506,120 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
if isinstance(e, dict) and e.get("field")
]
if field_errors:
return _render_preview_rand(
request, import_id=import_id, row=row, editing=True,
include_oob=True, summary=result["summary"],
message="Mai sunt valori invalide — corecteaza campurile marcate.",
)
return _render_preview_rand(
request, import_id=import_id, row=row, editing=False,
include_oob=True, summary=result["summary"],
)
# Eroare de validare: re-randeaza formularul in modal cu valorile introduse.
# Modalul RAMANE DESCHIS (fara HX-Trigger-After-Settle:inchideModal).
res = row.get("resolved") or {}
err_map: dict[str, str] = {}
fix_map: dict[str, str] = {}
for e in field_errors:
if e.get("field"):
err_map[e["field"]] = e.get("message") or e.get("msg") or ""
if e.get("fix"):
fix_map[e["field"]] = e["fix"]
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
"row_index": row_index,
"csrf_token": get_csrf_token(request),
"vin": res.get("vin") or "",
"stare_css": row.get("stare_css") or "",
"stare_eticheta": row.get("stare_eticheta") or "",
# Valorile DIN FORM (pentru ca userul sa vada ce a introdus):
"form_nr": str(form.get("nr_inmatriculare") or res.get("nr_inmatriculare") or ""),
"form_vin": str(form.get("vin") or res.get("vin") or ""),
"form_data": str(form.get("data_prestatie") or res.get("data_prestatie") or ""),
"form_odo_final": str(form.get("odometru_final") or res.get("odometru_final") or ""),
"form_odo_initial": str(form.get("odometru_initial") or res.get("odometru_initial") or ""),
"err_map": err_map,
"fix_map": fix_map,
"vin_context": res.get("vin") or "",
"btn_label": "Salveaza",
"message": "Mai sunt valori invalide — corecteaza campurile marcate.",
})
# Succes: OOB swap rand + contoare + inchideModal.
# Continut primar (swap in #detaliu-modal-body): stub invizibil + script recalc.
# OOB: <tr> actualizat + rezumat + contor.
# HX-Trigger-After-Settle: inchideModal → base.html JS inchide modalul.
oob_content = templates.get_template("_preview_rand.html").render({
"request": request,
"import_id": import_id,
"row": row,
"editing": False,
"oob_tr": True,
"include_oob": True,
"summary": result["summary"],
"message": None,
"csrf_token": get_csrf_token(request),
})
html_body = '<div style="display:none;"></div>' + oob_content
resp = HTMLResponse(content=html_body)
resp.headers["HX-Trigger-After-Settle"] = "inchideModal"
return resp
finally:
conn.close()
@router.post("/_import/{import_id}/rand/{row_index}/confirma-review", response_class=HTMLResponse)
async def web_confirma_review(
request: Request,
import_id: int,
row_index: int,
) -> HTMLResponse:
"""Confirma explicit valorile unui rand needs_review → seteaza reviewed=1 in DB.
US-007 (PRD 5.12), T2: butonul 'Confirma valorile' din modal seteaza reviewed=1
pentru randul indicat. La recalcul (_web_compute_preview), randul cu reviewed=1
si fara erori de validare reale devine ok (nu mai e blocat de flaguri ambigue).
Guard: 404 cross-account (scoping JOIN), 409 batch committed.
Raspunde OOB (rand + contoare) + HX-Trigger-After-Settle:inchideModal,
identic cu web_editeaza_rand la succes.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
acct = account_or_default(account_id)
conn = get_connection()
try:
# Scoping: JOIN verifica ca batch-ul apartine contului si ca randul exista.
# Acelasi tipar ca apply_row_override (404 cross-account, 409 committed).
row_db = conn.execute(
"SELECT r.id AS rid, b.status AS bstatus "
"FROM import_rows r JOIN import_batches b ON b.id = r.batch_id "
"WHERE b.id=? AND b.account_id=? AND r.row_index=?",
(import_id, acct, row_index),
).fetchone()
if not row_db:
raise HTTPException(status_code=404, detail="rand de import inexistent")
if row_db["bstatus"] == "committed":
raise HTTPException(status_code=409, detail="batch deja comis; confirmarea nu mai are efect")
# Seteaza reviewed=1 — marcaj separat, NU camp de continut (NU intra in override_json/payload).
conn.execute("UPDATE import_rows SET reviewed=1 WHERE id=?", (row_db["rid"],))
# Recalculeaza preview: randul cu reviewed=1 + fara erori reale devine ok.
result, row = _preview_one_row(conn, import_id, account_id, row_index)
if row is None or isinstance(result, str):
raise HTTPException(status_code=404, detail="rand de import inexistent")
# OOB: rand actualizat + rezumat + contor ok + inchideModal (identic cu succes editeaza)
oob_content = templates.get_template("_preview_rand.html").render({
"request": request,
"import_id": import_id,
"row": row,
"editing": False,
"oob_tr": True,
"include_oob": True,
"summary": result["summary"],
"message": None,
"csrf_token": get_csrf_token(request),
})
html_body = '<div style="display:none;"></div>' + oob_content
resp = HTMLResponse(content=html_body)
resp.headers["HX-Trigger-After-Settle"] = "inchideModal"
return resp
finally:
conn.close()
@@ -2499,6 +2667,82 @@ async def web_mapare_operatie(
conn.close()
@router.post("/_import/{import_id}/mapare-operatii", response_class=HTMLResponse)
async def web_mapare_operatii(
request: Request,
import_id: int,
) -> HTMLResponse:
"""Un singur POST salveaza toate maparile de operatii (US-004).
Primeste perechi (cod_op_service, cod_prestatie) ca liste paralele din un singur
<form> cu un select per operatie. Apeleaza save_mapping pentru fiecare pereche cu
cod ales (reuse EXACT, fara logica noua). Perechile cu cod_prestatie gol sunt ignorate.
D#12: per-item — cod invalid -> skip + sumar, restul salvate. O singura recompute
_web_compute_preview + re-randare #import-section la final.
CSRF + scoped sesiune (404 cross-account via _web_compute_preview) + guard committed 409.
"""
account_id = require_login(request)
conn = get_connection()
try:
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
# Guard batch committed (409)
acct = account_or_default(account_id)
batch = conn.execute(
"SELECT id, status FROM import_batches WHERE id=? AND account_id=?",
(import_id, acct),
).fetchone()
if not batch:
raise HTTPException(status_code=404, detail="batch de import inexistent sau inaccesibil")
if batch["status"] == "committed":
raise HTTPException(status_code=409, detail="batch deja comis; maparea nu mai are efect")
# Extrage listele paralele din form (getlist pentru valori multiple cu acelasi name)
ops_list = form.getlist("cod_op_service")
codes_list = form.getlist("cod_prestatie")
salvate: list[str] = []
sarite_invalide: list[str] = []
for cod_op_service, cod_prestatie in zip(ops_list, codes_list):
cod_op_service = str(cod_op_service or "").strip()
cod_prestatie = str(cod_prestatie or "").strip().upper()
# Ignora perechile fara cod ales (selectul ramas pe "— alege cod RAR —")
if not cod_op_service or not cod_prestatie:
continue
# Validare per-item (D#12): cod invalid -> skip + sumar, nu all-or-nothing
exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
).fetchone()
if not exists:
sarite_invalide.append(f"{cod_op_service} ({cod_prestatie} necunoscut)")
continue
save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send=False)
salvate.append(f"{cod_op_service} -> {cod_prestatie}")
# Compune mesajul sumar
parts: list[str] = []
if salvate:
parts.append(f"Salvate: {', '.join(salvate)}.")
if sarite_invalide:
parts.append(f"Coduri necunoscute ignorate: {', '.join(sarite_invalide)}.")
message = " ".join(parts) if parts else None
error = bool(sarite_invalide) and not salvate
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, message=message, error=error, **result
))
finally:
conn.close()
@router.get("/_import/reset", response_class=HTMLResponse)
def web_import_reset(request: Request) -> HTMLResponse:
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
@@ -2532,14 +2776,11 @@ async def web_confirma_import(
except (ValueError, TypeError):
n_confirmat = 0
# Randuri needs_review bifate explicit
reviewed_rows: set[int] = set()
for v in form.getlist("reviewed_rows"):
if isinstance(v, str):
try:
reviewed_rows.add(int(v))
except (ValueError, TypeError):
pass
# US-007: reviewed_rows (checkboxe vechi) NU mai este sursa de adevar pentru gate-ul
# de commit pe canalul web. Gate-ul este derivat din DB import_rows.reviewed (D#8).
# Randurile needs_review confirmate de operator via /confirma-review au resolved_status='ok'
# in DB (recalculat de _web_compute_preview), asa ca interogarea de mai jos include corect
# TOATE randurile gata de trimis.
confirmed_by = str(form.get("confirmed_by") or "").strip() or None
@@ -2559,10 +2800,19 @@ async def web_confirma_import(
request, message="Acest batch a fost deja comis."
))
# Incarca randurile cu stare ok si needs_review
# Incarca DOAR randurile ok din DB.
# D#8 (PRD 5.12): gate derivat din DB reviewed — randurile needs_review confirmate
# de operator via /confirma-review au resolved_status='ok' (recalculat de
# _web_compute_preview in calea /confirma-review). Randurile needs_review
# neconfirmate sunt excluse (nu au reviewed=1 => raman needs_review in DB).
# Fallback defensiv: includes si needs_review cu reviewed=1 (daca DB a ramas
# neactualizat din vreun motiv — ex. restart intre confirma-review si preview refresh).
ok_rows_db = conn.execute(
"SELECT row_index, raw_json, override_json, resolved_status FROM import_rows "
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index",
"SELECT row_index, raw_json, override_json, resolved_status, reviewed "
"FROM import_rows "
"WHERE batch_id=? AND (resolved_status='ok' OR "
"(resolved_status='needs_review' AND reviewed=1)) "
"ORDER BY row_index",
(import_id,),
).fetchall()
@@ -2584,28 +2834,18 @@ async def web_confirma_import(
# Decripteaza si construieste lista de randuri de trimis
to_enqueue: list[dict[str, Any]] = []
review_indices: set[int] = set()
for r in ok_rows_db:
try:
row_data = decrypt_creds(r["raw_json"]) or {}
except Exception:
continue
if r["resolved_status"] == "ok":
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
"override": _override_of(r), "status": "ok"})
elif r["resolved_status"] == "needs_review":
review_indices.add(r["row_index"])
# Adauga randurile needs_review bifate explicit
for r in ok_rows_db:
if r["resolved_status"] == "needs_review" and r["row_index"] in reviewed_rows:
try:
row_data = decrypt_creds(r["raw_json"]) or {}
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
"override": _override_of(r), "status": "needs_review"})
except Exception:
pass
to_enqueue.append({
"row_index": r["row_index"],
"data": row_data,
"override": _override_of(r),
"status": r["resolved_status"],
})
n_total_ok = len(to_enqueue)
@@ -2816,6 +3056,9 @@ def _render_cont(
creds_mesaj: str | None = None,
creds_eroare: str | None = None,
rot_eroare: str | None = None,
account_meta: dict | None = None,
date_firma_mesaj: str | None = None,
date_firma_eroare: str | None = None,
) -> HTMLResponse:
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
return templates.TemplateResponse(
@@ -2827,22 +3070,41 @@ def _render_cont(
creds_mesaj=creds_mesaj,
creds_eroare=creds_eroare,
rot_eroare=rot_eroare,
account_meta=account_meta or {},
date_firma_mesaj=date_firma_mesaj,
date_firma_eroare=date_firma_eroare,
),
)
def _fetch_account_meta(conn, acct: int) -> dict:
"""Intoarce metadatele contului (id, name, cui, email) pentru sectiunea 'Date firma'."""
row = conn.execute(
"SELECT id, name, cui, email FROM accounts WHERE id=?", (acct,)
).fetchone()
if not row:
return {"id": acct, "name": "", "cui": "", "email": ""}
return {
"id": row["id"],
"name": row["name"] or "",
"cui": row["cui"] or "",
"email": row["email"] or "",
}
@router.get("/_fragments/cont", response_class=HTMLResponse)
def fragment_cont(request: Request) -> HTMLResponse:
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR (fara a le expune)."""
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR + date firma."""
account_id = require_login(request)
acct = account_or_default(account_id)
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, are_creds=are_creds)
account_meta = _fetch_account_meta(conn, acct)
return _render_cont(request, are_creds=are_creds, account_meta=account_meta)
finally:
conn.close()
@@ -2863,7 +3125,149 @@ def cont_roteste_cheie(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, api_key=new_key, are_creds=are_creds)
account_meta = _fetch_account_meta(conn, acct)
return _render_cont(request, api_key=new_key, are_creds=are_creds, account_meta=account_meta)
finally:
conn.close()
@router.post("/cont/date-firma", response_class=HTMLResponse)
async def cont_date_firma(request: Request) -> HTMLResponse:
"""Actualizeaza datele firmei (companie, email, CUI) pentru contul din sesiune.
Valideaza campurile (reuse _norm_cui / _norm_email din accounts.py), verifica
unicitatea CUI-ului, actualizeaza accounts.name/email/cui. CSRF enforce.
Scoped pe contul sesiunii (nu poate atinge alt cont).
"""
from ..accounts import _norm_cui, _norm_email
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
acct = account_or_default(account_id)
companie_raw = str(form.get("companie") or "").strip()
email_raw = str(form.get("email") or "")
cui_raw = str(form.get("cui") or "")
# Validare companie
if not companie_raw:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta=account_meta,
date_firma_eroare="Compania (numele firmei) este obligatorie.",
)
# Normalizare si validare email
try:
email_norm = _norm_email(email_raw)
except ValueError as exc:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
date_firma_eroare=f"Email invalid: {exc}",
)
if not email_norm:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
date_firma_eroare="Email-ul de contact este obligatoriu.",
)
# Normalizare si validare CUI
try:
cui_norm = _norm_cui(cui_raw)
except ValueError as exc:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
date_firma_eroare=f"CUI invalid: {exc}",
)
if not cui_norm:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
date_firma_eroare="CUI-ul firmei este obligatoriu.",
)
# Actualizare in DB
conn = get_connection()
try:
try:
conn.execute(
"UPDATE accounts SET name=?, email=?, cui=? WHERE id=?",
(companie_raw, email_norm, cui_norm, acct),
)
except sqlite3.IntegrityError:
# CUI duplicat (index partial unic ux_accounts_cui)
existing = conn.execute(
"SELECT id FROM accounts WHERE cui=? AND id!=?", (cui_norm, acct)
).fetchone()
owner = existing["id"] if existing else "?"
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_norm},
date_firma_eroare=(
f"CUI-ul {cui_norm} este deja folosit de alt cont (id={owner}). "
"Foloseste un CUI diferit sau contacteaza administratorul."
),
)
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
return _render_cont(
request,
are_creds=are_creds,
account_meta=account_meta,
date_firma_mesaj="Datele firmei au fost salvate.",
)
finally:
conn.close()
@@ -2949,12 +3353,14 @@ def cont_rar_creds(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
account_meta = _fetch_account_meta(conn, acct)
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
creds_eroare="Email si parola sunt obligatorii.",
account_meta=account_meta,
)
enc = encrypt_creds({"email": email, "password": parola})
@@ -2964,10 +3370,12 @@ def cont_rar_creds(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(enc, acct),
)
account_meta = _fetch_account_meta(conn, acct)
return _render_cont(
request,
are_creds=True,
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
account_meta=account_meta,
)
finally:
conn.close()

View File

@@ -1,5 +1,18 @@
<div id="acasa-section">
{# === Banner ne-blocant: cont incomplet (US-002) ===
Apare cand accounts.name / email / CUI sunt necompletate (conturi legacy sau create din CLI).
NU blocheaza importul sau uploadul — doar orienteaza operatorul sa completeze datele.
Dispare automat dupa ce contul devine complet (re-render la urmatoarea navigare/reload).
#}
{% if cont_incomplet %}
<div class="card banner" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); margin-bottom:14px; padding:10px 14px; font-size:13px;">
<strong>Completeaza datele firmei (email / CUI).</strong>
Contul tau nu are inca email de contact si CUI configurate.
<a href="/?tab=cont" style="margin-left:6px;">Completeaza acum &rarr;</a>
</div>
{% endif %}
{# === Container colapsabil: stepper + upload intr-un singur element <details> (US-006).
Serverul seteaza atributul `open` din are_trimiteri:
are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS)

View File

@@ -1,6 +1,47 @@
<div class="card" id="card-cont">
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
<!-- Sectiunea: Date firma (US-002) -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Date firma</h3>
{% if date_firma_mesaj %}
<div class="flash" style="margin-bottom:12px;">{{ date_firma_mesaj }}</div>
{% endif %}
{% if date_firma_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ date_firma_eroare }}</div>
{% endif %}
<form hx-post="/cont/date-firma"
hx-target="#card-cont"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Companie</label><br>
<input type="text" name="companie" required
value="{{ account_meta.name or '' }}"
style="width:100%; max-width:340px;"
placeholder="Numele firmei (ex. Service Auto SRL)">
</p>
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Email contact</label><br>
<input type="email" name="email" required
value="{{ account_meta.email or '' }}"
style="width:100%; max-width:340px;"
placeholder="contact@firma.ro">
</p>
<p style="margin:0 0 12px;">
<label style="font-size:13px; color:var(--muted);">CUI (cod unic de identificare)</label><br>
<input type="text" name="cui" required
value="{{ account_meta.cui or '' }}"
style="width:100%; max-width:340px;"
placeholder="RO12345678">
</p>
<button type="submit">Salveaza datele firmei</button>
</form>
</div>
<!-- Sectiunea: Cheia mea API -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Cheia mea API</h3>

View File

@@ -0,0 +1,78 @@
{# _editare_preview_modal.html — fragment de editare rand preview in modalul global.
US-006 (PRD 5.12): swap-uit in #detaliu-modal-body de butonul Editeaza din preview.
US-007 (PRD 5.12): butonul 'Confirma valorile' apare DOAR pe randurile needs_review
(T2): trimite CSRF POST la /confirma-review, inchide modalul via HX-Trigger-After-Settle.
Necesita din context:
import_id — id batch import
row_index — index rand (0-based)
csrf_token — token CSRF
vin — VIN pentru titlu
stare_css — clasa CSS pill (ex. "s-ok")
stare_eticheta — text pill (ex. "Gata de trimis")
message — mesaj de eroare general (None daca nu e)
is_needs_review — True daca randul e in starea needs_review (afiseaza butonul Confirma)
+ variabilele pentru _form_editare.html:
form_nr, form_vin, form_data, form_odo_final, form_odo_initial
err_map, fix_map, vin_context, btn_label
#}
<div class="card" style="border:none; padding:0; margin:0;">
{# Header cu heading accesibil (aria-labelledby al dialogului) #}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">
Editare rand {{ row_index + 1 }}
{% if vin %}<span class="muted" style="font-weight:400; font-size:13px;">· {{ vin }}</span>{% endif %}
</h2>
<span class="pill {{ stare_css }}" style="font-size:11px;">{{ stare_eticheta }}</span>
</div>
{% if message %}
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:12px;"
role="alert">{{ message }}</div>
{% endif %}
<form hx-post="/_import/{{ import_id }}/rand/{{ row_index }}/editeaza"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-disabled-elt="find button"
hx-on::response-error="this.querySelector && this.querySelector('.rand-eroare-banner') && (this.querySelector('.rand-eroare-banner').style.display='block');">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div class="rand-eroare-banner" role="alert"
style="display:none; margin-bottom:10px; padding:8px 12px; border:1px solid var(--err);
background:color-mix(in srgb, var(--err) 12%, var(--card)); border-radius:6px; font-size:13px;">
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
</div>
{% include "_form_editare.html" %}
<div style="margin-top:10px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button type="button"
style="min-height:44px; padding:8px 18px;
background:var(--card); color:var(--muted); border-color:var(--line);"
data-modal-close>Anuleaza</button>
</div>
</form>
{% if is_needs_review %}
{# T2 (US-007): Butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
POST separat (form propriu) la /confirma-review cu CSRF. Raspunsul inchide
modalul via HX-Trigger-After-Settle: inchideModal + swap OOB randul si countorii. #}
<form hx-post="/_import/{{ import_id }}/rand/{{ row_index }}/confirma-review"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-disabled-elt="find button"
style="margin-top:12px; border-top:1px solid var(--line); padding-top:12px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<p class="muted" style="font-size:13px; margin:0 0 8px;">
Valorile sunt corecte si doriesti sa includi acest rand la trimitere la RAR?
</p>
<button type="submit"
style="min-height:44px; padding:8px 18px;
background:var(--ok, #2a7); color:#fff; border-color:transparent;">
Confirma valorile
</button>
</form>
{% endif %}
</div>

View File

@@ -0,0 +1,41 @@
{# _form_editare.html — partial partajat: campurile vehicul/data/odometru.
US-005 (PRD 5.12): extras DRY din _trimitere_detaliu.html; refolosit si de
_preview_rand.html (US-006) pentru editarea randurilor de import in modal.
Inclus cu {% include "_form_editare.html" %} INSIDE un <form> element al
template-ului parinte. Acel parinte pune form-ul, CSRF-ul si orice campuri
suplimentare (ex. select cod_prestatie din _trimitere_detaliu.html).
Necesita din context (setate de parinte inainte de include):
form_nr — valoare curenta nr_inmatriculare
form_vin — valoare curenta vin
form_data — valoare curenta data_prestatie (YYYY-MM-DD sau brut)
form_odo_final — valoare curenta odometru_final
form_odo_initial — valoare curenta odometru_initial
err_map — dict {field_name: mesaj_eroare} (poate fi {})
fix_map — dict {field_name: hint_fix} (poate fi {})
vin_context — string VIN pentru aria-label (poate fi '')
btn_label — eticheta butonului primar (ex. 'Salveaza si retrimite')
#}
{% from "_macros.html" import camp %}
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{{ camp('vin', 'VIN (serie sasiu)', form_vin,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{# Restul campurilor in grila responsiva existenta. #}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:0 16px;">
{{ camp('data_prestatie', 'Data prestatie', form_data, tip='date',
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{{ camp('odometru_final', 'Odometru final', form_odo_final,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
</div>
{# Buton primar parametrizat. #}
<div style="margin-top:14px;">
<button type="submit">{{ btn_label or 'Salveaza' }}</button>
</div>

View File

@@ -4,3 +4,49 @@
Simbolul pastrat (apelat in _mapari.html, _preview_import.html, _trimitere_detaliu.html)
dar intoarce string gol. Coloanele DB raman (default 1, ne-citite pentru hold). #}
{% macro autosend_toggle(form_id='', checked=True, label='') -%}{%- endmacro %}
{# US-005 (PRD 5.12): macro `camp` partajat — extras din _trimitere_detaliu.html si
_preview_rand.html. Suporta tip='date' (calendar nativ, D#10/R3) si tip='text' (default).
Parametri:
nome — name="" al input-ului (si cheie in err_map/fix_map)
eticheta — text pentru label
valoare — valoarea curenta (pre-fill)
tip — type="" al input-ului: 'text' (default) sau 'date' (calendar nativ)
err_map — dict {field_name: mesaj_eroare}; default {}
fix_map — dict {field_name: hint_fix}; default {}
vin_context — string VIN pentru aria-label cu context (default '')
id_prefix — prefix pentru id="" al input-ului (default 'c'; preview poate folosi 'e-N')
#}
{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c') %}
<div style="margin-bottom:10px;">
<label for="{{ id_prefix }}-{{ nome }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
{% if tip == 'date' %}
{# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD.
Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta
(ca sa nu se piarda tacut la submit). #}
{%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%}
<input id="{{ id_prefix }}-{{ nome }}" type="date" name="{{ nome }}"
value="{{ valoare if _dp_ok else '' }}"
style="width:100%; {% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
aria-label="{{ eticheta }}{% if vin_context %} (VIN: {{ vin_context }}){% endif %}"
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
{% if not _dp_ok and valoare %}
<input type="hidden" name="data_prestatie_raw" value="{{ valoare }}">
<span class="camp-fix" style="font-size:12px;">Valoarea originala: {{ valoare }}</span>
{% endif %}
{% else %}
<input id="{{ id_prefix }}-{{ nome }}" type="{{ tip }}" name="{{ nome }}"
value="{{ valoare or '' }}"
style="width:100%; {% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
{% if vin_context %}aria-label="{{ eticheta }} (VIN: {{ vin_context }})"{% endif %}
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
{% endif %}
{% if err_map.get(nome) %}
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nome) }}</div>
{% endif %}
{% if fix_map.get(nome) %}
<span class="camp-fix" style="font-size:12px;">{{ fix_map.get(nome) }}</span>
{% endif %}
</div>
{% endmacro %}

View File

@@ -1,6 +1,8 @@
<div id="import-section">
{% set pas = 2 %}{% include '_stepper.html' %}
{% from '_eroare.html' import card_erori %}
{# prima_inregistrare poate veni din context (web_upload_import) sau derivat din sample_rows #}
{%- set prima_inreg = prima_inregistrare if prima_inregistrare is defined else (sample_rows[0] if sample_rows else none) -%}
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">
Mapare coloane —
@@ -23,6 +25,44 @@
Maparea se retine automat pentru fisiere cu acelasi antet.
</p>
{# Tabel orizontal preview: antet + prima inregistrare (US-003) #}
<div class="tablewrap" style="margin-bottom:16px;">
<table class="preview-antet" style="border-collapse:collapse; font-size:12px; width:100%; min-width:max-content;">
<thead>
<tr>
{% for col in columns %}
<th style="padding:4px 10px; text-align:left; background:var(--card); border:1px solid var(--line);
white-space:nowrap; font-weight:600; font-size:12px; color:var(--ink);">
{{ col }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% if prima_inreg %}
<tr>
{% for col in columns %}
{%- set val = prima_inreg.get(col, '') | string -%}
<td style="padding:4px 10px; border:1px solid var(--line); white-space:nowrap;
font-size:11px; color:var(--muted); max-width:160px; overflow:hidden; text-overflow:ellipsis;"
title="{{ val }}">
{{ val[:40] }}{% if val | length > 40 %}…{% endif %}
</td>
{% endfor %}
</tr>
{% else %}
<tr>
<td colspan="{{ columns | length }}"
style="padding:6px 10px; border:1px solid var(--line); font-size:12px;
color:var(--muted); font-style:italic; text-align:center;">
Antet fara randuri de date
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
hx-target="#import-section"
hx-swap="outerHTML">
@@ -87,12 +127,19 @@
<div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<button type="submit"
style="min-height:44px; padding:10px 24px; font-size:14px;">
{% if not prima_inreg %}disabled aria-disabled="true"{% endif %}
style="min-height:44px; padding:10px 24px; font-size:14px;{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
Salveaza si continua la preview
</button>
{% if not prima_inreg %}
<span style="font-size:12px; color:var(--err);">
Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.
</span>
{% else %}
<span class="muted" style="font-size:12px;">
maparea se retine pentru fisiere cu acelasi antet
</span>
{% endif %}
</div>
</form>

View File

@@ -54,7 +54,8 @@
{% endfor %}
</div>
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload) -->
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload).
US-004: un singur <form> cu un select per operatie + un singur buton Salveaza. -->
{% if unmapped_ops %}
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
@@ -63,51 +64,68 @@
preselectata) si salveaza — randurile blocate trec automat in
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
</p>
{% for e in unmapped_ops %}
{%- set top = e.suggestions[0] if e.suggestions else None -%}
{%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%}
<form class="maprow" hx-post="/_import/{{ import_id }}/mapare-operatie"
hx-target="#import-section" hx-swap="outerHTML"
style="align-items:flex-end;">
<form hx-post="/_import/{{ import_id }}/mapare-operatii"
hx-target="#import-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="randuri blocate">{{ e.blocked }} randuri</span></div>
{% if e.denumire and e.denumire != e.cod_op_service %}
<div class="muted">{{ e.denumire }}</div>
{% endif %}
{% if e.suggestions %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
{% for e in unmapped_ops %}
{%- set top = e.suggestions[0] if e.suggestions else None -%}
{%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%}
<div class="maprow" style="align-items:flex-end; margin-bottom:10px;">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="randuri blocate">{{ e.blocked }} randuri</span></div>
{% if e.denumire and e.denumire != e.cod_op_service %}
<div class="muted">{{ e.denumire }}</div>
{% endif %}
{% if e.suggestions %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mapcol">
<select name="cod_prestatie" aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
<div class="mapcol">
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<button type="submit" style="min-height:44px;">Salveaza</button>
{% endfor %}
<div style="margin-top:12px;">
<button type="submit" style="min-height:44px;">Salveaza maparile</button>
</div>
</form>
{% endfor %}
</div>
{% endif %}
<!-- Banner discoverability (T1, US-007): vizibil cand exista randuri needs_review.
Explica operatorului ca randurile cu 'Verifica valori' nu pleaca la RAR
pana le deschide in modal si apasa 'Confirma valorile'. Dispare via OOB
cand summary.needs_review == 0. -->
<div id="preview-needs-review-banner">
{% if summary.get('needs_review', 0) %}
<div class="banner warn" role="note" aria-live="polite"
style="margin-bottom:12px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;">
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana le deschizi in modal si confirmi in modal
cu butonul <strong>Confirma valorile</strong>.
</div>
{% endif %}
</div>
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
altfel Enter intr-un camp ar declansa trimiterea ireversibila). Bifele
needs_review se asociaza la #confirm-form prin atributul form=. -->
US-007: 8 coloane (coloana de verificare eliminata).
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
<div class="tablewrap tabel-trimiteri">
<table>
<thead>
@@ -119,7 +137,6 @@
<th class="col-data">Data</th>
<th class="col-km">KM final</th>
<th class="col-note">Note</th>
<th class="col-verificat">Verificat?</th>
<th class="col-actiuni">Actiuni</th>
</tr>
</thead>
@@ -164,10 +181,7 @@
style="max-width:80px;"
aria-describedby="n-hint">
<span id="n-hint" class="muted" style="font-size:12px; margin-left:6px;">
(<span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> ok
{% if summary.get('needs_review', 0) %}
+ pana la {{ summary.get('needs_review', 0) }} verificate manual
{% endif %})
(<span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> ok)
</span>
</div>
@@ -226,20 +240,19 @@
return el ? parseInt(el.dataset.ok || '0', 10) : 0;
}
/* Actualizeaza N si bannerul cand se bifeaza needs_review SAU cand se editeaza un rand. */
/* Actualizeaza N dupa editare/confirmare rand (OOB).
US-007: reviewed_rows (checkboxe) eliminate; N = randurile ok din DB,
actualizate via OOB (#preview-ok-count[data-ok]) dupa /confirma-review sau /editeaza. */
function updateN() {
var checked = document.querySelectorAll('input[name="reviewed_rows"]:checked').length;
var total = getOk() + checked;
var total = getOk();
var inp = document.getElementById('n-confirmat');
var disp = document.getElementById('n-display');
var btn = document.getElementById('confirm-btn');
/* Nu re-activa confirm cat un rand e in editare (mutual-exclusion). */
var editing = document.querySelector('tr[data-editing="1"]') !== null;
if (inp) inp.value = total;
if (disp) disp.textContent = total;
var hintOk = document.getElementById('n-hint-ok');
if (hintOk) hintOk.textContent = getOk();
if (btn) btn.disabled = (total === 0) || editing;
if (hintOk) hintOk.textContent = total;
if (btn) btn.disabled = (total === 0);
}
/* Filtrare randuri dupa stare.

View File

@@ -1,11 +1,13 @@
{#
_preview_rand.html — un singur rand de preview import.
Doua moduri:
- display (editing falsy): <tr> normal cu 9 coloane in format .tabel-trimiteri.
- edit (editing truthy): <tr class="preview-edit"> (display:block) cu un singur
<td> ce contine un FORM PROPRIU (NU #confirm-form). Escapa grila table-layout:fixed.
Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section.
La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob).
US-006 (PRD 5.12): editarea inline (tr.preview-edit + mutual-exclusion script)
a fost eliminata. Butonul Editeaza deschide MODALUL global (#detaliu-modal-body).
Parametri:
editing — ELIMINAT (ignorat, pastrat pentru compatibilitate apeluri vechi)
include_oob — True: randeaza OOB rezumat + contor + script recalc (swap dupa save)
oob_tr — True: adauga hx-swap-oob pe <tr> insusi (pentru raspunsul POST succes)
summary — dict cu contoarele per status
Campuri pre-computate de _web_compute_preview (NOT din template raw):
row.prez — prezentare_din_payload(resolved): vehicul_nr, vin_scurt,
@@ -16,87 +18,10 @@
#}
{%- set res = row.resolved -%}
{%- set status = row.resolved_status -%}
{% if editing %}
{%- set err_map = {} -%}
{%- set fix_map = {} -%}
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- if e.get('fix') -%}{%- set _ = fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endif -%}{%- endfor -%}
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}" data-editing="1"
class="preview-edit">
<td data-eticheta="" style="padding:0; border:none;">
<form class="rand-editare"
hx-post="/_import/{{ import_id }}/rand/{{ row.row_index }}/editeaza"
hx-target="#preview-row-{{ row.row_index }}"
hx-swap="outerHTML"
hx-indicator="#rand-spinner-{{ row.row_index }}"
hx-disabled-elt="find button"
hx-on::response-error="this.querySelector('.rand-eroare-banner').style.display='block';"
style="padding:12px; background:rgba(91,141,239,.06); border-radius:4px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
<strong style="font-size:13px;">Editare rand {{ row.row_index + 1 }}</strong>
<span class="pill {{ row.stare_css }}" style="font-size:11px;">{{ row.stare_eticheta }}</span>
</div>
{% if message %}
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:10px;"
role="alert">{{ message }}</div>
{% endif %}
<div class="rand-eroare-banner" role="alert"
style="display:none; margin-bottom:10px; padding:8px 12px; border:1px solid var(--err);
background:color-mix(in srgb, var(--err) 12%, var(--card)); border-radius:6px; font-size:13px;">
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
</div>
{% macro camp(nume, eticheta, valoare, tip='text') %}
<div>
<label for="e-{{ row.row_index }}-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
<input id="e-{{ row.row_index }}-{{ nume }}" type="{{ tip }}" name="{{ nume }}" value="{{ valoare or '' }}"
style="width:100%; {% if err_map.get(nume) %}border-color:var(--err);{% endif %}"
aria-label="{{ eticheta }} — randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin') or '' }})"
{% 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 %}
{% if fix_map.get(nume) %}
<span class="camp-fix">{{ fix_map.get(nume) }}</span>
{% endif %}
</div>
{% endmacro %}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 16px;">
{{ camp('nr_inmatriculare', 'Numar inmatriculare', res.get('nr_inmatriculare')) }}
{{ camp('vin', 'VIN (serie sasiu)', res.get('vin')) }}
{{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', res.get('data_prestatie')) }}
{{ camp('odometru_final', 'Odometru final', res.get('odometru_final')) }}
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', res.get('odometru_initial')) }}
</div>
<div style="margin-top:10px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button type="submit" style="min-height:44px; padding:8px 18px;">Salveaza</button>
<button type="button" style="min-height:44px; padding:8px 18px;
background:var(--card); color:var(--muted); border-color:var(--line);"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}"
hx-target="#preview-row-{{ row.row_index }}" hx-swap="outerHTML">Anuleaza</button>
<span id="rand-spinner-{{ row.row_index }}" class="htmx-indicator muted" style="font-size:13px;">
se salveaza…
</span>
</div>
</form>
</td>
</tr>
<script>
(function() {
/* Mutual-exclusion: cat un rand e in editare, dezactiveaza confirm + alte Editeaza. */
var btn = document.getElementById('confirm-btn');
if (btn) { btn.disabled = true; btn.title = 'Termina editarea randului inainte de a trimite.'; }
document.querySelectorAll('.btn-editeaza').forEach(function(b) { b.disabled = true; });
})();
</script>
{% else %}
{%- set disp_fix_map = {} -%}
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') and e.get('fix') -%}{%- set _ = disp_fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endfor -%}
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
{% if oob_tr %}hx-swap-oob="outerHTML:#preview-row-{{ row.row_index }}"{% endif %}
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
<td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td>
<td class="col-stare" data-eticheta="Stare">
@@ -105,7 +30,7 @@
<td class="col-vehicul" data-eticheta="Vehicul">
{{ row.prez.vehicul_nr }}
{% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %}
<div class="muted" style="font-size:12px;">{{ row.prez.vin_scurt }}</div>
<div class="muted" style="font-size:12px; white-space:nowrap;">{{ row.prez.vin_scurt }}</div>
{% endif %}
{# Fix-uri de validare pe vehicul #}
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
@@ -140,24 +65,13 @@
{{ row.nota_umana or '' }}
{% endif %}
</td>
<td class="col-verificat" data-eticheta="Verificat?" style="text-align:center;">
{% if status == 'needs_review' %}
<label class="chk" style="min-height:44px; justify-content:center; cursor:pointer;"
title="Bifat inseamna ca ai verificat valorile si le incluzi in trimitere">
<input type="checkbox" form="confirm-form" name="reviewed_rows" value="{{ row.row_index }}"
onchange="window.updateN && window.updateN()"
aria-label="Verificat — randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
verif.
</label>
{% endif %}
</td>
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
{% if status not in ('already_sent', 'duplicate_in_file') %}
<button type="button" class="btn-editeaza"
style="min-height:44px; padding:6px 14px; font-size:13px;
background:transparent; border-color:var(--line); color:var(--ink);"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare"
hx-target="#preview-row-{{ row.row_index }}" hx-swap="outerHTML"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare-modal"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
aria-label="Editeaza randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
Editeaza
</button>
@@ -165,7 +79,7 @@
</td>
</tr>
{% if include_oob %}
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #}
{# OOB: actualizeaza rezumatul, contorul, bannerul needs_review dupa save/confirma-review. #}
{% set status_labels = [
('ok','Gata de trimis'), ('needs_review','Verifica valori'), ('needs_mapping','Cod RAR lipsa'),
('needs_data','Date incomplete'), ('already_sent','Deja trimis'), ('duplicate_in_file','Duplicat in fisier')] %}
@@ -177,6 +91,19 @@
{% endfor %}
</div>
<span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
{# Banner discoverability: OOB swap dupa confirmare/editare → dispare cand needs_review==0. #}
<div id="preview-needs-review-banner" hx-swap-oob="true">
{% if summary.get('needs_review', 0) %}
<div class="banner warn" role="note" aria-live="polite"
style="margin-bottom:12px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;">
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana le deschizi in modal si confirmi in modal
cu butonul <strong>Confirma valorile</strong>.
</div>
{% endif %}
</div>
<script>
(function() {
/* Editare incheiata: re-activeaza confirm + butoanele Editeaza, recalculeaza N.
@@ -192,4 +119,3 @@
})();
</script>
{% endif %}
{% endif %}

View File

@@ -82,6 +82,12 @@
{% if editabil %}
{% set err_map = {} %}
{% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %}
{# fix_map gol pentru Trimiteri (fix-hints vin din preview, nu din corectii de trimitere). #}
{% set fix_map = {} %}
{# vin_context pentru aria-label cu context VIN (D#6). #}
{%- set vin_context = form_vin -%}
{# btn_label pentru butonul primar al partial-ului. #}
{%- set btn_label = 'Salveaza si retrimite' -%}
{% if corectie_msg %}
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin:0 0 12px;"
@@ -90,30 +96,19 @@
{# Erori fara camp (field None) nu dispar silentios in editare —
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #}
Erori cu camp raman afisate per-camp de macro-ul `camp` din _form_editare.html. #}
{% for e in erori_3n if not e.field %}
<div class="s-error" style="font-size:13px; margin:0 0 10px;" role="alert">{{ e.problema }}</div>
{% endfor %}
{% macro camp(nume, eticheta, valoare, tip='text') %}
<div style="margin-bottom:10px;">
<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 %}
<form hx-post="/trimitere/{{ id }}/corecteaza"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #}
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else).
RAMANE in _trimitere_detaliu.html (D#5 — logica specifica acestui modal). #}
{% if nomenclator_rar %}
<div style="margin:0 0 12px;">
<label for="c-cod-prestatie" class="muted" style="font-size:12px; display:block;">Operatie RAR (cod prestatie)</label>
@@ -139,7 +134,8 @@
{% endif %}
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #}
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent.
RAMANE in _trimitere_detaliu.html (D#5). #}
{% if prez.op_service_cod %}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie service</div>
@@ -147,22 +143,8 @@
</div>
{% endif %}
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
{{ camp('vin', 'VIN (serie sasiu)', form_vin) }}
{# Restul campurilor in grila. #}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:0 16px;">
{{ 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>
{# === Actiune primara conditionata de stare. needs_data/needs_mapping
-> „Salveaza si retrimite" pe /corecteaza. UN SINGUR buton primar per stare. === #}
<div style="margin-top:14px;">
<button type="submit">Salveaza si retrimite</button>
</div>
{# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY (US-005). === #}
{% include "_form_editare.html" %}
</form>
{% else %}
{# Context read-only pentru randuri ne-editabile (sent/sending/queued/error). #}

View File

@@ -60,7 +60,13 @@
{% if v == 'delete' %}onsubmit="return confirm('Stergi acest cont? (stergere soft)');"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
{% if v == 'activate' and not acct.is_complete %}
<button type="submit"{% if cls == 'danger' %} class="danger"{% endif %}
disabled
title="Completeaza datele firmei (companie + email + CUI) inainte de activare">{{ label }}</button>
{% else %}
<button type="submit"{% if cls == 'danger' %} class="danger"{% endif %}>{{ label }}</button>
{% endif %}
</form>
{% endfor %}
</div>

View File

@@ -345,15 +345,28 @@
@media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { display:none; }
}
/* Tableta (7681024px): header compact fara suprapuneri.
Grila 3-coloane se pastreaza; logo si titlu mai mici; versiunea ascunsa
(informatia secundara elibereaza spatiu in celula dreapta: comutator tema +
hamburger raman vizibili). min-height:92px din regula de baza e resetat —
inaltimea header-ului e determinata de continut, nu de un prag fix. */
@media (min-width:768px) and (max-width:1024px) {
header { min-height:0; padding:10px 16px; gap:6px; }
.brand-logo { height:44px; }
header h1 { font-size:16px; }
/* Versiunea (ex. "v0.9.3") este informatie secundara pe tableta:
ascunsa pentru a elibera spatiu in celula dreapta. */
.header-right > .muted { display:none; }
}
/* === Preview import: coloane extra fata de tabelul Trimiteri.
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat).
US-007: 8 coloane (coloana de verificare manuala eliminata).
Suma latimi fixe: col-id(48) + col-stare(104) + col-data(104) +
col-km(76) + col-note(176) + col-verificat(80) + col-actiuni(92) = 680px.
Restul (~600px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
col-km(76) + col-note(176) + col-actiuni(92) = 600px.
Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
.tabel-trimiteri .col-km { width:76px; }
.tabel-trimiteri .col-note { width:176px; }
.tabel-trimiteri .col-verificat{ width:80px; }
.tabel-trimiteri .col-actiuni { width:92px; }
/* Randul de editare inline iese din grila table-layout:fixed (display:block),
astfel formularul nu e constrans de latimile coloanelor individuale.
@@ -814,9 +827,12 @@
// Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder
// inainte ca raspunsul fragmentului sa fie swap-uit in corp.
// Trateaza atat .trimitere-row (Trimiteri) cat si .btn-editeaza (preview import)
// → open() instaleaza inert pe <main>, focus-trap si readuce focusul la inchidere (US-006).
document.body.addEventListener('htmx:beforeRequest', function(evt) {
var elt = evt.detail && evt.detail.elt;
if (elt && elt.classList && elt.classList.contains('trimitere-row')) open(elt);
if (!elt || !elt.classList) return;
if (elt.classList.contains('trimitere-row') || elt.classList.contains('btn-editeaza')) open(elt);
});
// Dupa swap-ul fragmentului (sau re-render corectie/mapare): muta focusul in modal.
body.addEventListener('htmx:afterSettle', function() {

View File

@@ -50,8 +50,8 @@
<input type="text" name="name" value="{{ name or '' }}" required style="width:100%;">
</p>
<p>
<label>CUI <span style="color:var(--muted);font-size:12px;">(optional)</span></label><br>
<input type="text" name="cui" value="{{ cui or '' }}" style="width:100%;">
<label>CUI <span style="color:var(--err)">*</span></label><br>
<input type="text" name="cui" value="{{ cui or '' }}" required style="width:100%;">
</p>
<p>
<label>Email <span style="color:var(--err)">*</span></label><br>

File diff suppressed because one or more lines are too long

232
docs/design.md Normal file
View File

@@ -0,0 +1,232 @@
# design.md — Sistemul de design Gateway RAR AUTOPASS
Sursa de adevar pentru deciziile vizuale ale aplicatiei web. **Orice plan de design
(`/plan-design-review`, `/design-consultation`, `/design-review`) si orice modificare
de UI trebuie sa porneasca de aici.** Unde un mockup sau o propunere difera de acest
document, documentul are dreptate (sau se actualizeaza explicit, intr-un commit separat).
Limba UI: romana, fara diacritice in cod/atribute tehnice, cu diacritice acceptate in
textul vizibil (fontul are `latin-ext`). Fara emoji.
CSS-ul traieste inline in `app/web/templates/base.html` (un singur `<style>`). Nu exista
build step. Tokenii de mai jos sunt variabile CSS reale definite acolo.
---
## 1. Principii
1. **Compact, nu inghesuit.** Densitate mare de informatie utila, dar cu ritm si spatiu.
Pe ecrane mici aratam ESENTIALUL, nu tot ce incape pe desktop. Restul intra in detaliu
(modal) sau in linii secundare mici.
2. **Compactarea e si pentru desktop.** Cand o componenta e mai lizibila compacta (ex.
wizard-ul de import), forma compacta se aplica pe toate latimile, nu doar pe mobil.
3. **Mobile-first ca verificare, nu ca scuza.** Orice ecran trebuie sa fie complet
utilizabil la 360px latime, fara scroll orizontal de pagina si fara text rupt vertical.
4. **Starea prin text + culoare, niciodata doar culoare** (accesibilitate; pill-uri cu
eticheta umana, glife ✓/✗ cu text).
5. **O singura zona de actiune dominanta pe ecran.** Un singur buton primar vizibil per
context (ex. „Trimite la RAR"). Restul sunt secundare/ghost.
6. **Tinte de atins generoase pe touch, sobre pe desktop.** Vezi scara de control.
---
## 2. Tokeni
### 2.1 Culoare (variabile CSS, 4 teme)
Paleta e definita pe `:root` (dark, default) si suprascrisa pe `[data-theme="light|petrol"]`.
`auto` se rezolva la light/dark dupa `prefers-color-scheme`. **Nu folosi culori hardcodate;
foloseste mereu variabilele.** Pentru tente, `color-mix(in srgb, var(--x) N%, transparent|var(--card))`.
| Token | Rol | dark | light | petrol |
|-------|-----|------|-------|--------|
| `--bg` | fundal pagina | `#0f1218` | `#f5f7fa` | `#0e1416` |
| `--card` | suprafata card/meniu | `#181c24` | `#ffffff` | `#161e20` |
| `--ink` | text principal | `#e6e9ef` | `#1a1d24` | `#e6e9ef` |
| `--muted` | text secundar | `#8b93a7` | `#5c6473` | `#8b93a7` |
| `--line` | borduri/separatoare | `#262b36` | `#e2e5ea` | `#232c2e` |
| `--accent` | actiune primara / link | `#2E74D6` | `#1F66C9` | `#0E7C7B` |
| `--ok` | succes / trimis | `#2FBF8F` | `#15803d` | `#2FBF8F` |
| `--warn` | atentie / de verificat | `#E0A93B` | `#b45309` | `#E0A93B` |
| `--err` | eroare / distructiv | `#E05D5D` | `#dc2626` | `#E05D5D` |
Accentul light (`#1F66C9`) e ales pentru contrast AA pe alb (5.51:1). Orice text colorat
pe `--card` trebuie sa ramana >= 4.5:1 in toate cele 3 palete.
### 2.2 Tipografie
Font: **IBM Plex Sans** (UI), **IBM Plex Mono** (VIN, coduri, ID-uri). Self-hosted, `latin-ext`
pentru diacritice, `font-display:swap`. Greutati disponibile: 400, 500, 700.
Scara (px / weight) — folosita consecvent, nu inventa marimi noi:
| Rol | size | weight | note |
|-----|------|--------|------|
| Titlu pagina (header) | 20 (desktop) / 17 (mobil) | 700 | letter-spacing -.01em |
| Titlu sectiune / card | 15 | 600 | `h2.sec` |
| Subtitlu / `h3` | 14 | 600 | |
| Corp / controale | 14 | 400/500 | inputuri, butoane |
| Eticheta camp, link card | 13 | 400/500 | `.cardlink`, label form |
| Secundar / meta | 12 | 400 | text muted, sub-linii |
| Micro (coduri, badge) | 11 | 500/700 | mono pentru coduri |
Numerele tabulare: `font-variant-numeric: tabular-nums` pe tabele (aliniere coloane).
Coduri/VIN/ID: `font-family: "IBM Plex Mono"`.
### 2.3 Spatiere
Scara 4px: **4, 6, 8, 10, 12, 14, 16, 20, 24**. Padding card desktop `16px 20px`, mobil `16px`.
Gap intre carduri `1416px`. Gap intre controale pe o linie `812px`.
### 2.4 Radius
| Valoare | Uz |
|---------|-----|
| `6px` | controale: butoane, input, select |
| `78px` | carduri-rand, meniuri, butoane icon |
| `10px` | carduri de sectiune |
| `12px` | modal (desktop) |
| `99px` | pill-uri, badge-uri, bara de progres |
### 2.5 Elevatie
Plat implicit (border `1px solid var(--line)`). Umbra DOAR pentru elemente plutitoare:
meniuri/kebab `0 8px 24px rgba(0,0,0,.18)`, modal `0 16px 48px rgba(0,0,0,.35)`.
---
## 3. Breakpoints
Un singur prag conceptual mobil la **768px**; un prag de densitate la **1024px**.
| Interval | Numit | Regula |
|----------|-------|--------|
| `>= 1024px` | desktop | layout complet; aplica si compactarile globale (wizard) |
| `7681024px` | tableta | **card-uri** pentru tabelele actionabile (preview, mapari), 2 pe rand; tabelele dense read-only raman cu scroll contained |
| `< 768px` | mobil | un card pe rand, o coloana, tinte touch 44px |
CSS custom properties NU functioneaza in `@media`; pragul se scrie literal
(`@media (max-width:767px)`, `@media (max-width:1024px)`). Reutilizeaza aceste praguri,
nu introduce altele noi.
---
## 4. Scara de control (tinte de atins)
| Context | min-height | Uz |
|---------|-----------|-----|
| Touch (`< 768px`) | **44px** | orice buton/link/select interactiv |
| Desktop standard | **36px** | butoane, icon-btn, cardlink, intrari meniu |
| Compact (desktop) | **32px** | kebab summary, butoane pager, pill-cat |
Pe desktop nu fortam 44px peste tot (devine greoi); pe mobil da. Latimea butoanelor:
**auto, nu full-width**, cu exceptia butonului primar de actiune dintr-o bara dedicata
(ex. „Trimite la RAR" in bara sticky, „Salveaza si continua").
---
## 5. Componente
### 5.1 Butoane — sistem unificat
Patru variante. Toate: `font:inherit` (IBM Plex Sans), `font-weight:500`, `border-radius:6px`,
`padding:8px 14px` (desktop), tinta conform scarii de control. Tranzitie `filter/background .15s`.
| Varianta | Clasa | Fundal | Text | Bordura | Uz |
|----------|-------|--------|------|---------|-----|
| Primar | `.btn` (default `<button>`) | `--accent` | `#fff` | `--accent` | actiunea dominanta |
| Secundar | `.btn-secondary` | transparent | `--ink` | `--line` | actiuni neutre (Editeaza, Filtreaza) |
| Ghost | `.btn-ghost` | transparent | `--accent` | transparent | actiuni tertiare/linkuri-actiune |
| Distructiv | `.btn-danger` | transparent | `--err` | `--err` | Sterge; hover → fundal `--err`, text `#fff` |
**Iconite in butoane:** label text + iconita optionala la stanga (16px, `fill:currentColor`,
`aria-hidden`). **Butoanele icon-only sunt interzise pentru actiuni cu text echivalent**
(ex. Salveaza/Sterge in tabele) — au cauzat „bloc colorat cu iconita invizibila" pe mobil.
Cand spatiul e strans, foloseste un grup compact `[ Salveaza ] [ Sterge ]` cu text scurt,
nu doua blocuri full-width unul sub altul. Icon-only ramane permis DOAR pentru: comutator
tema, hamburger cont, kebab, inchidere modal — toate cu `aria-label`.
Stari: `:hover``filter:brightness(1.08)` (primar) sau `background:var(--line)` (secundar/ghost);
`:focus-visible``outline:2px solid var(--accent); outline-offset:2px`; `:disabled`
`opacity:.45; cursor:default`. Stare „dirty" (modificari nesalvate) pe butonul de salvare:
fundal `--accent`.
### 5.2 Card
`background:var(--card); border:1px solid var(--line); border-radius:10px`. Carduri de
sectiune cu titlu `h2.sec` (15/600). Carduri-rand (lista pe mobil/tableta) cu radius 810px,
stivuite vertical, gap intern 78px.
### 5.3 Tabel → card-uri (responsive)
Tabelele **actionabile** (Trimiteri, Preview import, Mapari) devin card-uri sub 1024px.
Regula de card (vezi §3): NU folosi pattern-ul „eticheta cu `min-width` fix + valoare in
flex" — sparge valorile pe verticala. In schimb:
- **Stivuieste**: eticheta mica deasupra valorii (`display:block`), SAU
- **Card semantic**: linie titlu (identificator + stare), linii secundare mici. Preferat
pentru liste lungi (Trimiteri, Preview).
Sub 768px: un card pe rand. 7681024px: grid 2 carduri pe rand
(`grid-template-columns:repeat(2,1fr); gap:12px`).
Tabelele **dense read-only** (Jurnal, Nomenclator, Admin) raman tabel cu scroll orizontal
**contained in card** (`.tablewrap { overflow-x:auto }`), nu se cardifica.
### 5.4 Stepper / wizard import — COMPACT pe toate latimile
Patru pasi: Incarca fisier · Potriveste coloanele · Verifica · Confirma trimiterea.
- **Desktop**: o bara slim orizontala — pastila numar (sau ✓) + titlu scurt pe O linie,
pasul activ evidentiat. **Fara paragraf de ajutor inalt** in bara (ajutorul, daca e
nevoie, e text mic sub bara, o singura linie). Inaltime tinta ~44px, nu blocuri inalte.
- **Tableta/mobil**: colapsat la o singura linie — `Pasul N din 4 · <Titlu>` + bara de
progres (`height:5px; border-radius:99px`, umplere `--accent` la `N/4`). Ajutorul pasului
activ sub bara, text 12px muted.
Niciodata 4 coloane egale cu text — se taie/se rupe pe ecrane inguste.
### 5.5 Pill-uri si badge-uri
Stare: `.pill` 12px, radius 99px, cu clasa de culoare (`.s-ok/.s-warn/...`). Filtre de
stare: `.pill-cat` (contur inactiv, umplere activa pe culoarea categoriei). Badge contor:
cerc 18px, `--err`, text alb 11/700.
### 5.6 Formulare
Label 1213px muted deasupra controlului. Input/select: `--bg`, bordura `--line`, radius 6px,
padding `7px 10px`. Pe mobil controalele de formular din sectiunile de continut sunt
full-width (tinta 44px). Pe desktop pastreaza latimi rezonabile (`select` max ~340px).
### 5.7 Modal
Desktop: dialog centrat `max-width:680px`, radius 12px, backdrop `rgba(0,0,0,.55)`,
scroll intern. Mobil (`< 768px`): full-screen (fara colturi/umbra), buton inchidere 44px,
focus-trap + scroll-lock + `inert` pe `<main>`.
---
## 6. Accesibilitate (obligatoriu)
- Contrast text >= 4.5:1 (normal), >= 3:1 (>=18px bold) in toate cele 3 palete.
- Stare comunicata prin text, nu doar culoare.
- `:focus-visible` vizibil pe tot ce e interactiv (outline `--accent`).
- Tinte touch >= 44px pe mobil.
- Icon-only obligatoriu cu `aria-label`; SVG decorativ `aria-hidden="true"`.
- Modale: `role="dialog"`, `aria-modal`, focus-trap, focus return pe trigger.
- `prefers-reduced-motion`: scurteaza/elimina tranzitiile non-esentiale.
---
## 7. Pentru planurile de design (cum se foloseste acest fisier)
Inainte de orice propunere vizuala:
1. Citeste acest fisier integral. Foloseste DOAR tokenii de aici (culoare, type, radius, spatiu).
2. Verifica fiecare ecran la 360 / 768 / 1024 / 1280px.
3. Aplica compactarile globale (wizard, butoane) si pe desktop, nu doar pe mobil.
4. Respecta „un singur primar per context" si scara de control.
5. Daca o propunere cere un token nou (culoare/marime/radius), justifica si adauga-l AICI
in acelasi PR — nu introduce valori ad-hoc in template.
Stadiul de implementare a regulilor responsive se urmareste in PRD-ul activ
(`docs/prd/prd-5.13-responsive-compact.md`) si in `docs/ROADMAP.md`.

View File

@@ -0,0 +1,598 @@
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260626-201417.md -->
# PRD 5.12 — Editare unificata in modal + cont cu companie/email/CUI obligatorii + rafinari import (calendar data, mapare cu antet+prima inregistrare, un singur Salveaza, preview compact) + responsive tableta/mobil
**Stare**: inchis (verify-pass 2026-06-26; 8 stories TDD prin agent team, VERIFY context curat PASS + 1 FAIL remediat, /code-review high 3 buguri reparate; regresie 987 passed/1 skipped/0 failed; asteapta confirmare commit — poarta umana)
> Proces complet: `docs/ROADMAP.md` §5. Contractul RAR (sursa de adevar de contract):
> `docs/api-rar-contract.md`. Starea trece: `draft → aprobat → in-executie → verify-pass → inchis`
> (actualizata de lead). Acest PRD nu repeta strategia/contractul — le linkeaza.
>
> Continua 5.11 ([prd-5.11](prd-5.11-ux-import-compact-preview-navigatie.md)). **Backendul de
> trimitere (worker, masina de stari de trimitere, idempotenta, contract RAR) ramane NEATINS.**
> Atingeri de schema permise (ambele coloane noi, migrare defensiva `_migrate`, ca la 3.3b/3.5/3.6):
> `accounts.email` (US-001) si `import_rows.reviewed` (US-007, marcaj „verificat" per rand de preview).
> Vezi Non-Goals.
## 1. Obiectiv
Continuam dogfooding-ul de first-run inceput in 5.11. Sase frictiuni confirmate E2E in browser
(Playwright pe `exemple/prezentari_test.csv`, 2026-06-26) — toate UI/UX, plus o regula de date pe
conturi:
1. **Editarea unui rand din preview e rupta vizual si arunca o eroare JS.** Modul de editare inline
(`tr.preview-edit` cu `display:block` intr-un tabel `table-layout:fixed`) colapseaza coloanele —
antetul si formularul se randeaza pe verticala, caracter cu caracter (reprodus identic cu
`image copy.png`). La click pe **Anuleaza** se arunca in consola
`TypeError: Cannot read properties of null (reading 'htmx-internal-data')` (reprodus live).
Decizie utilizator: **editarea trebuie sa fie un MODAL, ca la Trimiteri, refolosind ACELASI
formular** (fara cod duplicat).
2. **Data prestatiei se scrie doar manual** (input text cu hint `YYYY-MM-DD`). Trebuie sa se poata
alege si din **calendar** (`<input type="date">` nativ, decizie utilizator — zero dependinte JS).
3. **Conturile nu au reguli minime de identitate.** Confirmat in baza: toate conturile au `cui=NULL`,
iar conturile create din CLI/teste nu au niciun utilizator → fara email. Decizie utilizator:
un cont inregistrat trebuie sa aiba **obligatoriu companie, email si CUI**.
4. **Maparea coloanelor nu arata datele.** Pasul 2 listeaza nume de coloana + 2 exemple stivuite, dar
nu se vede clar **capul de tabel (numele coloanelor) + valorile primei inregistrari**, ca operatorul
sa stie ce mapeaza.
5. **Panoul „Operatii de mapat la cod RAR" cere un Salveaza per rand.** La un fisier cu N operatii
nemapate sunt N butoane „Salveaza" si N submit-uri. Trebuie **un singur buton Salveaza** care
salveaza toate maparile odata.
6. **Tabelul de preview (pasul 3) nu e compact si are o coloana neclara.** Randurile sunt foarte inalte
(VIN-ul se sparge pe verticala), iar coloana **„Verificat?"** nu are sens evident in acest pas
(operatorul nu intelege bifa). Trebuie lista mai compacta si coloana clarificata/eliminata.
7. **Pe tableta si mobil interfata arata prost si articolele din header se suprapun.** Header-ul are grila
desktop `1fr auto 1fr` (`min-height:92px`, logo 60px) si un singur prag mobil `@media (max-width:767px)`,
dar **nimic pentru tableta (7681024px)** — acolo logo + titlu + badge mediu + comutator tema + versiune
+ hamburger se inghesuie si se suprapun. Tot fluxul (header, import, preview, modal, Trimiteri, Mapari,
Cont) trebuie **compact, functional si ergonomic** pe tableta si telefon, cu tinte touch si fara suprapuneri.
Toate sunt **UI/UX**, cu o singura exceptie de date controlata: identitatea contului (companie/email/CUI
obligatorii, US-001/002).
## 2. Non-Goals (anti scope-creep)
- **Nu** atingem worker-ul, reconcilierea, idempotenta, `build_key`, masina de stari de trimitere sau
contractul RAR.
- **Nu** schimbam canalul API (`POST /v1/prezentari` / `/valideaza`) si nici logica de mapare
(`mapping.py` `resolve_prestatii`). Maparea operatie→cod ramane neschimbata; doar UI-ul de mapare din
pasul de import se reorganizeaza (US-005) si reuseaza `save_mapping`/`reresolve_account` existente.
- **Nu** stergem coloanele DB `auto_send` (deja neutralizate in 5.11) si nu reintroducem conceptul.
- **Nu** schimbam stocarea editarii de preview: ramane `import_rows.override_json` (Approach B din 3.6),
ruta `POST /_import/{id}/rand/{i}/editeaza` ramane sursa de adevar; doar **suprafata** de editare trece
din rand-inline in modal.
- **Nu** facem editare in bloc / multi-rand si nici editare a operatiei/codului RAR din modalul de rand
(codul se mapeaza din panoul „Operatii de mapat", ca azi).
- **Nu** schimbam fluxul de login/parola; un cont poate avea in continuare mai multe loginuri (`users`),
dar primeste un email canonic de contact pe `accounts` (US-001).
- **Nu** rescriem validarea de continut (`validation.py`); `<input type="date">` produce tot `YYYY-MM-DD`,
acceptat azi.
## 3. Stories atomice
> Fiecare story: cea mai mica unitate care lasa sistemul functional. Backend + UI pentru acelasi
> comportament = 2 stories. Toate rutele web noi sub `require_login`, scoped pe contul din sesiune
> (404 cross-account), CSRF pe toate POST-urile.
>
> **Cerinta transversala (toate story-urile cu UI): responsive obligatoriu.** Fiecare suprafata noua/atinsa
> (US-002..007) se verifica E2E pe **3 viewport-uri: desktop (≥1280px), tableta (7681024px) si mobil
> (≤767px / ~390px)** — fara overflow orizontal (`scrollWidth <= clientWidth`), fara suprapuneri, tinte
> touch ≥44px, modal full-screen pe mobil. US-008 acopera header-ul + cadrul global; fiecare story isi
> verifica propria suprafata pe cele 3 viewport-uri.
### US-001: Backend — companie/email/CUI obligatorii pe cont (`accounts.email` + validari)
**Ca** administrator al gateway-ului **vreau** ca orice cont sa aiba companie, email si CUI **pentru ca**
azi conturile pot exista fara email (CLI/teste) si fara CUI, deci nu pot fi identificate fiscal/contactate.
- **Depinde de**: —
- **Fisiere**: `app/schema.sql` (coloana `accounts.email` + `_migrate` defensiv), `app/accounts.py`
(`create_account` accepta+valideaza `email`; helper `account_is_complete`), `app/web/auth_routes.py`
(signup: CUI devine obligatoriu; scrie `accounts.email`), `tools/account.py` (CLI create cere
`--email` + `--cui`), `tests/test_accounts.py`, `tests/test_signup.py` (~6 fisiere)
- **Test intai (RED)**: `tests/test_accounts.py`
`test_create_account_fara_email_ridica`, `test_create_account_fara_cui_ridica`,
`test_email_normalizat_lowercase_trim`, `test_migrare_adauga_coloana_email_idempotent`,
`test_account_is_complete_false_pe_legacy_incomplet`;
`tests/test_signup.py``test_signup_fara_cui_422`, `test_signup_scrie_email_pe_account`,
`test_signup_cui_existent_mesaj_prietenos` (NU mesajul tehnic cu `activate --account`).
- **Acceptance criteria**:
- [x] Migrare: `accounts.email TEXT` (nullable la nivel de schema pentru conturile legacy), `_migrate`
defensiv idempotent (ca `users.is_admin` la 3.3b). Contul de sistem id=1 ramane fara email (exceptat).
- [x] `create_account(conn, name, cui, email, active)``name`/`cui`/`email` goale → `ValueError`
cu cauza+fix (catalog `errors.py` daca exista cod potrivit); `email` normalizat (trim+lower);
`cui` normalizat (trim+upper, ca azi). CUI duplicat → mesajul existent.
- [x] Signup web: `cui` devine **obligatoriu** (azi optional); la succes scrie `accounts.email = email`-ul
utilizatorului. Lipsa CUI → re-randare formular cu eroare (422), pastrand campurile.
- [x] **CUI duplicat la signup = mesaj prietenos, NU cel tehnic** (decizie user 2026-06-26, optiunea 1):
„Aceasta firma (CUI …) e deja inregistrata. Cere accesul de la administratorul contului." — fara
referinta la CLI `activate --account`. **Model: 1 firma = 1 cont = 1 login**; fluxul de
invitatie/alaturare a unui al doilea email pe aceeasi firma e deferit la TODOS (optiunea 2).
- [x] **Canal de contact concret in mesaj** (T3 gate /autoplan, aprobat 2026-06-26): mesajul include un
email/canal de suport configurabil din settings (ex. `support_email`); daca setarea lipseste,
fallback la formularea de mai sus. Operatorul primeste un pas urmator real, nu doar „cere accesul".
Nu mai lasam mesajul tehnic ridicat de `create_account` sa ajunga verbatim in signup — detectam
CUI duplicat in handler-ul de signup si compunem mesajul prietenos acolo (NU `error=str(exc)`).
- [x] CLI `tools/account.py create` cere `--email` + `--cui` (refuza fara ele); `--with-key` neschimbat.
- [x] `account_is_complete(row)` (companie + email + CUI ne-goale) — helper pur, fara efecte.
- [x] **NU** atinge `users`, `submissions`, worker-ul sau idempotenta.
- **Verificare E2E**: TestClient — signup fara CUI → 422; signup complet → `accounts.email` populat;
`create_account` fara email/cui → ValueError.
### US-002: UI — gate de activare + pagina Cont editeaza companie/email/CUI + banner legacy
**Ca** operator/administrator **vreau** sa vad si sa completez companie/email/CUI **pentru ca**
conturile incomplete (legacy) trebuie aduse la regula fara re-inregistrare.
- **Depinde de**: US-001
- **Fisiere**: `app/web/templates/_cont.html` (sectiune noua „Date firma"), `app/web/routes.py`
(ruta `POST /cont/date-firma` scoped sesiune + CSRF; context `account_meta`+`cont_incomplet`),
`app/web/templates/admin.html` + `app/web/routes.py` (gate activare pe `account_is_complete`),
`app/web/templates/_banner.html` sau `_acasa.html` (banner „Completeaza datele firmei"),
`tests/test_web_cont.py`, `tests/test_admin.py` (~6 fisiere)
- **Test intai (RED)**: `tests/test_web_cont.py`
`test_cont_afiseaza_companie_email_cui`, `test_post_date_firma_actualizeaza`,
`test_post_date_firma_cui_duplicat_eroare`, `test_banner_cont_incomplet_pe_legacy`;
`tests/test_admin.py``test_activare_cont_incomplet_refuzata`.
- **Acceptance criteria**:
- [x] `_cont.html` are o sectiune „Date firma" (deasupra cheii API) cu companie + email + CUI editabile,
prefilled din `accounts`; `POST /cont/date-firma` valideaza (reuse `create_account`-style) + CSRF +
scoped sesiune; eroare pe CUI duplicat / camp gol, mesaj 3-niveluri.
- [x] Banner ne-blocant „Completeaza datele firmei (email/CUI)" pe Acasa cand `account_is_complete` e fals;
dispare dupa completare. NU blocheaza importul/uploadul.
- [x] In panoul admin, butonul **Activeaza** e dezactivat (cu tooltip) pe conturi incomplete —
nu activam la RAR un cont fara identitate completa.
- [x] Fara regresie pe rutele existente din `_cont.html` (cheie API, creds RAR).
- **Verificare E2E**: browser pe `/?tab=cont` — completez email+CUI → banner dispare; admin nu poate
activa un cont incomplet.
### US-003: UI — pasul „Potriveste coloanele" arata antet + prima inregistrare
**Ca** operator **vreau** sa vad numele coloanelor din fisier si valorile primului rand **pentru ca**
sa stiu exact ce date mapez la fiecare camp RAR.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_mapcoloane.html`, `app/web/routes.py` (`web_upload_import` /
`web_save_mapare_coloane` paseaza deja `sample_rows`; expune `prima_inregistrare`),
`tests/test_web_mapcoloane.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_mapcoloane.py`
`test_mapcoloane_arata_cap_tabel_coloane`, `test_mapcoloane_arata_valori_prima_inregistrare`,
`test_mapcoloane_fara_randuri_degradeaza` (fisier cu antet, fara randuri de date → fara crash).
- **Acceptance criteria**:
- [x] Deasupra (sau langa) randurile de mapare, un mic tabel orizontal cu **un cap de tabel = numele
coloanelor din fisier** si **un rand = valorile primei inregistrari** (truncate la o lungime
rezonabila, `title` pe valoare integrala). Foloseste `.tablewrap` pentru scroll orizontal pe mobil.
- [x] Fiecare coloana din capul de tabel ramane vizual asociata cu select-ul ei de mapare (ex. aceeasi
ordine, sau evidentiere la hover) — operatorul vede „coloana X (valoare „...") → campul canonic Y".
- [x] Fisier fara randuri de date → se arata doar capul de tabel, fara „prima inregistrare" (fara crash).
- [x] Nicio schimbare de backend de parsare/mapare; doar randare (datele exista deja in `sample_rows`).
- **Verificare E2E**: browser pasul 2 — upload `prezentari_test.csv` → vad antetul real + valorile randului 1.
### US-004: UI+backend — un singur „Salveaza" pe „Operatii de mapat la cod RAR"
**Ca** operator **vreau** sa salvez toate maparile de operatii dintr-un singur click **pentru ca**
azi e cate un buton per operatie si trebuie apasat pe fiecare.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_preview_import.html` (panoul de mapare → un singur `<form>`),
`app/web/routes.py` (ruta noua `POST /_import/{id}/mapare-operatii` plural; pastreaza
`mapare-operatie` singular pentru compat sau o inlocuieste — vezi AC), `tests/test_web_mapare_op.py`
(~3 fisiere)
- **Test intai (RED)**: `tests/test_web_mapare_op.py`
`test_mapare_operatii_salveaza_multiple_intr_un_post`,
`test_mapare_operatii_ignora_randuri_neselectate` (op fara cod ales → nesalvata, nu eroare),
`test_mapare_operatii_re_rezolva_blocatele` (randurile cu cod ales trec din `needs_mapping`).
- **Acceptance criteria**:
- [x] Panoul „Operatii de mapat la cod RAR" devine UN singur `<form>` cu un select per operatie +
**un singur buton „Salveaza maparile"** la final.
- [x] `POST /_import/{id}/mapare-operatii` primeste perechi `(cod_op_service, cod_prestatie)` (liste
paralele), apeleaza `save_mapping` pentru fiecare operatie cu cod ales (reuse exact, fara logica
noua de mapare), apoi **o singura** recompute `_web_compute_preview` + re-randare `#import-section`.
- [x] Operatiile fara cod ales (`— alege cod RAR —`) sunt ignorate (nu produc eroare, nu se salveaza).
- [x] Toggle-ul auto_send NU reapare (eliminat in 5.11).
- [x] CSRF + scoped sesiune + guard batch committed (409) pastrate.
- **Verificare E2E**: browser pasul 3 — aleg coduri pentru toate operatiile, un click pe „Salveaza
maparile" → toate randurile trec din „Cod RAR lipsa", o singura re-randare.
### US-005: Refactor — formular de editare partajat (DRY) intre Trimiteri si preview
**Ca** dezvoltator **vreau** un singur formular de editare de continut **pentru ca** sa nu existe cod
duplicat intre modalul Trimiteri si editarea de preview (sursa bug-urilor inline din 3.6/5.11).
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_form_editare.html` (NOU — partial cu campurile vehicul/data/odo),
`app/web/templates/_trimitere_detaliu.html` (consuma partial-ul), `app/web/templates/_macros.html`
(macro `camp` extins cu `tip='date'`), `tests/test_web_form_editare.py` (~4 fisiere)
- **Test intai (RED)**: `tests/test_web_form_editare.py`
`test_form_editare_are_input_date_pe_data_prestatie`,
`test_trimitere_detaliu_foloseste_form_partajat`,
`test_camp_macro_randeaza_type_date`.
- **Acceptance criteria**:
- [x] Partial `_form_editare.html` randeaza grila responsiva existenta
(`repeat(auto-fit, minmax(200px,1fr))`) cu campurile: `nr_inmatriculare`, `vin`, `data_prestatie`,
`odometru_final`, `odometru_initial`, plus map de erori per-camp (tipar `corectie_errors`).
Parametrizat prin: URL de POST, valorile curente, harta de erori, eticheta butonului primar.
- [x] **`data_prestatie` = `<input type="date">`** (calendar nativ); valoarea ramane `YYYY-MM-DD`.
Daca valoarea curenta nu e `YYYY-MM-DD` valid, inputul degradeaza grijuliu (gol + hint), fara crash.
- [x] `_trimitere_detaliu.html` randeaza acelasi partial in ramura `editabil` — comportamentul modalului
Trimiteri (post `/corecteaza`, select cod RAR pe needs_data/needs_mapping) ramane identic.
- [x] Macro `camp` suporta `tip='date'` fara sa strice apelurile `type='text'` existente.
- **Verificare E2E**: browser — modalul Trimiteri (rand `needs_data`) arata un calendar la Data prestatie;
salvarea+revalidarea functioneaza ca azi.
### US-006: UI — „Editeaza" din preview deschide MODALUL (acelasi formular), nu rand inline
**Ca** operator **vreau** sa editez un rand de preview intr-un modal curat **pentru ca** editarea inline
e rupta vizual si arunca eroare la Anuleaza.
- **Depinde de**: US-005
- **Fisiere**: `app/web/templates/_preview_rand.html` (scoate ramura `editing`/`tr.preview-edit` +
scriptul de mutual-exclusion; butonul „Editeaza" tinteste modalul global), `app/web/routes.py`
(ruta GET fragment editare preview → randeaza `_form_editare.html` in `#detaliu-modal-body`;
POST `/_import/{id}/rand/{i}/editeaza` ramane, dar raspunde cu inchidere modal + OOB pe rand+contoare),
`app/web/templates/_preview_import.html` (foloseste modalul global `#detaliu-modal`),
`tests/test_web_preview_edit.py` (~5 fisiere)
- **Test intai (RED)**: `tests/test_web_preview_edit.py`
`test_editeaza_preview_serveste_fragment_modal` (NU `tr.preview-edit`),
`test_salvare_preview_inchide_modal_si_oob_rand`,
`test_anuleaza_nu_lasa_rand_orfan` (regresie pe eroarea htmx null),
`test_editare_preview_scoped_404_alt_cont`, `test_editare_batch_committed_409`.
- **Acceptance criteria**:
- [x] Butonul „Editeaza" pe rand face `hx-get` catre fragmentul de editare cu `hx-target="#detaliu-modal-body"`
(acelasi mecanism de modal ca la Trimiteri, deschis prin clasa/markup existent in `base.html`).
- [x] Fragmentul randeaza `_form_editare.html` cu POST la `/_import/{id}/rand/{i}/editeaza`,
`hx-target="#detaliu-modal-body"`. La succes: **modalul se inchide** (`HX-Trigger: inchideModal`,
ca la `/corecteaza`) si randul + contoarele se actualizeaza prin **OOB swap** (reuse `include_oob`).
- [x] **Ramura `editing` / `tr.preview-edit` + scriptul inline de mutual-exclusion sunt ELIMINATE** din
`_preview_rand.html` (sursa colapsarii pe verticala + a erorii `htmx-internal-data` la Anuleaza).
- [x] „Anuleaza" = inchiderea modalului (mecanismul global), fara cerere catre `/_import/.../rand/{i}`,
deci fara eroarea JS reprodusa. Test de regresie pe consola curata.
- [x] Mutatie pura pe `override_json` pastrata (ruta neschimbata logic); scoping JOIN→404,
guard committed→409 raman.
- [x] Pe eroare de validare, modalul ramane deschis cu valorile + erorile per-camp (tipar Trimiteri).
- **Verificare E2E**: browser pasul 3 — Editeaza → modal cu calendar + campuri; completez data → Salveaza →
modal se inchide, randul trece pe „Gata de trimis", contoarele cresc; Anuleaza → modal se inchide,
**0 erori in consola**.
### US-007: UI — preview compact + scoaterea coloanei „Verificat?"
**Ca** operator **vreau** o lista de preview compacta si fara coloane neclare **pentru ca** randurile
sunt prea inalte (VIN pe verticala) si nu inteleg bifa „Verificat?".
- **Depinde de**: US-006
- **Fisiere**: `app/schema.sql` (coloana `import_rows.reviewed` + `_migrate` defensiv),
`app/web/templates/_preview_rand.html`, `app/web/templates/_preview_import.html`
(scoate coloana `col-verificat` + logica inline `reviewed_rows` din tabel),
`app/web/templates/_form_editare.html` / fragmentul modal (buton „Confirma valorile" pe `needs_review`),
`app/web/templates/base.html` (latimi `col-*` recalibrate, anti-overflow),
`app/api/v1/import_router.py` + `app/web/routes.py` (citesc `reviewed` in `_resolve_row_for_preview` /
`_web_compute_preview` ca `needs_review`-confirmat → `ok`; gate `n_confirmat` la commit foloseste
`reviewed`, nu bife inline; ruta care seteaza `reviewed=1`), `tests/test_web_preview_compact.py`,
`tests/test_import_review.py` (~7 fisiere)
- **Test intai (RED)**: `tests/test_web_preview_compact.py`
`test_preview_fara_coloana_verificat`,
`test_preview_vin_nu_se_sparge_pe_verticala` (VIN intr-o singura linie / wrap controlat);
`tests/test_import_review.py`
`test_needs_review_exclus_din_gata_pana_la_confirmare`,
`test_confirmare_in_modal_seteaza_reviewed_si_devine_ok`,
`test_reviewed_nu_intra_in_payload_sau_idempotency` (marcaj separat, NU camp de continut),
`test_migrare_adauga_coloana_reviewed_idempotent`,
`test_editare_valoare_pe_needs_review_reseteaza_reviewed` (daca schimbi valoarea, re-cere confirmare).
- **Acceptance criteria**:
- [x] Coloana **„Verificat?" eliminata** din tabelul de preview; antetul si celulele scad la 8 coloane.
- [x] Randuri compacte: VIN nu se mai sparge pe verticala (latime minima pe coloana Vehicul / `white-space`
controlat); fara overflow orizontal la 1280px (`scrollWidth <= clientWidth`); cardurile <768px raman.
- [x] **Decizie inchisa (Q1): confirmare in modal, rand exclus pana confirmi.** Un rand `needs_review`:
- apare cu pill Verifica valori" + motivul concret in Note" (data ambigua / formule Excel / coercion);
- este **exclus din „gata de trimis"** (nu intra in `n_confirmat`) pana cand operatorul il deschide in
modal (US-006) si apasa **Confirma valorile"** (sau il corecteaza), ceea ce seteaza
`import_rows.reviewed=1`; abia atunci randul devine `ok` la recalculul `_resolve_row_for_preview`.
- [x] **Banner discoverability deasupra tabelului** (T1 gate /autoplan, aprobat 2026-06-26): cand exista randuri
`needs_review`, un banner ne-blocant deasupra tabelului explica: Randurile cu <pill>Verifica valori</pill>
nu pleaca la RAR pana le deschizi si confirmi in modal." Fara el, gate-ul mutat din coloana vizibila in
modal devine usor de ratat (operatorul crede ca pill-ul e informativ). Bannerul dispare cand
`summary.needs_review == 0`.
- [x] **Buton explicit „Confirma valorile"** (T2 gate /autoplan, aprobat 2026-06-26): in modal (US-006), randurile
`needs_review` au un buton SEPARAT „Confirma valorile" care seteaza `reviewed=1` — atestare explicita,
distincta de salvarea unei corectii de continut. NU se seteaza `reviewed=1` implicit la orice save
(altfel operatorul ar atesta o valoare ambigua fara intentie). Salvarea unei CORECTII pe un rand deja
confirmat reseteaza `reviewed` (vezi AC urmator).
- [x] **Marcaj separat, nu camp de continut**: `import_rows.reviewed` (nullable/int, migrare defensiva) NU
intra in payload, in `override_json` sau in cheia de idempotenta. Daca utilizatorul **schimba** o
valoare a unui rand deja confirmat, `reviewed` se reseteaza (re-cere confirmare).
- [x] Comitul ramane gate HARD pe `n_confirmat` (niciun rand ambiguu nu pleaca la RAR fara confirmare
umana explicita) — acum derivat din `reviewed`, nu din bife inline `reviewed_rows`.
- [x] Bara de confirmare („Trimite la RAR") si contoarele raman corecte dupa editari/confirmari (OOB),
fara coloana Verificat?.
- [x] Guard committed→409 si scoping JOIN→404 pe ruta de confirmare (acelasi tipar ca `/editeaza`).
- **Verificare E2E**: browser pasul 3 (cu un xlsx cu data ambigua / VIN numeric) — lista compacta, fara
coloana Verificat?, VIN pe o linie; randul `needs_review` ramane exclus din „gata de trimis" pana il
confirm in modal („Confirma valorile") → devine „Gata de trimis", contorul creste.
### US-008: UI — responsive tableta + mobil (header fara suprapuneri + cadru compact/ergonomic)
**Ca** operator pe telefon/tableta **vreau** o interfata compacta, fara articole de header suprapuse
**pentru ca** azi pe mobil arata prost si elementele din header se calca unele pe altele.
- **Depinde de**: — (header/cadru global); coordonat cu US-006/007 pentru modal+preview pe mobil
- **Fisiere**: `app/web/templates/base.html` (header grid + media queries tableta 7681024 + mobil ≤767,
modal full-screen, `.cont-menu`, tinte touch), eventual `app/web/templates/_status.html` /
`_acasa.html` (contoare + nav pe randuri inguste), `tests/test_web_responsive.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_responsive.py`
`test_header_are_breakpoint_tableta` (exista reguli `@media` intre 768 si 1024 pentru header),
`test_header_elemente_nu_au_min_height_fix_pe_mobil`,
`test_modal_full_screen_pe_mobil` (clasa/regula prezenta). (Testele de markup/CSS; pixel-level la E2E.)
- **Acceptance criteria**:
- [x] **Header fara suprapuneri pe tableta (7681024px)**: logo + titlu + badge mediu + comutator tema +
versiune + hamburger se aseaza fara sa se calce (grid/flex care wrap-uieste sau ascunde versiunea/
titlul lung); `min-height:92px` nu forteaza inghesuirea. Pe mobil (≤767px) raman regulile existente,
verificate ca nu se suprapun la ~390px latime.
- [x] **Compact + ergonomic**: spatieri reduse pe mobil, tinte interactive ≥44px (butoane, pill-uri,
linkuri nav, intrari hamburger), fara dublu-scroll; modalul de editare (US-006) e **full-screen**
pe mobil (nu o casuta minuscula).
- [x] **Fara overflow orizontal** pe niciuna din paginile principale (Acasa/import, preview pas 3, Mapari,
Cont, login/signup) la 768px si la ~390px (`scrollWidth <= clientWidth`).
- [x] Contoarele de status + nav-ul „Trimiteri/Mapari" se aseaza pe randuri lizibile pe mobil (fara taiere).
- [x] Light/Dark/Petrol/Auto raman corecte pe toate viewport-urile (fara regresie de tema).
- **Verificare E2E**: browser Playwright cu `browser_resize` la **390×844 (mobil)**, **820×1180 (tableta)**
si **1280×800 (desktop)** — screenshot pe Acasa/import, preview pas 3 (cu modal deschis) si Cont; header
fara suprapuneri pe toate trei; 0 overflow orizontal; tinte touch ok.
## 4. Riscuri
- **R1 — Gate `needs_review` la scoaterea coloanei.** Coloana „Verificat?" era gate-ul HARD prin care
randurile cu valori ambigue (data ambigua, formula Excel) intrau in trimitere doar dupa bifa umana.
Scoaterea ei naiva ar auto-include randuri ambigue (declaratie ireversibila la RAR). Mitigare:
confirmarea se muta in modalul de editare (US-007 AC); `n_confirmat` ramane gate HARD. Vezi Q1.
- **R2 — Refactor formular partajat (US-005) atinge modalul Trimiteri (cale LIVE).** `_trimitere_detaliu.html`
e folosit pentru corectii reale care re-trimit la RAR. Mitigare: US-005 = refactor fara schimbare de
comportament; teste byte-compat pe post `/corecteaza` + regresia existenta verde inainte de US-006/007.
- **R3 — `<input type="date">` si valori ne-`YYYY-MM-DD`.** Fisiere cu data in alt format ajung in editare
ca text ne-valid pentru inputul date (s-ar goli). Mitigare: AC US-005 — degradare grijulie (gol + hint),
fara pierdere tacuta; data ramane editabila si re-validata la salvare.
- **R4 — Migrare `accounts.email`.** Conturi legacy raman cu `email=NULL`. Mitigare: coloana nullable +
`account_is_complete` (banner + gate activare), nu hard-block; contul de sistem id=1 exceptat.
- **R5 — Eroarea htmx `htmx-internal-data`.** Reprodusa la Anuleaza pe editarea inline. Mitigare: US-006
elimina complet ramura inline + scriptul; test de regresie pe consola curata.
- **R6 — Responsive = fisier fierbinte `base.html`.** US-008 atinge header + media queries, fisier partajat
cu alte story-uri (US-007 latimi `col-*`). Mitigare: serializare la lead (NU paralel pe `base.html`);
verificare pixel pe 3 viewport-uri ca breakpoint-ul de tableta nu strica desktop-ul/mobilul existent.
## 5. Intrebari deschise
> Se rezolva cu utilizatorul ÎNAINTE de executie (poarta de aprobare PRD).
- **Q1 (gate `needs_review`) — INCHIS (user, 2026-06-26): confirmare in modal, rand exclus pana confirmi.**
Context — `needs_review` apare cand validarea TRECE dar parsarea fisierului a fost incerta, in 3 cazuri
(sursa: `import_parse.py` + `import_router.py:201-230`), aproape exclusiv la **xlsx** (la CSV nu se
declanseaza — de-aceea coloana e goala in cazul comun):
1. **Data ambigua** — zi ≤12 si format neclar (`05.06` = 5 iun. sau 6 mai?).
2. **Coloana cu formule Excel** fara valori calculate (rata mare de celule goale).
3. **Coercion suspect** la citire xlsx — VIN numeric (pierde zerourile din fata) / odometru ca float.
Decizie: scoatem coloana mereu-prezenta „Verificat?"; randul `needs_review` ramane **exclus din „gata de
trimis"** pana e deschis in modal si **confirmat** („Confirma valorile") sau corectat, persistand
`import_rows.reviewed=1` (marcaj separat, NU camp de continut → nu intra in payload/idempotenta).
Implementat in US-007.
- **Q2 (model cont-email) — INCHIS (user, 2026-06-26): model A** (email canonic pe `accounts`), cu
**1 firma = 1 cont = 1 login**. CUI ramane unic; al doilea email pe acelasi CUI e respins la signup cu
mesaj prietenos (US-001). Fluxul de invitatie/alaturare (mai multi utilizatori per firma) → TODOS.
- **Q3 (CLI legacy `tools/account.py`)**: facem `--email`/`--cui` obligatorii rupe scripturile vechi de
test? Daca da, pastram un flag `--allow-incomplete` doar pentru teste, sau actualizam fixture-urile.
## 6. Valuri de executie (graful de dependente)
```
Val 1 (paralel, fisiere disjuncte):
[US-001] accounts.email + validari companie/email/CUI (schema/accounts/auth_routes/cli)
[US-003] mapcoloane: antet + prima inregistrare (_mapcoloane.html/routes)
[US-004] un singur Salveaza pe operatii (_preview_import.html/routes)
[US-005] formular de editare partajat (DRY) + input date (_form_editare/_trimitere_detaliu/_macros)
Val 2 (deblocate de Val 1):
[US-002] Cont editeaza date firma + gate activare + banner (dep US-001; _cont/admin/routes)
[US-006] Editeaza preview → modal (acelasi formular) (dep US-005; _preview_rand/_preview_import/routes)
Val 3 (deblocat de US-006; ating base.html → serializate):
[US-007] preview compact + scoate „Verificat?" + gate review (dep US-006; _preview_*/base.css/import_router)
[US-008] responsive tableta+mobil + header fara suprapuneri (base.html media queries; coordonat cu US-006/007)
```
Fisiere fierbinti partajate (serializate de lead, NU paralel pe acelasi fisier): `routes.py`
(US-001/002/003/004/006), `_preview_import.html` (US-004/006/007), `_preview_rand.html` (US-006/007),
`base.html` (US-007 latimi `col-*` + US-008 header/media queries — serializate strict intre ele). Vezi ROADMAP §5.5.
---
## Raport VERIFY
> Faza VERIFY rulata de subagent verificator independent (context curat, PRD-only, ROADMAP §5.6),
> 2026-06-26. Lead orchestrare prin agent team (8 teammates Sonnet TDD pe valuri cu fisiere disjuncte;
> `routes.py` si `base.html` serializate ca fisiere fierbinti). Backend trimitere (worker, masina de
> stari de trimitere, idempotenta `build_key`, contract RAR, canal API) NEATINS — confirmat
> `git diff --stat` (app/worker/, app/idempotency.py, app/mapping.py, app/validation.py = 0 modificari).
### Rezultat: PASS (toate 8 stories)
- **Suita**: `python3 -m pytest -q` -> **987 passed, 1 skipped, 0 failed** (baseline 934 -> +53 teste noi).
Live RAR `FINALIZATA` = opt-in indisponibil in mediu (normal, ca la livrabilele anterioare).
- **PASS/FAIL per story** (dovezi cod + teste, verificator independent):
- US-001 accounts email/CUI — PASS (migrare defensiva, create_account valideaza, account_is_complete
id=1 exceptat, signup CUI obligatoriu + mesaj prietenos T3, CLI --email/--cui).
- US-002 Cont date firma + gate activare + banner — PASS.
- US-003 mapcoloane antet + prima inregistrare — PASS (confirmat E2E browser).
- US-004 un singur Salveaza pe operatii — PASS (ruta plurala, D#12 skip invalid).
- US-005 formular editare partajat + input date — PASS (D#5/D#6/D#10).
- US-006 Editeaza preview -> MODAL — PASS (ramura inline eliminata, Anuleaza fara eroare htmx, E2E 0 erori consola).
- US-007 preview compact + gate review in modal — PASS (reviewed marcaj separat, NU in payload/idempotenta;
gate HARD pe ambele canale; T1 banner; T2 buton Confirma; D#9 reset).
- US-008 responsive tableta + mobil — PASS (E2E pe 390/820/1280, header fara suprapuneri, D#13 verificat).
- **Invariante critice**: R2 (submissions neatins dupa editare preview) PASS; reviewed in afara
payload/override/idempotency PASS; migrari idempotente PASS; ramura inline `tr.preview-edit` eliminata PASS.
### VERIFY a gasit 1 FAIL -> remediat TDD, re-confirmat
- FAIL: `signup.html` eticheta CUI „(optional)" + input fara `required` (contrazicea AC US-001 „CUI obligatoriu";
serverul respingea corect 422 dar UI comunica gresit). Reparat TDD (eticheta `*` + `required`),
test de lock `test_signup_html_cui_obligatoriu_ui`.
### Faza CLOSE — `/code-review high` (8 unghiuri prin subagenti, verificare cod first-hand)
3 buguri reale reparate TDD (regresie finala 987 passed):
1. **HIGH**`confirma-review` folosea `hx-swap="none"` -> scriptul `updateN()` din continutul principal nu
se executa -> `n_confirmat` ramanea stale -> „Trimite la RAR" pica pe gate HARD 422 (fluxul confirma->commit
US-007 rupt la prima incercare). Fix: formularul Confirma valorile aliniat la `hx-target="#detaliu-modal-body"`
`hx-swap="innerHTML"` (ca /editeaza).
2. **MEDIUM** — email duplicat la signup arata mesajul gresit „firma e deja inregistrata" (`"deja folosit"`
prindea si `ValueError("email deja folosit")` din `create_user`). Fix: detectie email-dup inaintea CUI-dup,
mesaj specific emailului.
3. **MEDIUM (a11y)** — butonul Editeaza din preview deschidea modalul ocolind `open()` (fara inert/focus-trap/
focus-return). Fix: handler-ul global `htmx:beforeRequest` trateaza si `.btn-editeaza` -> `open()`; JS inline eliminat.
Notat ca debt (neblocant): API preview re-deriva needs_review peste DB `resolved_status` cross-channel (web commit
numara oricum `reviewed=1`); mesaje prietenoase „camp gol" dead-code in cont_date_firma/signup (edge mascat de HTML
required); `zip()` truncheaza la liste POST inegale; `id` cont in mesajul CUI-duplicat; duplicari de cleanup
(context modal, markup banner, N query nomenclator).
### Nedovedit in sesiune
- Live RAR `FINALIZATA` prin `--send` (opt-in, lipsa creds/mediu) — risc minim, backend trimitere NEATINS.
---
## GSTACK REVIEW REPORT (/autoplan, 2026-06-26)
Branch: main · Commit: 283299f · Voci: Claude subagents (CEO/Design/Eng/DX) + verificare cod first-hand.
**Codex = INDISPONIBIL** (usage limit, reset 2026-07-18) -> mod `[subagent-only]` pe toate fazele.
Restore point: vezi comentariul HTML din capul fisierului. Test plan: `~/.gstack/projects/romfast-rar-autopass/main-prd5.12-test-plan-20260626.md`.
### Rezumat
PRD matur: Q1/Q2 inchise de user, Non-Goals clare, graf de valuri, R1-R6. Rutele si fisierele citate exista
toate in cod. Review-ul a confirmat fezabilitatea si a gasit **4 lacune de specificatie reale** (nu blocante,
dar de inchis inainte de executie) + cateva rafinari. Niciun User Challenge (un singur model activ -> nu se
poate forma consens cross-model; recomandarile de mai jos sunt sugestii, nu provocari).
### Decision Audit Trail
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|---|------|---------|-------------|-----------|-------------|---------|
| 1 | CEO | NU splitam in 5.12a/5.12b | Taste | P3/P6 | Valurile izoleaza deja US-001/002 (Val1/Val2) pe fisiere disjuncte; split adauga overhead de release fara castig tehnic pt. echipa mica | Split in 2 release-uri (CEO subagent) |
| 2 | CEO | Respins „testeaza worker-ul intai" (F1/F3/F8) | Mechanical | P3 | Non-Goals ingheata explicit worker/contract/idempotenta; conflateaza acest PRD UI cu munca de backend separata | CEO subagent F1/F3/F8 |
| 3 | CEO | First-run E2E smoke -> ramane in TODOS (deja listat) | Mechanical | P3 | Deja deferat din 5.11; recomandat, neblocant | A bloca 5.12 pe el |
| 4 | CEO | needs_review: pastram gate-ul, nu cerem date de utilizare | Mechanical | P1 | Gate-ul e safety-critical (declaratie ireversibila RAR); US-007 muta UI-ul, nu sterge gate-ul | CEO F5 (gather usage data first) |
| 5 | Eng | Partial partajat = DOAR campuri vehicul+data+err/fix; cod_prestatie select + nemapate_inline RAMAN in `_trimitere_detaliu` | Mechanical | P5/P4 | `_trimitere_detaliu` are 2 surse de cod (select + sectiune mapare inline) imposibil de absorbit fara branching fragil | Partial „atotcuprinzator" |
| 6 | Eng | US-005 parametrizeaza si `fix_map` (+ aria-label cu VIN) | Mechanical | P1 | Forma preview are fix-hints + aria-label cu context VIN; lista PRD le omitea -> ar pierde info la extractie | A lasa lista PRD ca atare |
| 7 | Eng | `import_rows.reviewed INTEGER DEFAULT 0` (nu NULL) | Mechanical | P5 | Gate-ul devine `reviewed=0` clar, fara ambiguitate NULL vs 0 | DEFAULT NULL |
| 8 | Eng | Gate commit derivat din DB `reviewed` pe AMBELE canale; API `reviewed_rows` pastrat dar seteaza `reviewed=1` (contract stabil) | Mechanical | P1/P5 | Evita divergenta web/API si pastreaza contractul `/v1/import/.../commit` | A schimba doar web-ul (divergenta tacuta) |
| 9 | Eng | reset `reviewed` la schimbare valoare se implementeaza in calea editeaza/override | Mechanical | P1 | E un AC US-007 fara loc de implementare numit; `apply_row_override` e locul | A-l lasa nespecificat |
| 10 | Design | `<input type=date>` ne-ISO: gol + hint + valoare bruta in hidden, fara pierdere | Taste->auto | P1 | Previne pierderea tacuta de date pe formate Excel; backend deja marcheaza needs_review | A goli pur si simplu inputul |
| 11 | Design | US-003 fisier fara randuri: mesaj explicit „antet fara randuri de date" + blocheaza Continua | Mechanical | P1 | Edge case altfel = esec tacut | Doar „fara crash" |
| 12 | US-004 | Bulk mapping: validare per-item, skip invalid + sumar, restul salvate, 1 re-render | Mechanical | P1 | PRD acopera „fara cod = ignorat" dar nu „cod invalid pe 1 din N" | All-or-nothing |
| 13 | US-008 | Modal full-screen mobil: VERIFICA, nu re-adauga (exista base.html:407) | Mechanical | P4 | Regula deja prezenta la `@media max-width:767px` | A re-implementa |
| 14 | DX | Q3: actualizam fixture-urile via factory in `conftest.py`, FARA `--allow-incomplete` in prod | Taste | P5/P4 | Escape-hatch lasa o veruca in codul de productie; factory centralizat e curat si mai bun pe termen lung | `--allow-incomplete` flag |
| T1 | Design | needs_review: banner persistent deasupra tabelului | Taste -> APROBAT user 2026-06-26 | P1/P5 | Gate-ul mutat in modal devine usor de ratat; bannerul il face explicit | Doar pill+tooltip |
| T2 | Design/Eng | „Confirma valorile" = buton explicit separat (nu implicit pe save) | Taste -> APROBAT user 2026-06-26 | P5 | Atestare explicita pe valori ambigue; evita confirmarea accidentala | Implicit pe orice save |
| T3 | DX | Mesaj CUI duplicat include canal de contact configurabil (fallback la actual) | Taste -> APROBAT user 2026-06-26 | P1 | Operatorul primeste un pas urmator real; detectie in handler signup, nu `str(exc)` | Pastreaza mesajul ca in PRD |
### NOT in scope (confirmat)
- Worker, reconciliere, idempotenta, `build_key`, masina de stari de trimitere, contract RAR (Non-Goals).
- Canal API `POST /v1/prezentari` / `/valideaza` si `mapping.resolve_prestatii` — neschimbate.
- Multi-utilizatori per firma (flux invitatie/alaturare) -> TODOS.
- First-run E2E smoke ca poarta de release -> TODOS (deja deferat din 5.11).
- Split 5.12a/5.12b -> respins (vezi D#1).
### What already exists (de refolosit, nu reconstruit)
- Modal global `#detaliu-modal` + `inchideModal` (`HX-Trigger-After-Settle`, routes.py:1235/1394) — US-006 il refoloseste.
- `include_oob` pentru OOB swap rand+contoare — US-006/007 il refolosesc.
- `save_mapping` / `reresolve_account` — US-004 le refoloseste (fara logica noua de mapare).
- Macro `camp` exista INLINE in ambele forme (`_preview_rand.html:51`, `_trimitere_detaliu.html:98`) — US-005 il EXTRAGE (nu „extinde in `_macros.html`" cum spune lista de fisiere; `_macros.html` are azi doar `autosend_toggle` gol).
- Modal full-screen mobil + tinte touch 44px — deja in base.html (`@media max-width:767px`, liniile 407-427). US-008 = tableta + verificare, nu rescriere.
- `_migrate` defensiv idempotent (tipar `users.is_admin` 3.3b) — US-001/007 il urmeaza.
---
## Faza 1 — CEO (strategie & scope)
CEO DUAL VOICES — CONSENSUS:
```
Dimensiune Claude Codex Consens
------------------------------------- -------- ------- ---------
1. Premise valide? DA* N/A n/a (1 voce)
2. Problema corecta? DA N/A n/a
3. Calibrare scope? DISAGREE N/A -> taste (split?)
4. Alternative explorate suficient? PARTIAL N/A n/a
5. Riscuri competitive acoperite? DA N/A n/a
6. Traiectorie 6 luni sanatoasa? DA N/A n/a
* premise = decizii user deja luate (Q1/Q2 inchise, modal/calendar = „decizie utilizator")
```
**Examinat, nimic blocant pe strategie.** PRD-ul rezolva first-run friction confirmat E2E; scope-ul e calibrat
prin valuri. CEO subagent a recomandat split-ul in 2 release-uri (D#1, respins) si a ridicat findings de „testeaza
worker-ul" care cad in afara Non-Goals (D#2, respinse). Single-critical pastrat: Q3 (backward-compat CLI) — real,
mutat la faza DX/Eng. Dream-state delta: 5.12 inchide first-run UX; ramane (separat) poarta E2E smoke + flux
multi-user firma.
## Faza 2 — Design (UI/UX)
Litmus (Claude design; Codex n/a):
```
Dimensiune Scor Nota
-------------------------------- ----- -------------------------------------------
Ierarhie informatie (mapcoloane) 7/10 US-003 ok; recomandat cap-tabel sticky pe fisiere cu 15+ coloane
Stari (load/empty/error/partial) 6/10 empty-file (US-003) si date ne-ISO (US-005) sub-specificate
Gate needs_review in modal 6/10 LANDMINE: gate HARD mutat dintr-o coloana vizibila intr-un modal
Responsive tableta (US-008) 7/10 breakpoint lipseste azi; spec sa fie pixel-exact, nu aspirational
Interactiune modal (US-006/007) 6/10 „Confirma valorile" = buton separat vs implicit-pe-save (ambiguu)
```
**Issue-uri auto-decise:** D#10 (date ne-ISO), D#11 (empty-file mesaj). **Taste surfaced la gata:** banner
discoverability pe needs_review (T1) + buton „Confirma valorile" explicit (T2).
## Faza 3 — Eng (arhitectura & teste)
ENG DUAL VOICES — CONSENSUS:
```
Dimensiune Claude Codex Consens
--------------------------- -------- ------- ---------
1. Arhitectura sanatoasa? DA(cond) N/A n/a — cond. pe partial corect parametrizat
2. Acoperire teste? PARTIAL N/A n/a — vezi test plan, 4 gap-uri
3. Riscuri performanta? DA N/A n/a — irelevant (UI/CRUD mic)
4. Securitate? DA N/A n/a — CSRF+scoped pastrate pe rute noi
5. Cai de eroare? PARTIAL N/A n/a — bulk mapping partial, date ne-ISO
6. Risc deployment? DA N/A n/a — 2 migrari nullable defensive
```
Diagrama arhitectura (componente noi vs existente):
```
US-001 create_account(+email) -> auth_routes.signup ──┐
accounts.email (migrare) -> tools/account.py CLI ┤ (3 call-sites de actualizat)
account_is_complete ┘
US-005 _form_editare.html (NOU) <── _trimitere_detaliu.html (cod_prestatie select + nemapate_inline RAMAN aici)
└──< _preview_rand.html (US-006: ramura inline ELIMINATA)
US-006 preview „Editeaza" -> #detaliu-modal-body (GET fragment) -> POST /editeaza -> inchideModal + OOB
US-007 import_rows.reviewed (migrare) -> _resolve_row_for_preview -> gate n_confirmat
├── web routes.py /confirma (citeste DB reviewed)
└── API import_router commit_import (reviewed_rows -> seteaza reviewed=1; contract stabil)
US-008 base.html @media (768-1024) NOU + verificare 767 existent
```
**Findings auto-decise:** D#5 (partial scope), D#6 (fix_map), D#7 (reviewed DEFAULT 0), D#8 (gate pe 2 canale),
D#9 (reset reviewed in apply_row_override), D#12 (bulk partial). Test plan scris pe disc (44 codepath-uri,
4 gap-uri marcate). **Invariant critic confirmat (R2):** editarea preview ramane override-only, NU re-queue —
test obligatoriu (#24/#25 in test plan): dupa editare preview, `submissions` neatins.
## Faza 3.5 — DX (CLI + erori + contract API)
DX CONSENSUS (Claude; Codex n/a):
```
Dimensiune Nota
--------------------------------- -------------------------------------------
CLI create --email/--cui Q3 nerezolvat: a face flag-uri obligatorii rupe fixture-urile
Mesaj eroare CUI duplicat prietenos da, dar „cere accesul de la admin" nu spune CUM
Contract API commit reviewed_rows risc de divergenta tacuta -> rezolvat de D#8 + test #39
Migrare fixture-uri recomandat factory in conftest.py (DX gap real)
```
**Auto-decis:** D#14 (Q3 -> factory, fara `--allow-incomplete`). **Taste surfaced:** mesaj CUI duplicat sa includa
un canal de contact concret (T3).
### Cross-Phase Themes
- **Tema A — Gate-ul needs_review (Design + Eng).** Design: mutarea in modal ascunde gate-ul (discoverability).
Eng: gate-ul trebuie sa fie DB-backed pe ambele canale + reset la editare. Semnal high-confidence: tratati
needs_review ca feature de sine statator in US-007, nu ca „stergere de coloana". -> T1 + D#8/D#9.
- **Tema B — Sub-specificarea „Confirma valorile" (Design + Eng).** Ambii: cand se seteaza `reviewed=1`?
Buton separat vs implicit pe save. -> T2.
- **Tema C — Q3 backward-compat (CEO + DX + Eng).** Toate trei: a face email/CUI obligatorii rupe fixture-uri.
-> D#14 (factory).
### Implementation Tasks (aggregate)
_Niciun fisier `tasks-*.jsonl` per faza (autoplan ruleaza review-urile inline, nu skill-urile standalone)._
Task-urile concrete = AC-urile din US-001..008 + cele 14 decizii din audit trail + 4 gap-urile din test plan.
### Status: DONE_WITH_CONCERNS
Concerns (de inchis inainte de executie, niciunul blocant): cele 3 taste decisions de la gate (T1 banner,
T2 buton confirma, T3 contact CUI) + integrarea celor 14 decizii in AC-urile US. Codex indisponibil -> review
single-voice; re-ruleaza dupa 2026-07-18 daca vrei al doilea unghi adversarial.

View File

@@ -0,0 +1,334 @@
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/feat-5.12-5.13-responsive-autoplan-restore-20260627-182914.md -->
# PRD 5.13 — Responsive compact (mobil/tableta) + sistem de butoane + design.md
**Stare**: DRAFT — pentru /autoplan (implementarea NU e facuta inca)
**Data**: 2026-06-27
**Sursa de design**: [docs/design.md](../design.md) (sursa de adevar pentru planurile de design)
> Nota: o sesiune anterioara a explorat o implementare + mockup-uri si a fost REVENITA
> (working tree readus la 5.12). Acest PRD ramane ca specificatie de planificat prin
> `/autoplan`. Sectiunile „Livrabile" / „Raport VERIFY" de mai jos descriu directia
> propusa si dovezile din explorare, NU stare livrata — re-validati prin autoplan.
## Context / problema
PRD 5.12 a marcat „responsive tableta/mobil" ca livrat, dar dogfooding-ul real
(screenshot-uri `localhost_8010_.png`, `step3-preview.png`, `tablet-820.png`,
`mobile-*.png`) a aratat ca paginile arata execrabil pe ecrane mici:
- **P0 break vertical**: in cardurile de Trimiteri pe mobil, eticheta lua `min-width:120px`
fix iar valoarea (nod text intr-un flex) se strangea la ~0 si `word-break:break-word`
o spargea **caracter cu caracter pe verticala** („B 0 7 5 8" pe coloana). Ilizibil.
- **P0 stepper import**: 4 coloane egale cu text + `overflow:hidden` → pe tableta „Confirma
trimiterea" era taiat; pe mobil 4 benzi minuscule cu text pe 3 randuri.
- **P0 tabel preview pe tableta** (7681024px): 8 coloane `table-layout:fixed` cu latimi
fixe storceau vehicul+operatie → text rupt.
- **P1 „afiseaza tot ca pe desktop"**: cardurile aratau toate cele 68 campuri, nu esentialul.
- **P1 butoane exagerate**: `.tabel-card td button { width:100%; min-height:44px }` facea
Salveaza + Sterge **doua blocuri full-width** unul sub altul; butoanele icon-only din
„Mapari salvate" aveau **iconite invizibile** (SVG mic intr-un bloc colorat mare).
- **P2**: mapare coloane cu scroll orizontal pe mobil; versiunea `vX.Y.Z` ocupa spatiu in
header pe mobil; bara sticky de confirmare se rupea necontrolat.
Feedback user pe mockup-uri (2026-06-27):
- Compactarea sa fie **si pe desktop** (ex. wizard-ul mai compact peste tot).
- Pe **desktop** butoanele Salveaza/Sterge = **doar text** (fara iconita).
- Pe **mobil** = iconite, dar un set modern, **recognoscibil** (Lucide stroke), nu cele vechi.
- Nevoie de un **design.md** pe care planurile de design sa-l foloseasca.
## Decizii (confirmate cu user prin AskUserQuestion + mockup-uri)
1. **Directie**: carduri compacte, esential vizibil, butoane mici. (D: „da, dar ajustez")
2. **Tableta (7681024px)**: tabelele actionabile devin **carduri, 2 pe rand** (grid).
3. **Scope**: pachet complet P0+P1+P2 + teste + acest PRD + ROADMAP + design.md.
4. **Butoane**: desktop = text; mobil = iconita patrata 44px, set **Lucide stroke** (contur).
5. **Wizard**: compact **peste tot** (inclusiv desktop): bara slim pe o linie; pe mobil
„Pasul N din 4 · Titlu" + bara de progres.
## Livrabile
### design.md (nou)
Sistemul de design: principii, tokeni (culoare 4 teme, tipografie IBM Plex, spatiere, radius,
elevatie), breakpoints (768/1024), scara de control (44/36/32), componente (butoane, card,
tabel→card, stepper, pill, formulare, modal), accesibilitate, si o sectiune „Pentru planurile
de design". Sursa de adevar; planurile pornesc de aici.
### Cod (doar CSS + markup template; backend NEATINS)
- **`_macros.html`**: macro `icon(name)` (Lucide save/trash/edit/plus, stroke) + `act_btn(label, ic, kind, attrs)`
(buton de actiune responsiv: desktop text / mobil iconita).
- **`base.html`** (CSS, inline):
- Sistem de butoane `.btn-secondary/.btn-ghost/.btn-danger/.btn-sm` + default primar imbunatatit
(font-weight 500, focus-visible).
- Sistem `.act` / `.act-save` / `.act-del` / `.act-group`: desktop = text, mobil = iconita 44px.
- Stepper compact `.stepper*` (track slim desktop/tableta; rezumat + bara progres mobil).
- Card mobil Trimiteri/Preview **rescris**: stivuit compact, vehicul = titlu, stare = pill,
`#`/checkbox ascunse, **fara gap fix de 120px** (fix break vertical).
- **Tableta 7681024px**: `.tabel-trimiteri` + `.tabel-card` → grid 2 carduri/rand.
- Versiune ascunsa pe mobil; bara sticky confirmare compacta; mapare coloane stivuita full-width.
- Coloana actiuni preview 92→104px + `.btn-editeaza { white-space:nowrap }`.
- **`_stepper.html`**: rescris pe clasele compacte (fara stiluri inline inalte).
- **`_mapari.html`**: butoanele icon-only inlocuite cu `act_btn` (salvate + reguli-text + formate-coloane).
### Teste
- `test_web_responsive.py`: aserturile existente pastrate (toate trec).
- `test_web_mapari_actiuni.py`: actualizat de la `.icon-btn` la sistemul `.act` (act-save/act-del,
aria-label pe fiecare, `.act-ic` prezent), docstring marcat „superseda 5.10".
## Invariante respectate
- Breakpoint unic 768px + densitate 1024px; un singur bloc `@media (max-width:767px) {` principal
pe care se bazeaza testele (regulile noi adaugate inauntru, nu in blocuri noi inaintea lui).
- Tabelele dense read-only (Jurnal/Nomenclator/Admin) raman scroll-contained, NU se cardifica.
- Backend trimitere (worker, masina stari, idempotenta, contract RAR, canal API, mapping,
validation) NEATINS. Zero schema. Pur CSS + markup.
## Raport VERIFY (live, app pornit cu DB seedata, Playwright 390/820/1280)
- **Trimiteri mobil 390**: carduri compacte, pill stare + vehicul bold + operatie+cod, fara
break vertical. Header compact, versiune ascunsa. PASS.
- **Trimiteri tableta 820**: grid 2 carduri/rand, fara scroll orizontal. PASS.
- **Trimiteri desktop 1280**: tabel complet neschimbat (fara regresie). PASS.
- **Mapari mobil 390**: Salveaza = iconita discheta albastra, Sterge = iconita cos rosu (Lucide,
patrate 44px, recognoscibile), NU blocuri full-width. PASS.
- **Wizard import**: compact pe o linie pe desktop (✓ Incarca · ✓ Coloane · 3 Verifica · 4 Confirma)
+ ajutor sub; pe mobil „Pasul 3 din 4 · Verifica" + bara progres. PASS.
- **Preview import mobil 390**: carduri compacte per rand + bara confirmare compacta. PASS.
- Regresie pytest: vezi ROADMAP (suita verde).
## Debt notat (neblocant)
- Duplicarea pill stare + `eticheta_problema` pe error/needs_* arata redundant in carduri
(„Eroare / Eroare") — logica de continut in `routes.py`/`labels.py`, nu responsive.
- Filtrele de data (Azi/7zile/30zile/Custom) stivuiesc full-width pe mobil (4 randuri); ar putea
fi grid 2x2 — imbunatatire viitoare.
---
<!-- ============================================================ -->
<!-- GSTACK REVIEW REPORT — /autoplan (subagent-only; codex usage-limited) -->
<!-- ============================================================ -->
# GSTACK REVIEW REPORT
> Pipeline: CEO -> Design -> Eng -> Final Gate (DX skipped: produs end-user, nu unealta de developer).
> Voci: Claude subagent independent pe fiecare faza. **Codex INDISPONIBIL** (usage limit
> OpenAI pana la 2026-07-18) -> toate fazele ruleaza `[subagent-only]`. Consensul nu poate
> fi "CONFIRMED de ambele modele"; o singura voce.
## Faza 1 — CEO (strategie & scope)
### CEO dual voices
- **Codex (CEO):** `[codex-unavailable: usage limit]`.
- **Claude subagent (CEO):** rulat foreground, independent. Findings:
- **F1 (high):** premisa "utilizarea pe mobil e reala" e asumata, nedovedita. Submiterea RAR
e data-entry din xlsx/csv la receptia service-ului = task desktop/tastatura. Wizardul
(upload -> mapare -> preview -> commit) e greu utilizabil pe telefon indiferent de CSS.
Niciun semnal real (analytics/cerere user) citat; doar screenshot-uri ale propriului render urat.
- **F2 (high):** reframe — durerea reala sunt bug-urile de layout de pe DESKTOP (break vertical,
stepper taiat, butoane full-width, iconite invizibile), nu "responsive". Tier 1 = bug-uri +
sistem butoane + design.md (valoare clara, zilnica). Tier 2 = cardificare mobil/tableta (speculativ).
- **F3 (medium):** "CSS inline intr-un singur <style>, no build" e enuntat ca axioma, fara a fi
cantarit vs alternative (Tailwind CDN, Pico.css). Pentru HTMX e o alegere aparabila, dar
netestata/nelintata -> un blob `<style>` in crestere unde o editare de media-query strica tacit alte ecrane.
- **F4 (critical, REZOLVAT partial):** risc de churn — re-implementare din proza. CEO recomanda
recuperarea commit-ului revertit. **Verificat in git reflog: nu exista commit 5.13** (a fost
explorare in working tree, revertita, niciodata commis). Deci nu e nimic de recuperat din git ->
singura cale e re-implementarea din PRD -> PRD-ul TREBUIE facut mai specific (vezi Eng §2/§5).
- **F5 (medium):** scenariul de regret la 6 luni — rewrite CSS multi-template pentru audienta cu
mobil ~zero; `<style>` se dubleaza; un viitor content-change reintroduce clasa de bug "break vertical"
pentru ca nimic structural nu o previne; modul "card pe tableta" nevazut de un user real. Ce NU va
parea prost: design.md + sistemul de butoane.
- **F6 (pozitiv):** design.md e bun (tokeni, ratii de contrast AA, gotcha CSS-var-in-@media). De
pastrat/commis independent de soarta lui 5.13.
### CEO consensus table
```
CEO DUAL VOICES — CONSENSUS TABLE (codex N/A: usage limit)
═══════════════════════════════════════════════════════════════
Dimension Claude Codex Consensus
──────────────────────────────────── ─────── ───── ─────────
1. Premises valid? NO (F1) N/A 1-voce: NU (mobil nedovedit)
2. Right problem to solve? PARTIAL N/A 1-voce: reframe la bug+butoane (F2)
3. Scope calibration correct? NO N/A 1-voce: full P0+P1+P2 supradimensionat
4. Alternatives explored? NO (F3) N/A 1-voce: framework nediscutat
5. Competitive/market risks? N/A N/A irelevant (tool intern B2B)
6. 6-month trajectory sound? PARTIAL N/A 1-voce: design.md+butoane da; cardificare risc
═══════════════════════════════════════════════════════════════
O singura voce -> nimic "CONFIRMED de ambele". Findings critice tratate ca atare oricum.
```
### NOT in scope (confirmat / deferat)
- Backend (worker, masina stari, idempotenta, contract RAR, canal API, mapping, validation): NEATINS. [PRD]
- Tabele dense read-only (Jurnal/Nomenclator/Admin): raman scroll-contained, NU se cardifica. [PRD]
- Refactor `routes.py`/`labels.py` pentru "Eroare/Eroare": deferat (debt). Dar vezi Design §2 — un guard
de template (pill-only cand eticheta == stare) e ieftin si in-scope.
- Adoptarea unui framework CSS: respins (P4 DRY + no-build potrivit stack-ului HTMX), dar de notat explicit ca decizie.
### What already exists (grounding pe cod real, nu pe "Raport VERIFY")
- `docs/design.md` (232 linii): **FACUT**, calitate buna. Deliverabilul "design.md" e in esenta livrat.
- Sistem butoane (`.act`/`.act-save`/`.btn-secondary`/`.btn-ghost`/`.btn-danger`/`.btn-sm`): **NEFACUT** (absent in base.html).
- Macro `icon()` / `act_btn()` + Lucide: **NEFACUT** (absent in `_macros.html`; doar `camp`+`autosend_toggle`).
- Stepper compact: **NEFACUT** (`_stepper.html` inca flex 4-coloane inline = exact anti-patternul P0).
- Grid 2 carduri/rand tableta 768-1024px: **NEFACUT** (blocul @media 1024 doar ascunde `.col-actualizat`).
- Card mobil Trimiteri (fara gap fix 120px): **NEFACUT** (`base.html:410-412` inca `td{display:flex}`+`::before{min-width:120px}`).
- "Raport VERIFY ... PASS" din PRD = din explorarea revertita, NU stare curenta. De NU tratat ca acceptanta.
### Dream-state delta
- CURRENT: 5.12 livrat (modal cont obligatoriu) + fundatie responsive 5.9/5.11; bug-uri P0 inca prezente in tree.
- THIS PLAN: compactare + butoane + cardificare mobil/tableta + design.md (deja partial).
- 12-MONTH IDEAL: un sistem de design tokenizat (design.md) aplicat consecvent, cu teste pe COMPORTAMENT
la breakpoint (nu doar string-match pe clase), astfel incat editarile CSS sa nu mai poata reverti tacit.
Delta: planul nu instituie teste pe comportament; ramane string-match fragil (vezi Eng §2).
### CEO completion summary
Plan corect ca directie de design, dar (a) supra-incadrat ca "responsive" cand miezul de valoare e
fix-bug-desktop + butoane + design.md; (b) premisa "mobil real" nedovedita; (c) re-implementare din
proza fara specificul CSS/test -> risc mare de a reverti din nou. design.md e cel mai durabil activ.
---
## DECIZIE PREMISA (gate user, 2026-06-27)
**Scope: pachet complet FARA grid 2-carduri/rand pe tableta.** Actionable lists (Trimiteri,
Preview, Mapari) raman **o coloana pana la 1024px**. Reverseaza Decizie #2 din PRD.
-> `design.md` §3 si §5.3 trebuie editate la "o coloana pana la 1024px" in acelasi PR.
-> Deliverabilul "grid 2/rand" si testul lui aferent SCOT din scope; se adauga guard ca raman 1 coloana.
## Faza 2 — Design (subagent-only; codex usage-limited)
### Design dual voices
- **Codex (design):** `[codex-unavailable: usage limit]`.
- **Claude subagent (design):** independent. Findings:
- **Meta-hazard (high):** sectiunea "Raport VERIFY ... PASS" se citeste ca raport de finalizare
desi munca e nelivrata -> relabel "Criterii de acceptanta (de dovedit)" sau sterge.
- **Ierarhie card mobil 7/10:** `vehicul=titlu, stare=pill, operatie+cod` corect. RISC pe ce se
ascunde: (a) `actualizat` (timestamp) = singurul semnal "se misca / e blocat?" pe mobil ->
pastreaza o linie meta 12px muted SAU garanteaza in modal si spune-o; (b) `checkbox` ascuns ->
omoara multi-select pe mobil; daca bulk "Trimite la RAR" e workflow real = regresie functionala
tacuta (high). PRD trebuie sa declare explicit daca bulk e desktop-only by design.
- **Stari lipsa (high):** empty/loading/error/partial ale listelor cardificate nespecificate
(fragmente HTMX-swapped). "Eroare/Eroare" (pill+eticheta) e cel mai vizibil exact in cardul nou
-> guard de TEMPLATE ieftin (pill-only cand `eticheta_problema == stare_text`), nu refactor routes.py.
- **Sistem iconite 8/10 (sound):** desktop-text/mobil-icon corect, rezolva "SVG invizibil in bloc".
Gap: (a) `act_btn` TREBUIE sa emita `aria-label={{label}}` in ramura icon-only (invariant de macro);
(b) Sterge fara confirmare pe 44px touch = risc data-loss la mis-tap -> confirm/undo pe `act-del`.
- **Stepper 7/10:** "Pasul N din 4" + bara progres clar. CONTRADICTIE tableta: design.md §3 = tableta
distincta, §5.4 baga "Tableta/mobil" impreuna in forma colapsata, iar PRD linia 5 zice "compact peste
tot". La 820px = track slim sau "Pasul N din 4"? De ales explicit.
- **Specificitate 9/10:** cel mai puternic punct; PRD referentiaza tokenii design.md. Contradictie minora:
stepper inline foloseste `rgba(91,141,239,.10)` hardcodat vs design.md §2.1 (color-mix obligatoriu).
### Design litmus scorecard
```
DESIGN LITMUS (codex N/A: usage limit)
═══════════════════════════════════════════════════════════════
Dimensiune Claude Codex Nota
────────────────────────────────── ────── ───── ──────────────
1. Ierarhie informatie 7/10 N/A risc pe campuri ascunse (timestamp/checkbox)
2. Stari (empty/loading/error) 3/10 N/A nespecificate (high)
3. Sistem iconite/butoane 8/10 N/A aria-label macro + confirm delete
4. Stepper / wizard 7/10 N/A contradictie tableta de rezolvat
5. Specificitate + aliniere md 9/10 N/A 1 culoare hardcodata de scos
6. Tabel->card responsive (2-up scos) N/A o coloana pana la 1024px (decizie user)
7. Accesibilitate (design.md §6) 8/10 N/A solid; lipseste pattern confirm distructiv
═══════════════════════════════════════════════════════════════
```
### Auto-decizii Design (6 principii)
- Guard template "Eroare/Eroare" (pill-only): **INCLUS** (P2 boil-lakes, <1 fisier, in cardul rescris). [TASTE -> gate]
- `act_btn` invariant aria-label in ramura icon-only: **INCLUS** (P1 completeness + a11y obligatoriu). [mecanic]
- Confirm/undo pe `act-del` mobil: **INCLUS** ca cerinta (P1; data-loss). Implementare = confirm nativ simplu, nu modal nou (P5). [TASTE -> gate]
- Linie meta `actualizat` pe card mobil (12px muted, timp relativ): **INCLUS** (P1; semnal "blocat"). [TASTE -> gate]
- Bulk-select pe mobil: **DECLARAT desktop-only by design** (P3 pragmatic; checkbox ascuns pe mobil ramane), de notat in PRD. [TASTE -> gate]
- Relabel "Raport VERIFY" -> "Criterii de acceptanta": **INCLUS** (mecanic, evita falsa finalizare).
- Rezolvare contradictie stepper tableta: **la 768-1024px = forma colapsata "Pasul N din 4"** (P5 explicit, consecvent cu mobil; track slim doar >=1024px). [mecanic]
- Scoatere culoare hardcodata stepper -> `color-mix(var(--accent))`: **INCLUS** (DRY/tokeni design.md §2.1). [mecanic]
## Faza 3 — Eng (subagent-only; codex usage-limited)
### Eng dual voices
- **Codex (eng):** `[codex-unavailable: usage limit]`.
- **Claude subagent (eng):** independent, grounded pe cod real. Findings:
- **§2 Fragilitate teste (CRITICAL):** vezi artefact test-plan. Invariantul PRD ("un singur bloc
@media principal") e FACTUAL GRESIT — exista DOUA blocuri (377, 404); testele feliaza ferestre
fixe `[idx:idx+5000]` de la PRIMA aparitie; rescrierea cardului impinge `min-height:0`/`100vw`
peste fereastra -> `test_header...` + `test_modal...` PIC. Cauza probabila a revert-ului.
FIX OBLIGATORIU INAINTE de CSS: refactor cele 2 teste sa ancoreze pe sentinel + slice pana la EOF.
- **§1 Arhitectura (medium):** `act_btn` randeaza si textul si SVG-ul inline, ascunzand unul per
breakpoint -> fiecare rand de tabel duce `<path>` Lucide chiar pe desktop (bloat DOM/octeti pe
toate viewporturile). Acceptabil (P5 simplu) sau `<use href="#sprite">` definit o data.
- **§3 Edge cases (medium):** VIN 17 car. (`_preview_rand.html:33` nowrap) — verifica sa nu produca
scroll orizontal la 360px dupa scoaterea gap-ului 120px.
- **§4 Teste (high):** `test_web_mapari_actiuni.py` are 3 aserturi pe `.icon-btn` (toate se rup);
lipsesc teste pentru: card mobil fara 120px (P0 fara guard!), `act_btn` aria-label, stepper compact.
Plus (post-decizie): guard ca actionable lists raman 1 coloana pana la 1024px (nu 2-up).
- **§5 Risc re-implementare (high):** PRD da intentie, nu CSS exact/offset-uri; fara fix-ul de teste
re-implementarea reverteaza din nou. `git reflog`: NU exista commit 5.13 de recuperat (explorare necommisa).
- **§6 Complexitate ascunsa:** `_stepper.html` e ~70 linii inline -> mutarea in base.html = CRESTERE
base.html; plaseaz-o DEPARTE de blocul mobil 404 ca sa nu strangi bugetul de octeti al ferestrei.
### Eng consensus table
```
ENG DUAL VOICES — CONSENSUS (codex N/A: usage limit)
═══════════════════════════════════════════════════════════════
Dimensiune Claude Codex Nota
────────────────────────────────── ────── ───── ─────────────────
1. Arhitectura sound? DA* N/A inline-CSS ok; SVG bloat minor
2. Test coverage suficient? NU N/A fragil + lipsuri (high)
3. Riscuri performanta? minor N/A SVG inline pe randuri
4. Securitate? N/A N/A pur CSS/markup, fara suprafata noua
5. Error paths? NU N/A stari card nespecificate (cu Design)
6. Risc deploy/regresie? RIDICAT N/A revert auto daca testele nu se repara intai
═══════════════════════════════════════════════════════════════
```
### Arhitectura (diagrama)
```
base.html (un singur <style>, no build)
├─ :root tokeni (design.md §2) [exista]
├─ .btn / .btn-secondary/.btn-ghost/.btn-danger/.btn-sm [DE ADAUGAT]
├─ .act / .act-save / .act-del / .act-group [DE ADAUGAT]
├─ .stepper* (track slim >=1024; colapsat <1024) [DE ADAUGAT, departe de blocul 404]
├─ @media (min-width:768px)and(max-width:1024px) { ... } [exista; FARA grid 2-up]
├─ @media (max-width:767px) { #377 mic } [exista — sursa fragilitatii]
└─ @media (max-width:767px) { #404 PRINCIPAL } [card rescris AICI, dupa header/modal]
_macros.html : icon(name) + act_btn(label,ic,kind,attrs) [DE ADAUGAT; aria-label invariant]
_stepper.html: rescris pe .stepper* (fara inline inalt) [DE RESCRIS]
_mapari.html : .icon-btn -> act_btn [DE MIGRAT]
TESTE : refactor ferestre fixe INAINTE de CSS [BLOCANT]
```
### Auto-decizii Eng (6 principii)
- Refactor cele 2 teste fragile INAINTE de CSS: **OBLIGATORIU, BLOCANT** (P1+P5; altfel revert garantat). [mecanic]
- Corectare invariant PRD "un singur bloc @media": **CORECTAT** (sunt doua; regula reala = adauga dupa header/modal + slice EOF). [mecanic]
- SVG inline vs `<use>` sprite: **inline acum** (P5 explicit/simplu); sprite notat ca optimizare. [TASTE -> gate]
- Teste noi (#2 card fara 120px, #3 act_btn aria, #4 stepper, #5 window-guard, +1-coloana guard): **INCLUSE** (P1). [mecanic]
- "Aserturile existente pastrate (toate trec)" din PRD: **CORECTAT** la "refactor + pastreaza intentia" (imposibil ca atare). [mecanic]
## Decision Audit Trail
| # | Faza | Decizie | Clasif. | Principiu | Rationament |
|---|------|---------|---------|-----------|-------------|
| 1 | CEO | Scope: complet FARA grid 2/rand tableta | GATE user | - | user a ales la premise gate; ambele voci design contestau 2-up |
| 2 | CEO | Backend NEATINS, pur CSS/markup | mecanic | P4 | confirmat de PRD; zero schema |
| 3 | CEO | Fara framework CSS (inline ramane) | mecanic | P4/P5 | no-build potrivit HTMX; notat ca decizie |
| 4 | CEO | Commit revertit de recuperat? NU exista | mecanic | - | git reflog: explorare necommisa; re-impl din PRD |
| 5 | Design | Guard template "Eroare/Eroare" pill-only | taste | P2 | in cardul rescris, <1 fisier |
| 6 | Design | act_btn aria-label invariant icon-only | mecanic | P1 | a11y obligatoriu design.md §6 |
| 7 | Design | Confirm/undo pe act-del mobil | taste | P1 | data-loss la mis-tap 44px |
| 8 | Design | Linie meta `actualizat` pe card mobil | taste | P1 | singurul semnal "blocat" pe mobil |
| 9 | Design | Bulk-select = desktop-only by design | taste | P3 | checkbox ascuns pe mobil; de declarat |
| 10 | Design | Relabel "Raport VERIFY" -> "acceptanta" | mecanic | - | evita falsa finalizare |
| 11 | Design | Stepper 768-1024 = forma colapsata | mecanic | P5 | consecvent cu mobil; track slim >=1024 |
| 12 | Design | Stepper color-mix in loc de hardcodat | mecanic | P4 | design.md §2.1 tokeni |
| 13 | Eng | Refactor teste fragile INAINTE de CSS | mecanic | P1/P5 | BLOCANT; cauza probabila revert |
| 14 | Eng | SVG inline acum (sprite deferat) | taste | P5 | simplu > optimizare prematura |
| 15 | Eng | 5 teste noi + migrare mapari | mecanic | P1 | acopera P0 + a11y + scope guard |
| 16 | Eng | design.md §3/§5.3 -> "1 coloana <=1024" | mecanic | - | consecventa cu decizia de scope |
## Cross-Phase Themes
- **"Raport VERIFY se citeste ca facut, dar nu e"** — semnalat de Design (meta-hazard) SI implicit de
Eng (§5 re-impl). Semnal high-confidence: relabel + grounding pe cod real, nu pe raport.
- **Stari/erori nespecificate** — Design §2 (stari card) + Eng §4 (error paths). De adaugat o matrice de stari.
- **Fragilitate -> revert** — Eng §2 (teste) + CEO F5 (nimic structural nu previne re-bug). Repara testele intai.
## Eng completion summary
Directia de design e fina; planul livreaza DOAR daca fragilitatea testelor (§2) e rezolvata INTAI
(refactor cele 2 teste pe ancora+EOF), altfel rescrierea cardului mobil reverteaza singura din nou.
Invariantul PRD despre "un singur bloc @media" e gresit si trebuie corectat. design.md ramane activul durabil.

View File

@@ -11,12 +11,26 @@ variabila exportata explicit in shell. Testele care chiar verifica enforcement-u
(auth pornit, creds <test>) il seteaza punctual prin `monkeypatch`/`object.__setattr__`.
"""
import hashlib
import os
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
def make_test_cui(seed: str = "") -> str:
"""Factory centralizat (D#14, PRD 5.12 US-001): genereaza un CUI de test unic din seed.
Folosit de fixture-urile de test care creeaza conturi via /signup sau create_account
si au nevoie de un CUI unic per test (altfel unicitatea CUI-ului bloca al doilea signup
cu acelasi seed in acelasi DB de test).
Formatul 'ROTE' + 8 hex-uri e suficient de unic per DB de test (izolata per test).
"""
h = hashlib.md5(seed.encode()).hexdigest()[:8].upper()
return f"ROTE{h}"
def pytest_configure(config):
"""Markeri custom. `live` = teste care ating endpoint-ul real RAR (opt-in,
skip implicit; vezi tests/test_live_rar.py). Excludere: `-m 'not live'`."""

View File

@@ -112,4 +112,61 @@ def test_list_accounts_ordonat_fara_creds(conn):
assert ids == sorted(ids)
for r in rows:
assert "rar_creds_enc" not in r
assert set(r.keys()) == {"id", "name", "cui", "active", "status", "created_at"}
assert set(r.keys()) == {"id", "name", "cui", "email", "active", "status", "created_at"}
# ---------------------------------------------------------------------------
# US-001 (PRD 5.12): accounts.email + validari companie/email/CUI
# ---------------------------------------------------------------------------
def test_create_account_fara_email_ridica(conn):
"""create_account cu email="" ridica ValueError (email gol nu e acceptat)."""
from app.accounts import create_account
with pytest.raises(ValueError, match="email"):
create_account(conn, "Service X", cui="RO100", email="")
def test_create_account_fara_cui_ridica(conn):
"""create_account cu cui="" ridica ValueError (CUI gol nu e acceptat)."""
from app.accounts import create_account
with pytest.raises(ValueError, match="[Cc][Uu][Ii]|cod unic"):
create_account(conn, "Service X", cui="", email="test@test.com")
def test_email_normalizat_lowercase_trim(conn):
"""email e normalizat: trim + lower."""
from app.accounts import create_account
acct_id = create_account(conn, "Service X", cui="RO200", email=" Test@EXAMPLE.Com ")
row = conn.execute("SELECT email FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["email"] == "test@example.com"
def test_migrare_adauga_coloana_email_idempotent(conn):
"""_migrate e idempotent: ruleaza de doua ori fara eroare si coloana email exista."""
from app.db import _migrate
_migrate(conn) # a doua rulare (prima e in init_db)
cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "email" in cols
def test_account_is_complete_false_pe_legacy_incomplet(conn):
"""account_is_complete() returneaza False pe cont fara email sau fara CUI."""
from app.accounts import create_account, account_is_complete
# cont fara email si fara CUI
acct_id = create_account(conn, "Service Legacy")
row = conn.execute("SELECT * FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert account_is_complete(row) is False
# cont fara email, cu CUI
acct_id2 = create_account(conn, "Service Cu CUI", cui="RO300")
row2 = conn.execute("SELECT * FROM accounts WHERE id=?", (acct_id2,)).fetchone()
assert account_is_complete(row2) is False
# cont complet (cu email si CUI si name)
acct_id3 = create_account(conn, "Service Complet", cui="RO301", email="x@y.com")
row3 = conn.execute("SELECT * FROM accounts WHERE id=?", (acct_id3,)).fetchone()
assert account_is_complete(row3) is True
# contul sistem id=1 e EXCEPTAT (returneaza True indiferent)
row_sys = conn.execute("SELECT * FROM accounts WHERE id=1").fetchone()
assert account_is_complete(row_sys) is True

View File

@@ -36,8 +36,10 @@ def _csrf(client, url="/admin"):
def _signup(client, name, email, password="parola_test_001"):
from tests.conftest import make_test_cui
tok = _csrf(client, "/signup")
resp = client.post("/signup", data={"name": name, "email": email, "parola": password,
resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email),
"email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
assert resp.status_code == 200
from app.db import get_connection

View File

@@ -55,9 +55,11 @@ def _get_csrf(client: TestClient, url: str) -> str:
def _signup(client: TestClient, name: str, email: str, password: str = "parola_test_001") -> int:
"""Creeaza cont via POST /signup si intoarce account_id."""
from tests.conftest import make_test_cui
token = _get_csrf(client, "/signup")
resp = client.post("/signup", data={
"name": name,
"cui": make_test_cui(email),
"email": email,
"parola": password,
"csrf_token": token,
@@ -211,3 +213,51 @@ def test_activate_fara_csrf_403(client):
assert resp.status_code == 403, (
f"POST fara CSRF trebuia 403, got {resp.status_code}"
)
def test_activare_cont_incomplet_refuzata(client):
"""Admin nu poate activa un cont incomplet (fara email/CUI) — contul ramane pending.
Gate pe account_is_complete: un cont fara companie+email+CUI nu poate fi activat
de admin (buton dezactivat in UI + server refuza activarea).
"""
# Cream cont pending INCOMPLET direct prin create_account (fara email/CUI)
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
incomplete_id = create_account(conn, "Firma Incompleta SRL", active=False)
create_user(conn, incomplete_id, "incompleta@test.ro", "parola_test_001")
finally:
conn.close()
# Admin
admin_id = _signup(client, "Admin Gate SA", "admin_gate@test.ro")
_make_admin(admin_id)
_login(client, "admin_gate@test.ro")
# Obtine CSRF din /admin
resp = client.get("/admin")
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 in /admin"
csrf = m.group(1)
# Incearca sa activeze contul incomplet
resp2 = client.post("/admin/activate", data={
"account_id": str(incomplete_id),
"csrf_token": csrf,
})
# Fie 303 redirect, fie pagina cu eroare — important: contul NU e activat
assert resp2.status_code in (200, 303, 422), (
f"Raspuns neasteptat: {resp2.status_code}"
)
# Verifica in DB: contul ramane pending (neactivat)
assert not _get_account_active(incomplete_id), (
"Contul incomplet (fara email/CUI) a fost activat — gate pe account_is_complete nu functioneaza"
)

View File

@@ -143,7 +143,8 @@ def test_preview_arata_panoul_de_mapare(client):
assert r.status_code == 200
assert "Operatii de mapat la cod RAR" in r.text
assert "OP-REV" in r.text
assert "/_import/%d/mapare-operatie" % import_id in r.text
# US-004: panoul foloseste ruta plurala (un singur form pentru toate operatiile)
assert "/_import/%d/mapare-operatii" % import_id in r.text
# --------------------------------------------------------------------------- #

618
tests/test_import_review.py Normal file
View File

@@ -0,0 +1,618 @@
"""Teste US-007 (PRD 5.12): gate review in modal — import_rows.reviewed + confirmare.
TDD RED: testele sunt scrise inainte de implementare.
Scenarii:
1. Migrare: import_rows.reviewed INTEGER DEFAULT 0, idempotent.
2. Rand needs_review exclus din "gata de trimis" pana la confirmare explicita.
3. POST confirma-review seteaza reviewed=1 → randul devine ok la recalcul.
4. `reviewed` NU intra in payload/idempotency (marcaj separat).
5. Editarea unei valori pe un rand confirmat reseteaza reviewed=0 (D#9).
"""
from __future__ import annotations
import io
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client fara autentificare web obligatorie (cont 1 implicit)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "ir.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _seed_op1(account_id: int = 1) -> None:
"""Semeaza nomenclator + mapare OP-1 → R-FRANE."""
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 (?, 'OP-1', 'R-FRANE', 1)",
(account_id,),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
import csv as _csv
buf = io.StringIO()
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
# Rand care declanseaza needs_review: data in format ambiguu (DD.MM.YYYY cu zi<=12)
# si format_data=None -> col_fmt="ambiguous" -> is_ambiguous_date=True
_ROWS_NEEDS_REVIEW = [
{
"VIN": "WVWZZZ1KZAW000123",
"Nr": "B001TST",
"Data": "05.06.2026", # Format ambiguu: zi=5 <= 12, luna=6 <= 12
"KM": "123456",
"Operatie": "OP-1",
},
]
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _get_csrf(client: TestClient) -> str:
r = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text)
return m.group(1) if m else ""
def _upload_and_preview_needs_review(client: TestClient) -> int:
"""Upload CSV cu data ambigua + salveaza mapare fara format_data → preview.
format_data=None → col_fmt='ambiguous' → data '05.06.2026' → is_ambiguous=True
→ flag needs_review in _resolve_row_for_preview.
Intoarce import_id.
"""
rows = _ROWS_NEEDS_REVIEW
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _get_csrf(client)
# IMPORTANT: format_data="" (gol/None) → date_col_format={} → col_fmt="ambiguous"
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": "", # fara format -> ambiguous
"csrf_token": csrf2,
})
assert r2.status_code == 200, r2.text
return iid
def _get_reviewed(import_id: int, row_index: int) -> int:
"""Citeste valoarea reviewed din DB pentru un rand."""
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute(
"SELECT reviewed FROM import_rows WHERE batch_id=? AND row_index=?",
(import_id, row_index),
).fetchone()
return int(row["reviewed"]) if row else -1
finally:
conn.close()
def _get_idempotency_key(import_id: int, row_index: int) -> str | None:
"""Citeste cheia de idempotenta a unui rand prin endpoint-ul de preview."""
from app.db import get_connection
from app.crypto import decrypt_creds
from app.api.v1.import_router import (
_resolve_row_for_preview, _signature, _build_idempotency_key,
)
import json
from app.mapping import account_or_default, load_mapping_meta, load_nomenclator_codes, load_text_rules
from app.import_parse import parse_date_value
conn = get_connection()
try:
acct = account_or_default(1)
row_db = conn.execute(
"SELECT raw_json, override_json, reviewed FROM import_rows "
"WHERE batch_id=? AND row_index=?",
(import_id, row_index),
).fetchone()
if not row_db:
return None
raw = decrypt_creds(row_db["raw_json"]) or {}
ov = decrypt_creds(row_db["override_json"]) if row_db["override_json"] else None
col_names = list(raw.keys())
sig = _signature(col_names)
mapping_row = conn.execute(
"SELECT json_mapare, format_data FROM column_mappings "
"WHERE account_id=? AND signature_coloane=?",
(acct, sig),
).fetchone()
if not mapping_row:
return None
json_mapare = json.loads(mapping_row["json_mapare"])
format_data = mapping_row["format_data"]
date_col_format: dict[str, str] = {}
if format_data:
for col_f, camp_c in json_mapare.items():
if camp_c == "data_prestatie":
date_col_format[col_f] = format_data
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
info = _resolve_row_for_preview(
raw_row=raw,
json_mapare=json_mapare,
date_col_format=date_col_format,
coercion_flags=[],
mapping=mapping,
mapping_meta=mapping_meta,
formula_columns=[],
override=ov or None,
valid_codes=valid_codes,
text_rules=text_rules,
reviewed=bool(row_db["reviewed"]),
)
try:
return _build_idempotency_key(1, info["resolved"])
except Exception:
return None
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_migrare_adauga_coloana_reviewed_idempotent():
"""_migrate adauga coloana import_rows.reviewed INTEGER DEFAULT 0.
Idempotent: a doua invocare nu ridica eroare.
"""
tmp = tempfile.mkdtemp()
import os
os.environ["AUTOPASS_DB_PATH"] = os.path.join(tmp, "m.db")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
try:
from app.db import get_connection, _migrate
# Initializare DB (inclusiv _migrate)
from app.db import init_db
init_db()
conn = get_connection()
try:
cols = {r["name"] for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()}
assert "reviewed" in cols, \
"import_rows trebuie sa aiba coloana 'reviewed' dupa init_db()"
# Verifica DEFAULT 0
row_info = {r["name"]: r for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()}
reviewed_col = row_info.get("reviewed")
assert reviewed_col is not None
# dflt_value poate fi "0" sau 0 in functie de SQLite versiune
assert str(reviewed_col["dflt_value"]) == "0", \
f"import_rows.reviewed trebuie sa aiba DEFAULT 0, got: {reviewed_col['dflt_value']}"
# Idempotenta: a doua invocare a _migrate nu ridica eroare
_migrate(conn) # no exception
finally:
conn.close()
finally:
del os.environ["AUTOPASS_DB_PATH"]
get_settings.cache_clear()
reset_cache()
def test_needs_review_exclus_din_gata_pana_la_confirmare(client):
"""Un rand needs_review nu intra in 'gata de trimis' (n_confirmat = 0) pana la confirmare.
US-007 Q1: randul cu data ambigua apare cu pill 'Verifica valori' si este
EXCLUS din n_confirmat. Bannerul de discoverability (T1) trebuie sa fie prezent.
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Verifica preview via GET /_import/{id}/preview
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200, r.text
html = r.text
# Randul are starea needs_review
assert "needs_review" in html, \
"Randul cu data ambigua trebuie sa apara cu starea needs_review in preview"
# Pill-ul cu eticheta "Verifica valori"
assert "Verifica valori" in html or "verifica valori" in html.lower(), \
"Pill-ul 'Verifica valori' trebuie sa apara pentru randul needs_review"
# n_confirmat = 0 (randul NU e in ok)
# Cautam valoarea campului n_confirmat (value="0")
n_match = re.search(r'id="n-confirmat"[^>]*value="(\d+)"', html) or \
re.search(r'name="n_confirmat"[^>]*value="(\d+)"', html)
if n_match:
n_val = int(n_match.group(1))
assert n_val == 0, \
f"n_confirmat trebuie sa fie 0 cand randul e needs_review (negasit), got {n_val}"
# Bannerul de discoverability (T1) — prezent cand summary.needs_review > 0
# Trebuie sa contina un mesaj despre faptul ca randurile nu pleaca pana la confirmare
# (ex. 'nu pleaca la RAR' sau 'confirmi in modal' sau 'Verifica valori')
banner_present = (
"nu pleaca" in html.lower() or
"confirmi in modal" in html.lower() or
"preview-needs-review-banner" in html
)
assert banner_present, \
"Bannerul de discoverability (T1) trebuie sa fie prezent cand exista randuri needs_review"
def test_confirmare_in_modal_seteaza_reviewed_si_devine_ok(client):
"""POST /_import/{id}/rand/0/confirma-review seteaza reviewed=1.
US-007 T2: operatorul apasa 'Confirma valorile' in modal →
reviewed=1 in DB → randul devine ok la recalcul preview.
Verifica:
- Raspuns 200
- reviewed=1 in DB
- Raspuns contine OOB cu pill 'Gata de trimis' (starea ok)
- Header HX-Trigger-After-Settle: inchideModal
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Verifica ca randul e needs_review inainte de confirmare
assert _get_reviewed(iid, 0) == 0, "reviewed trebuie sa fie 0 inainte de confirmare"
# POST confirma-review
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
# reviewed=1 in DB
assert _get_reviewed(iid, 0) == 1, \
"reviewed trebuie sa fie 1 in DB dupa confirmare"
# Raspuns contine OOB cu randul actualizat
html = r.text
assert 'id="preview-row-0"' in html or "preview-row-0" in html, \
"Raspunsul trebuie sa contina randul actualizat (OOB)"
# Starea a devenit ok
assert "Gata de trimis" in html or "s-ok" in html, \
"Dupa confirmare, randul trebuie sa fie ok (pill 'Gata de trimis')"
# Modal se inchide
trigger = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" in trigger, \
f"confirma-review trebuie sa emita inchideModal, got: '{trigger}'"
def test_reviewed_nu_intra_in_payload_sau_idempotency(client):
"""reviewed NU intra in payload, override_json sau cheia de idempotenta.
US-007 marcaj separat (D#8): reviewed e DOAR un flag de confirmare umana,
nu un camp de continut. Cheia de idempotenta trebuie sa fie identica inainte
si dupa confirmare.
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Cheia inainte de confirmare
key_before = _get_idempotency_key(iid, 0)
assert key_before is not None, "Cheia de idempotenta trebuie sa existe inainte de confirmare"
# Confirma
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
# Cheia dupa confirmare
key_after = _get_idempotency_key(iid, 0)
assert key_after is not None, "Cheia de idempotenta trebuie sa existe dupa confirmare"
assert key_before == key_after, \
f"Cheia de idempotenta NU trebuie sa se schimbe la confirmare! " \
f"Inainte: {key_before}, dupa: {key_after}. " \
"'reviewed' NU trebuie sa intre in payload sau cheia de idempotenta."
# Verifica ca 'reviewed' nu e in override_json
from app.db import get_connection
from app.crypto import decrypt_creds
conn = get_connection()
try:
row = conn.execute(
"SELECT override_json FROM import_rows WHERE batch_id=? AND row_index=?",
(iid, 0),
).fetchone()
if row and row["override_json"]:
ov = decrypt_creds(row["override_json"]) or {}
assert "reviewed" not in ov, \
"'reviewed' NU trebuie sa fie in override_json (camp separat, nu camp de continut)"
finally:
conn.close()
def test_editare_valoare_pe_needs_review_reseteaza_reviewed(client):
"""Editarea unei valori pe un rand confirmat reseteaza reviewed=0 (D#9).
Cand operatorul SCHIMBA o valoare (via POST editeaza) pe un rand deja confirmat
(reviewed=1), apply_row_override trebuie sa reseteze reviewed=0.
→ Randul se intoarce in starea needs_review si cere re-confirmare.
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Confirma randul
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
assert _get_reviewed(iid, 0) == 1, "reviewed trebuie sa fie 1 dupa confirmare"
# Editeaza o valoare (odometru_final)
csrf2 = _get_csrf(client)
r2 = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"odometru_final": "200000",
"csrf_token": csrf2,
})
assert r2.status_code == 200, r2.text
# reviewed trebuie sa fie 0 (resetat de apply_row_override)
assert _get_reviewed(iid, 0) == 0, \
"reviewed trebuie sa fie resetat la 0 dupa editarea unei valori (D#9 — re-cere confirmare)"
def test_confirma_review_guard_committed_409(client):
"""POST confirma-review pe batch deja comis → 409 (guard committed)."""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Marcheaza batch ca committed
from app.db import get_connection
conn = get_connection()
try:
conn.execute("UPDATE import_batches SET status='committed' WHERE id=?", (iid,))
conn.commit()
finally:
conn.close()
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 409, \
f"confirma-review pe batch committed trebuie sa returneze 409, got {r.status_code}"
def test_confirma_review_scoped_404_alt_cont():
"""POST confirma-review pe un rand al altui cont → 404 (scoping JOIN)."""
tmp = tempfile.mkdtemp()
env_patch = {
"AUTOPASS_DB_PATH": os.path.join(tmp, "scope.db"),
"AUTOPASS_WEB_AUTH_REQUIRED": "true",
}
for k, v in env_patch.items():
os.environ[k] = v
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
try:
with TestClient(app, follow_redirects=False) as c:
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user
conn = get_connection()
try:
acct1 = create_account(conn, "Firma A", active=True)
create_user(conn, acct1, "userA@test.com", "parola123secure")
acct2 = create_account(conn, "Firma B", active=True)
create_user(conn, acct2, "userB@test.com", "parola123secure")
finally:
conn.close()
# Semeaza operatii pentru acct1
from app.db import get_connection as gcn
conn2 = gcn()
try:
conn2.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
"VALUES ('R-FRANE','Reparatie frane')"
)
conn2.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, 'OP-1', 'R-FRANE', 1)", (acct1,)
)
conn2.commit()
finally:
conn2.close()
# Login ca userA, creeaza batch
def _login(client, email, pwd="parola123secure"):
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
client.post("/login", data={
"email": email, "parola": pwd, "csrf_token": m.group(1),
})
_login(c, "userA@test.com")
rows = _ROWS_NEEDS_REVIEW
csv_data = _csv_bytes(rows)
def _csrf():
r = c.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text)
return m.group(1) if m else ""
csrf = _csrf()
r = c.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _csrf()
c.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames, "canon": canons,
"format_data": "", "csrf_token": csrf2,
})
# Login ca userB si incearca confirma-review pe batch-ul lui A
_login(c, "userB@test.com")
csrf3 = _csrf()
r2 = c.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf3})
assert r2.status_code == 404, \
f"confirma-review cross-account trebuie sa returneze 404, got {r2.status_code}"
finally:
for k in env_patch:
if k in os.environ:
del os.environ[k]
ratelimit._hits.clear()
get_settings.cache_clear()
reset_cache()
# --------------------------------------------------------------------------- #
# Teste markup Bug 1: confirma-review form swap (B1) #
# --------------------------------------------------------------------------- #
def test_confirma_review_form_nu_foloseste_hx_swap_none():
"""Bug B1 (markup): formularul confirma-review NU trebuie sa foloseasca
hx-swap='none' — cu none, htmx aplica doar OOB-urile dar NU executa scriptul
din continutul principal → updateN() nu ruleaza → n_confirmat stale → 422.
Forma corecta: hx-target='#detaliu-modal-body' + hx-swap='innerHTML'.
"""
import pathlib
template = (
pathlib.Path(__file__).parent.parent
/ "app/web/templates/_editare_preview_modal.html"
)
html = template.read_text(encoding="utf-8")
# Gasim sectiunea formularului confirma-review (dupa marcajul T2)
idx = html.find("confirma-review")
assert idx >= 0, "Formularul confirma-review nu a fost gasit in template"
form_section = html[idx:]
# NU trebuie sa existe hx-swap="none" pe formularul confirma-review
assert 'hx-swap="none"' not in form_section, (
"Formularul confirma-review NU trebuie sa foloseasca hx-swap='none'. "
"Cu none, scriptul updateN() nu ruleaza → n_confirmat stale → gate 422."
)
# TREBUIE sa tinteasca #detaliu-modal-body cu innerHTML
assert 'hx-target="#detaliu-modal-body"' in form_section, (
"Formularul confirma-review trebuie sa aiba hx-target='#detaliu-modal-body' "
"ca scriptul updateN sa fie executat (identic cu formularul editeaza)."
)
assert 'hx-swap="innerHTML"' in form_section, (
"Formularul confirma-review trebuie sa aiba hx-swap='innerHTML' "
"ca scriptul updateN sa fie executat."
)
def test_confirma_review_raspuns_contine_script_updateN(client):
"""Bug B1 (functional): raspunsul POST confirma-review contine scriptul
updateN in payload-ul principal (nu doar OOB), astfel ca htmx il va executa
cand face swap in #detaliu-modal-body.
Verifica:
- Raspuns 200
- Raspunsul contine 'window.updateN' (scriptul de recalcul contor)
- Raspunsul contine 'updateN' inainte de ultimul OOB-element (@script tag nu e OOB)
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
html = r.text
# Scriptul trebuie sa fie in raspuns
assert "window.updateN" in html or "updateN" in html, (
"Raspunsul confirma-review trebuie sa contina scriptul updateN "
"pentru ca htmx sa-l execute la swap in #detaliu-modal-body."
)
# Scriptul NU trebuie sa aiba hx-swap-oob (altfel nu ar fi executat nici asa)
script_idx = html.rfind("<script>")
assert script_idx >= 0, "Tag-ul <script> nu a fost gasit in raspuns"
script_content = html[script_idx:]
assert "hx-swap-oob" not in script_content, (
"Scriptul updateN NU trebuie sa aiba hx-swap-oob — trebuie sa fie in "
"continutul principal pentru executie."
)

203
tests/test_signup.py Normal file
View File

@@ -0,0 +1,203 @@
"""Teste US-001 (PRD 5.12): companie/email/CUI obligatorii la signup.
TDD strict: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_signup_us001.db"))
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _csrf(html: str) -> str:
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', html)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', html)
assert m, "csrf_token negasit in HTML"
return m.group(1)
def test_signup_html_cui_obligatoriu_ui(client):
"""GET /signup: campul CUI NU contine '(optional)' si are atribut required (US-001 UI)."""
resp = client.get("/signup")
assert resp.status_code == 200
# (a) NU trebuie sa apara textul "(optional)" langa CUI
assert "(optional)" not in resp.text, "Campul CUI afiseaza '(optional)' — trebuie sa fie obligatoriu"
# (b) input[name=cui] trebuie sa aiba atribut required
assert 'name="cui"' in resp.text
# cautam required pe aceeasi linie cu name="cui" sau intr-un bloc care contine name="cui" required
import re
# fie pe aceeasi linie: <input ... name="cui" ... required ...>
# fie in orice forma cu required si name="cui" in acelasi tag
cui_input_match = re.search(r'<input[^>]*name="cui"[^>]*>', resp.text)
assert cui_input_match, "input name='cui' negasit in HTML"
assert "required" in cui_input_match.group(0), (
f"input[name='cui'] NU are atribut required: {cui_input_match.group(0)}"
)
def test_signup_fara_cui_422(client):
"""POST /signup fara CUI -> 422, formular re-randat cu eroare, fara cont creat."""
resp = client.get("/signup")
token = _csrf(resp.text)
resp = client.post("/signup", data={
"name": "Service Fara CUI",
"cui": "",
"email": "fara_cui@test.com",
"parola": "parolasecreta123",
"csrf_token": token,
})
# trebuie sa returneze 422 (sau sa randeze formularul cu eroare)
assert resp.status_code == 422
# cheia API nu trebuie sa apara
assert "rfak_" not in resp.text
# campul name trebuie sa fie pastrat (form re-render cu valorile existente)
assert "Service Fara CUI" in resp.text
# verifica ca nu s-a creat niciun cont
from app.db import get_connection
conn = get_connection()
try:
n = conn.execute(
"SELECT COUNT(*) AS n FROM accounts WHERE name='Service Fara CUI'"
).fetchone()["n"]
assert n == 0, "Cont creat desi CUI lipsea"
finally:
conn.close()
def test_signup_scrie_email_pe_account(client):
"""POST /signup valid -> accounts.email = emailul utilizatorului."""
resp = client.get("/signup")
token = _csrf(resp.text)
resp = client.post("/signup", data={
"name": "Service Cu Email",
"cui": "RO9999001",
"email": "cu_email@test.com",
"parola": "parolasecreta123",
"csrf_token": token,
})
assert resp.status_code == 200
assert "rfak_" in resp.text
from app.db import get_connection
conn = get_connection()
try:
acct = conn.execute(
"SELECT * FROM accounts WHERE name='Service Cu Email'"
).fetchone()
assert acct is not None
# emailul trebuie scris pe cont (normalizat: lower + trim)
assert acct["email"] == "cu_email@test.com"
finally:
conn.close()
def test_signup_email_duplicat_mesaj_email(client):
"""POST /signup cu email existent dar CUI nou -> mesaj despre EMAIL, NU despre CUI/firma.
Bug: 'email deja folosit' contine 'deja folosit' -> era prins de conditia CUI duplicat
si afisa gresit 'Aceasta firma (CUI X) e deja inregistrata' (CUI nou, NU cauza reala).
Fix: verifica intai email-ul, apoi CUI-ul.
"""
from tests.conftest import make_test_cui
# primul signup cu email E + CUI C1
resp = client.get("/signup")
token = _csrf(resp.text)
resp1 = client.post("/signup", data={
"name": "Firma Prima SRL",
"cui": make_test_cui("email-dup-c1"),
"email": "emaildup@test.com",
"parola": "parolasecreta123",
"csrf_token": token,
})
assert resp1.status_code == 200
assert "rfak_" in resp1.text, "Primul signup trebuia sa reuseasca"
# al doilea signup cu ACELASI email dar CUI NOU
resp = client.get("/signup")
token2 = _csrf(resp.text)
cui_nou = make_test_cui("email-dup-c2")
resp2 = client.post("/signup", data={
"name": "Firma A Doua SRL",
"cui": cui_nou,
"email": "emaildup@test.com",
"parola": "parolasecreta456",
"csrf_token": token2,
})
assert resp2.status_code in (200, 422)
assert "rfak_" not in resp2.text, "Nu trebuia creata cheie API la email duplicat"
body_lower = resp2.text.lower()
# mesajul trebuie sa se refere la EMAIL
assert "email" in body_lower, (
f"Mesajul de eroare nu mentioneaza 'email': {resp2.text[:500]}"
)
# mesajul NU trebuie sa afiseze pattern-ul gresit cu firma si CUI-ul nou
# (CUI-ul apare legitim si in campul pre-completat al formularului, dar nu in mesajul de eroare)
wrong_pattern = f"(cui {cui_nou.lower()}) e deja inregistrata"
assert wrong_pattern not in body_lower, (
f"Mesajul arata gresit pattern-ul CUI-duplicat desi problema e emailul: {resp2.text[:500]}"
)
# nu trebuie sa apara mesajul specific CUI-duplicat
assert "e deja inregistrata" not in body_lower, (
f"Mesajul arata 'e deja inregistrata' (mesaj CUI) la eroare de email: {resp2.text[:500]}"
)
def test_signup_cui_existent_mesaj_prietenos(client):
"""POST /signup cu CUI existent -> mesaj prietenos, NU mesaj tehnic cu 'activate --account'."""
resp = client.get("/signup")
token = _csrf(resp.text)
# primul signup
client.post("/signup", data={
"name": "Firma Existenta SRL",
"cui": "RO8888001",
"email": "firma1@test.com",
"parola": "parolasecreta123",
"csrf_token": token,
})
# al doilea signup cu acelasi CUI
resp = client.get("/signup")
token2 = _csrf(resp.text)
resp2 = client.post("/signup", data={
"name": "Alt Utilizator SRL",
"cui": "RO8888001",
"email": "firma2@test.com",
"parola": "parolasecreta456",
"csrf_token": token2,
})
assert resp2.status_code in (200, 422)
# NU trebuie sa apara mesajul tehnic cu referinta la CLI
assert "activate --account" not in resp2.text
# trebuie sa apara un mesaj prietenos cu CUI-ul
assert "RO8888001" in resp2.text
# trebuie sa contina cuvant cheie de tip "firma" sau "inregistrata"
body_lower = resp2.text.lower()
assert any(kw in body_lower for kw in ["firma", "inregistrat", "cont", "acces"])

View File

@@ -45,9 +45,11 @@ def _csrf(c: TestClient) -> str:
def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecreta") -> object:
from tests.conftest import make_test_cui
token = _csrf(c)
return c.post("/signup", data={
"name": name,
"cui": make_test_cui(email),
"email": email,
"parola": parola,
"csrf_token": token,

View File

@@ -24,7 +24,7 @@ def _run(argv):
def test_create_afiseaza_id(env, capsys):
rc = _run(["create", "--name", "Service X"])
rc = _run(["create", "--name", "Service X", "--cui", "RO001", "--email", "x@test.com"])
out = capsys.readouterr().out
assert rc == 0
assert "id=2" in out
@@ -32,14 +32,14 @@ def test_create_afiseaza_id(env, capsys):
def test_create_inactive_in_asteptare(env, capsys):
rc = _run(["create", "--name", "Service X", "--inactive"])
rc = _run(["create", "--name", "Service X", "--cui", "RO002", "--email", "x2@test.com", "--inactive"])
out = capsys.readouterr().out
assert rc == 0
assert "activ=nu" in out
def test_create_with_key_emite_cheie(env, capsys):
rc = _run(["create", "--name", "Service X", "--with-key"])
rc = _run(["create", "--name", "Service X", "--cui", "RO003", "--email", "x3@test.com", "--with-key"])
out = capsys.readouterr().out
assert rc == 0
assert "rfak_" in out
@@ -57,8 +57,8 @@ def test_create_with_key_emite_cheie(env, capsys):
def test_create_cui_duplicat_exit_2(env, capsys):
assert _run(["create", "--name", "Service A", "--cui", "RO123"]) == 0
rc = _run(["create", "--name", "Service B", "--cui", "RO123"])
assert _run(["create", "--name", "Service A", "--cui", "RO123", "--email", "a@test.com"]) == 0
rc = _run(["create", "--name", "Service B", "--cui", "RO123", "--email", "b@test.com"])
err = capsys.readouterr().err
assert rc == 2
assert "RO123" in err
@@ -66,10 +66,10 @@ def test_create_cui_duplicat_exit_2(env, capsys):
def test_with_key_atomic_pe_cui_duplicat(env, capsys):
# cont initial care ocupa CUI
assert _run(["create", "--name", "Service A", "--cui", "RO123"]) == 0
assert _run(["create", "--name", "Service A", "--cui", "RO123", "--email", "a@test.com"]) == 0
capsys.readouterr()
# --with-key pe CUI duplicat: rollback -> niciun cont B, nicio cheie orfana
rc = _run(["create", "--name", "Service B", "--cui", "RO123", "--with-key"])
rc = _run(["create", "--name", "Service B", "--cui", "RO123", "--email", "b@test.com", "--with-key"])
assert rc == 2
from app.db import get_connection
conn = get_connection()
@@ -82,7 +82,7 @@ def test_with_key_atomic_pe_cui_duplicat(env, capsys):
def test_activate_comuta_starea(env, capsys):
_run(["create", "--name", "Service X", "--inactive"])
_run(["create", "--name", "Service X", "--cui", "RO004", "--email", "x4@test.com", "--inactive"])
capsys.readouterr()
assert _run(["deactivate", "--account", "2"]) == 0
assert _run(["activate", "--account", "2"]) == 0
@@ -104,7 +104,7 @@ def test_activate_inexistent_exit_2(env, capsys):
def test_list_afiseaza_activ(env, capsys):
_run(["create", "--name", "Service X"])
_run(["create", "--name", "Service X", "--cui", "RO005", "--email", "x5@test.com"])
capsys.readouterr()
rc = _run(["list"])
out = capsys.readouterr().out
@@ -114,8 +114,8 @@ def test_list_afiseaza_activ(env, capsys):
def test_list_pending_filtreaza(env, capsys):
_run(["create", "--name", "Activ SRL"])
_run(["create", "--name", "Asteptare SRL", "--inactive"])
_run(["create", "--name", "Activ SRL", "--cui", "RO006", "--email", "activ@test.com"])
_run(["create", "--name", "Asteptare SRL", "--cui", "RO007", "--email", "ast@test.com", "--inactive"])
capsys.readouterr()
rc = _run(["list", "--pending"])
out = capsys.readouterr().out

View File

@@ -37,9 +37,10 @@ def _csrf(client, url):
def _signup(client, name, email, password="parola_test_001"):
from tests.conftest import make_test_cui
tok = _csrf(client, "/signup")
client.post("/signup", data={"name": name, "email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email,
"parola": password, "csrf_token": tok}, follow_redirects=True)
from app.db import get_connection
conn = get_connection()
try:

View File

@@ -230,3 +230,126 @@ def test_fragment_cont_nelogat_redirect(monkeypatch):
assert resp.status_code == 303
assert "/login" in resp.headers.get("location", "")
get_settings.cache_clear()
# ============================================================
# US-002: sectiunea 'Date firma' + banner cont incomplet
# ============================================================
def _create_complete_account(
name: str = "Firma Test SRL",
login_email: str = "firma_test@test.com",
account_email: str = "contact@firma.com",
cui: str = "RO12345678",
password: str = "parolasecreta10",
):
"""Creeaza cont COMPLET (name+email+CUI) + user. Intoarce (acct_id, user_id)."""
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, cui=cui, email=account_email, active=True)
user_id = create_user(conn, acct_id, login_email, password)
return acct_id, user_id
finally:
conn.close()
def test_cont_afiseaza_companie_email_cui(client):
"""Fragment /_fragments/cont contine sectiunea 'Date firma' cu companie, email, CUI prefilled."""
_create_complete_account(
name="Test Firma SRL",
login_email="tfirma@test.com",
account_email="contact_tf@test.com",
cui="RO11111111",
)
_login(client, "tfirma@test.com", "parolasecreta10")
resp = client.get("/_fragments/cont")
assert resp.status_code == 200
assert "Date firma" in resp.text or "date-firma" in resp.text, \
f"Sectiunea 'Date firma' lipseste: {resp.text[:500]}"
assert "Test Firma SRL" in resp.text, f"Compania nu e prefilled: {resp.text[:500]}"
assert "contact_tf@test.com" in resp.text, f"Email-ul nu e prefilled: {resp.text[:500]}"
assert "RO11111111" in resp.text, f"CUI-ul nu e prefilled: {resp.text[:500]}"
def test_post_date_firma_actualizeaza(client):
"""POST /cont/date-firma actualizeaza accounts.name, accounts.email, accounts.cui in DB."""
acct_id, user_id, _ = _create_account_user("update_df@test.com")
_login(client, "update_df@test.com", "parolasecreta10")
csrf = _get_csrf_from_fragment(client)
resp = client.post("/cont/date-firma", data={
"csrf_token": csrf,
"companie": "Firma Actualizata SRL",
"email": "contact@firma-act.com",
"cui": "RO99887766",
})
assert resp.status_code == 200, f"POST /cont/date-firma a returnat {resp.status_code}"
# Verifica in DB ca datele au fost actualizate
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute(
"SELECT name, email, cui FROM accounts WHERE id=?", (acct_id,)
).fetchone()
assert row["name"] == "Firma Actualizata SRL", f"name neschimbat: {row['name']}"
assert row["email"] == "contact@firma-act.com", f"email neschimbat: {row['email']}"
assert row["cui"] == "RO99887766", f"cui neschimbat: {row['cui']}"
finally:
conn.close()
def test_post_date_firma_cui_duplicat_eroare(client):
"""POST /cont/date-firma cu CUI deja folosit de alt cont -> eroare in raspuns."""
# Cont A cu CUI existent
_create_complete_account(
name="Firma A SRL",
login_email="firma_a_dup@test.com",
account_email="a_dup@test.com",
cui="ROAAA11111",
)
# Cont B fara CUI
acct_b, user_b, _ = _create_account_user("firma_b_dup@test.com")
_login(client, "firma_b_dup@test.com", "parolasecreta10")
csrf = _get_csrf_from_fragment(client)
resp = client.post("/cont/date-firma", data={
"csrf_token": csrf,
"companie": "Firma B SRL",
"email": "firma_b_dup@test.com",
"cui": "ROAAA11111", # CUI-ul lui A — duplicat
})
assert resp.status_code == 200
text = resp.text.lower()
assert "deja" in text or "duplicat" in text or "folosit" in text or "eroare" in text, \
f"Mesaj eroare CUI duplicat lipsa: {resp.text[:500]}"
# Contul B nu trebuie sa aiba CUI-ul lui A in DB
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT cui FROM accounts WHERE id=?", (acct_b,)).fetchone()
assert row["cui"] != "ROAAA11111", "CUI-ul duplicat a fost totusi salvat in DB"
finally:
conn.close()
def test_banner_cont_incomplet_pe_legacy(client):
"""Acasa afiseaza banner 'Completeaza datele firmei' cand contul e incomplet (fara email/CUI)."""
# Cont fara email/CUI (legacy: creat fara aceste campuri)
_create_account_user("legacy_test@test.com")
_login(client, "legacy_test@test.com", "parolasecreta10")
resp = client.get("/")
assert resp.status_code == 200
text = resp.text.lower()
# Banner trebuie sa apara cand contul e incomplet
assert "completeaza" in text or "date firm" in text or "incomplet" in text, \
f"Banner 'Completeaza datele firmei' lipsa pe Acasa: {resp.text[:2000]}"

View File

@@ -0,0 +1,174 @@
"""Teste US-005 (PRD 5.12): formular de editare partajat DRY + input date.
_form_editare.html — partial NOU cu campurile vehicul/data/odo.
_macros.html — macro `camp` extins cu tip='date'.
_trimitere_detaliu.html — consuma partial-ul in ramura editabil.
TDD: scriem testele RED inainte de implementare.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
from pathlib import Path
import pytest
from starlette.testclient import TestClient
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "app" / "web" / "templates"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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(acct: int, *, status: str, payload: dict) -> 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(6).hex()}", acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _payload_needs_data(vin: str = "WVWZZZ1JZXW0FE001") -> dict:
return {
"vin": vin,
"nr_inmatriculare": "B200FE",
"data_prestatie": "2026-06-15",
"odometru_final": "", # gol -> needs_data
"prestatii": [{"cod_prestatie": "R-FRANE"}],
}
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "form_editare.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()
# ---------------------------------------------------------------------------
# Test 1: fragmentul de trimitere randeaza <input type="date"> pentru data_prestatie
# ---------------------------------------------------------------------------
def test_form_editare_are_input_date_pe_data_prestatie(client):
"""Fragmentul de detaliu pentru un rand needs_data trebuie sa randereze
<input type="date"> pentru campul data_prestatie (calendar nativ, D#10/R3).
Inainte de US-005, campul e type="text" -> test RED.
"""
acct = _create_account_user("fe1@test.com")
sid = _insert(acct, status="needs_data", payload=_payload_needs_data())
_login(client, "fe1@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Campul data_prestatie trebuie sa fie type="date" (nu type="text").
assert 'name="data_prestatie"' in html, "campul data_prestatie lipseste din fragment"
# Cautam input cu name=data_prestatie si type=date.
assert re.search(r'<input[^>]+name="data_prestatie"[^>]+type="date"', html) or \
re.search(r'<input[^>]+type="date"[^>]+name="data_prestatie"', html), \
"data_prestatie trebuie sa fie <input type='date'>, nu type='text'"
# Asiguram ca NU este type="text" pentru data_prestatie.
# type="text" pe data_prestatie inseamna ca partial-ul nu e activ.
match_text = re.search(r'<input[^>]+name="data_prestatie"[^>]+type="text"', html) or \
re.search(r'<input[^>]+type="text"[^>]+name="data_prestatie"', html)
assert not match_text, \
"data_prestatie NU trebuie sa fie type='text' dupa US-005 (trebuie type='date')"
# ---------------------------------------------------------------------------
# Test 2: _trimitere_detaliu.html foloseste partial-ul _form_editare.html
# ---------------------------------------------------------------------------
def test_trimitere_detaliu_foloseste_form_partajat():
"""Sursa _trimitere_detaliu.html trebuie sa includa _form_editare.html.
Inainte de US-005, include lipseste -> test RED.
"""
sursa = (TEMPLATES_DIR / "_trimitere_detaliu.html").read_text(encoding="utf-8")
# Trebuie sa contina include sau import din _form_editare.html.
assert "_form_editare.html" in sursa, (
"_trimitere_detaliu.html nu referencieaza _form_editare.html. "
"US-005 cere ca partial-ul sa fie consumat in ramura editabil."
)
# ---------------------------------------------------------------------------
# Test 3: macro `camp` din _macros.html suporta tip='date'
# ---------------------------------------------------------------------------
def test_camp_macro_randeaza_type_date():
"""Macro `camp` din _macros.html trebuie sa suporte tip='date' si sa
randeze <input type='date'> fara a strica tip='text' (default).
Inainte de US-005, macros.html nu are macro `camp` -> test RED.
"""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)))
# Randare directa a macro-ului camp din _macros.html.
tmpl = env.from_string(
"{% from '_macros.html' import camp %}"
"{{ camp('data_prestatie', 'Data prestatie', '2026-06-15', tip='date') }}"
)
html = tmpl.render()
# Trebuie sa contina type="date".
assert 'type="date"' in html, \
"macro camp cu tip='date' trebuie sa randeze <input type='date'>"
assert 'name="data_prestatie"' in html, \
"macro camp trebuie sa randeze input cu name corect"
# Verifica ca tip='text' (default) inca functioneaza.
tmpl_text = env.from_string(
"{% from '_macros.html' import camp %}"
"{{ camp('nr_inmatriculare', 'Nr inmatriculare', 'B100AA') }}"
)
html_text = tmpl_text.render()
assert 'type="text"' in html_text, \
"macro camp fara tip explicit trebuie sa randeze type='text' (default neschimbat)"
assert 'type="date"' not in html_text, \
"macro camp fara tip='date' NU trebuie sa randeze type='date'"

421
tests/test_web_mapare_op.py Normal file
View File

@@ -0,0 +1,421 @@
"""Teste US-004 — un singur „Salveaza maparile" pe panoul de operatii nemapate.
Ruta noua: POST /_import/{id}/mapare-operatii (plural)
- primeste perechi (cod_op_service, cod_prestatie) ca liste paralele
- apeleaza save_mapping pentru fiecare pereche cu cod ales (reuse exact)
- ignora perechile cu cod_prestatie gol (nu eroare, nu salvare)
- D#12: validare per-item — cod invalid -> skip + sumar, restul salvate
- O singura _web_compute_preview + re-randare #import-section la final
- CSRF + scoped sesiune + guard batch committed (409) pastrate
"""
from __future__ import annotations
import csv as csv_mod
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, "t.db"))
# Mod dev: fallback cont 1, fara login/CSRF (ca in test_import_mapare_operatie).
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()
# ------------------------------------------------------------------ #
# Helpers de setup #
# ------------------------------------------------------------------ #
_HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final",
"Cod operatie", "Denumire"]
_CANON = ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final",
"operatie", "denumire_op"]
# Doua operatii distincte: OP-REV si OP-FR
_ROWS_2OPS = [
["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "OP-REV", "Revizie periodica"],
["WVWZZZ1KZAW002222", "CJ200AB", "2026-05-20", "98765", "OP-REV", "Revizie periodica"],
["WVWZZZ1KZAW003333", "IS300CD", "2026-04-10", "50000", "OP-FR", "Franare"],
]
# O singura operatie
_ROWS_1OP = [
["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "OP-REV", "Revizie periodica"],
["WVWZZZ1KZAW002222", "CJ200AB", "2026-05-20", "98765", "OP-REV", "Revizie periodica"],
]
def _csv_bytes(header, rows, sep=";") -> bytes:
buf = io.StringIO()
w = csv_mod.writer(buf, delimiter=sep)
w.writerow(header)
for r in rows:
w.writerow(r)
return buf.getvalue().encode("utf-8")
def _upload(client, rows=None) -> int:
"""Incarca fisier CSV si intoarce import_id."""
rows = _ROWS_2OPS if rows is None else rows
r = client.post(
"/_import/upload",
files={"file": ("t.csv", _csv_bytes(_HEADER, rows), "text/csv")},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"form mapare-coloane lipsa: {r.text[:300]}"
return int(m.group(1))
def _map_columns(client, import_id, canon=None):
return client.post(
f"/_import/{import_id}/mapare-coloane",
data={
"colname": _HEADER,
"canon": canon or _CANON,
"format_data": "YYYY-MM-DD",
},
)
def _get_batch_counts(import_id):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute(
"SELECT ok, needs_mapping FROM import_batches WHERE id=?", (import_id,)
).fetchone()
finally:
conn.close()
def _get_row_statuses(import_id):
from app.db import get_connection
conn = get_connection()
try:
rows = conn.execute(
"SELECT resolved_status FROM import_rows WHERE batch_id=? ORDER BY row_index",
(import_id,),
).fetchall()
return [r["resolved_status"] for r in rows]
finally:
conn.close()
def _get_mapping(cod_op_service, account_id=1):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute(
"SELECT cod_prestatie FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
(account_id, cod_op_service),
).fetchone()
finally:
conn.close()
# ------------------------------------------------------------------ #
# 1. Salveaza multiple operatii intr-un singur POST #
# ------------------------------------------------------------------ #
def test_mapare_operatii_salveaza_multiple_intr_un_post(client):
"""POST mapare-operatii cu 2 operatii alese -> ambele salvate, randurile trec la ok."""
import_id = _upload(client)
r = _map_columns(client, import_id)
assert r.status_code == 200
# Inainte: 0 ok, 3 needs_mapping
b = _get_batch_counts(import_id)
assert b["needs_mapping"] == 3
assert b["ok"] == 0
# Un singur POST cu ambele operatii
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV", "OP-FR"],
"cod_prestatie": ["OE-3", "OE-1"],
},
)
assert rm.status_code == 200, rm.text
# Raspuns e preview re-randat cu #import-section
assert "import-section" in rm.text
# Ambele mapari persistate
assert _get_mapping("OP-REV") is not None
assert _get_mapping("OP-REV")["cod_prestatie"] == "OE-3"
assert _get_mapping("OP-FR") is not None
assert _get_mapping("OP-FR")["cod_prestatie"] == "OE-1"
# Toate randurile trecute la ok
b2 = _get_batch_counts(import_id)
assert b2["ok"] == 3, b2
assert b2["needs_mapping"] == 0, b2
statuses = _get_row_statuses(import_id)
assert all(s == "ok" for s in statuses), statuses
def test_panoul_mapare_are_un_singur_form(client):
"""Preview-ul randeaza panoul de mapare cu un singur <form> si un buton Salveaza maparile."""
import_id = _upload(client)
r = _map_columns(client, import_id)
assert r.status_code == 200
# Ruta noua mapare-operatii (plural) prezenta in form
assert f"/_import/{import_id}/mapare-operatii" in r.text
# Un singur buton Salveaza
assert "Salveaza maparile" in r.text
# NU apare ruta singular mapare-operatie ca target de form (panoul unificat)
# (poate apare ca ruta pastrata, dar nu ca hx-post al formularului de mapare in bulk)
# ------------------------------------------------------------------ #
# 2. Ignora operatiile fara cod ales #
# ------------------------------------------------------------------ #
def test_mapare_operatii_ignora_randuri_neselectate(client):
"""Operatia cu cod_prestatie gol e ignorata (nu eroare, nu salvare)."""
import_id = _upload(client, rows=_ROWS_2OPS)
_map_columns(client, import_id)
# OP-REV cu cod ales, OP-FR fara cod ales (string gol = "— alege cod RAR —")
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV", "OP-FR"],
"cod_prestatie": ["OE-3", ""],
},
)
assert rm.status_code == 200, rm.text
# OP-REV salvat
assert _get_mapping("OP-REV") is not None
# OP-FR nesal vat (nu eroare, nu mapare)
assert _get_mapping("OP-FR") is None
# Randurile OP-REV (2) sunt ok, OP-FR (1) raman needs_mapping
b = _get_batch_counts(import_id)
assert b["ok"] == 2, b
assert b["needs_mapping"] == 1, b
# Panoul mai arata OP-FR (inca nemapat)
assert "OP-FR" in rm.text
def test_mapare_operatii_fara_nicio_selectie_nu_eroare(client):
"""POST cu toate cod_prestatie goale -> nici o eroare, nici o salvare, preview re-randat."""
import_id = _upload(client, rows=_ROWS_1OP)
_map_columns(client, import_id)
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV"],
"cod_prestatie": [""],
},
)
assert rm.status_code == 200, rm.text
# Nicio mapare salvata
assert _get_mapping("OP-REV") is None
# Randurile raman needs_mapping
b = _get_batch_counts(import_id)
assert b["needs_mapping"] == 2, b
assert b["ok"] == 0, b
# ------------------------------------------------------------------ #
# 3. Re-rezolva randurile blocate cu needs_mapping #
# ------------------------------------------------------------------ #
def test_mapare_operatii_re_rezolva_blocatele(client):
"""Operatiile cu cod ales trec din needs_mapping la ok (re-rezolvare imediata)."""
import_id = _upload(client, rows=_ROWS_1OP)
_map_columns(client, import_id)
# Inainte: 2 needs_mapping
b = _get_batch_counts(import_id)
assert b["needs_mapping"] == 2, b
assert b["ok"] == 0, b
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV"],
"cod_prestatie": ["OE-3"],
},
)
assert rm.status_code == 200
# Dupa: 2 ok, 0 needs_mapping
b2 = _get_batch_counts(import_id)
assert b2["ok"] == 2, b2
assert b2["needs_mapping"] == 0, b2
statuses = _get_row_statuses(import_id)
assert all(s == "ok" for s in statuses), statuses
# Preview randat nu mai arata panoul de mapare
assert "Operatii de mapat la cod RAR" not in rm.text
# ------------------------------------------------------------------ #
# 4. D#12 — validare per-item: cod invalid skip + sumar, restul ok #
# ------------------------------------------------------------------ #
def test_mapare_operatii_cod_invalid_skip_salveaza_restul(client):
"""D#12: daca un cod ales e invalid (1 din 2), skip-ul + sumar, celalalt salvat, 1 re-render."""
import_id = _upload(client, rows=_ROWS_2OPS)
_map_columns(client, import_id)
# OP-REV cod valid, OP-FR cod inexistent
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV", "OP-FR"],
"cod_prestatie": ["OE-3", "COD-INEXISTENT"],
},
)
assert rm.status_code == 200, rm.text
# OP-REV salvat (codul valid)
assert _get_mapping("OP-REV") is not None
assert _get_mapping("OP-REV")["cod_prestatie"] == "OE-3"
# OP-FR nesalvat (cod invalid)
assert _get_mapping("OP-FR") is None
# Sumar in mesaj: cod invalid mentionat
assert "COD-INEXISTENT" in rm.text or "necunoscut" in rm.text.lower()
# Randurile OP-REV (2) ok, OP-FR (1) inca needs_mapping
b = _get_batch_counts(import_id)
assert b["ok"] == 2, b
assert b["needs_mapping"] == 1, b
# O singura re-randare (200, nu redirect, nu multiple #import-section)
assert rm.text.count("import-section") >= 1
# ------------------------------------------------------------------ #
# 5. Guard batch committed (409) #
# ------------------------------------------------------------------ #
def test_mapare_operatii_batch_committed_409(client):
"""Batch deja comis -> 409 Conflict."""
import_id = _upload(client, rows=_ROWS_1OP)
_map_columns(client, import_id)
# Marcheaza batch-ul ca committed direct in DB
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE import_batches SET status='committed' WHERE id=?", (import_id,)
)
conn.commit()
finally:
conn.close()
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV"],
"cod_prestatie": ["OE-3"],
},
)
assert rm.status_code == 409, rm.text
# ------------------------------------------------------------------ #
# 6. Guard scoped sesiune (404 cross-account) #
# ------------------------------------------------------------------ #
def test_mapare_operatii_scoped_404_alt_cont(monkeypatch):
"""Import apartinand altui cont -> 404 (scoping corect)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "scope.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app, follow_redirects=False) as c:
conn = get_connection()
try:
acct_a = create_account(conn, "ServiceA", active=True)
create_user(conn, acct_a, "a@test.com", "parolasecreta10")
acct_b = create_account(conn, "ServiceB", active=True)
create_user(conn, acct_b, "b@test.com", "parolasecreta10")
finally:
conn.close()
def _login_and_get_csrf(c, email, password="parolasecreta10"):
resp = c.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
csrf = m.group(1)
c.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
# Get a fresh CSRF token for next request
resp2 = c.get("/?tab=import")
m2 = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp2.text)
return m2.group(1) if m2 else csrf
# Login cu cont A, upload fisier (batch apartine cont A)
csrf_a = _login_and_get_csrf(c, "a@test.com")
r = c.post(
"/_import/upload",
files={"file": ("t.csv", _csv_bytes(_HEADER, _ROWS_1OP), "text/csv")},
data={"csrf_token": csrf_a},
)
assert r.status_code == 200, r.text
m2 = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m2
import_id = int(m2.group(1))
# Extrage CSRF din raspuns pentru mapare-coloane
m_csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text)
csrf_for_map = m_csrf.group(1) if m_csrf else csrf_a
c.post(f"/_import/{import_id}/mapare-coloane",
data={"colname": _HEADER, "canon": _CANON, "format_data": "YYYY-MM-DD",
"csrf_token": csrf_for_map})
# Login cu cont B, incearca mapare pe batch-ul lui A
csrf_b = _login_and_get_csrf(c, "b@test.com")
rm = c.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV"],
"cod_prestatie": ["OE-3"],
"csrf_token": csrf_b,
},
)
assert rm.status_code == 404, f"expected 404 got {rm.status_code}: {rm.text[:200]}"
get_settings.cache_clear()

View File

@@ -0,0 +1,165 @@
"""Teste US-003 — pasul „Potriveste coloanele" arata antet + prima inregistrare.
Acceptance criteria:
- Deasupra/langa randurile de mapare, un mic tabel orizontal:
cap de tabel = numele coloanelor din fisier,
rand = valorile primei inregistrari (truncate; `title` pe valoarea integrala).
- Foloseste .tablewrap pentru scroll orizontal pe mobil.
- Fiecare coloana din cap ramane asociata vizual cu select-ul ei de mapare.
- Fisier fara randuri de date -> doar capul de tabel + mesaj explicit
„antet fara randuri de date" (D#11) + butonul „Continua" dezactivat.
"""
from __future__ import annotations
import io
import os
import tempfile
import pytest
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.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 _make_xlsx_bytes(rows: list[dict]) -> bytes:
"""Construieste un xlsx minimal cu openpyxl."""
openpyxl = pytest.importorskip("openpyxl")
wb = openpyxl.Workbook()
ws = wb.active
if not rows:
return b""
headers = list(rows[0].keys())
ws.append(headers)
for row in rows:
ws.append([row.get(h) for h in headers])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _make_xlsx_only_header(headers: list[str]) -> bytes:
"""Construieste un xlsx cu doar antet (fara randuri de date)."""
openpyxl = pytest.importorskip("openpyxl")
wb = openpyxl.Workbook()
ws = wb.active
ws.append(headers)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
_COLOANE = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
_SAMPLE_ROWS = [
{
"VIN": "WVWZZZ1KZAW000123",
"Nr inmatriculare": "B001TST",
"Data prestatie": "15.06.2026",
"Odometru final": "123456",
"Operatie": "Revizie",
},
{
"VIN": "WVWZZZ1KZAW000456",
"Nr inmatriculare": "B002TST",
"Data prestatie": "16.06.2026",
"Odometru final": "200000",
"Operatie": "Revizie",
},
]
def _upload_csv_and_get_mapare(client, rows=None, headers=None) -> tuple[int, str]:
"""Incarca un xlsx si intoarce (import_id, html_text) din pasul de mapare coloane."""
if headers is not None:
# Fisier cu doar antet
xlsx = _make_xlsx_only_header(headers)
else:
xlsx = _make_xlsx_bytes(rows or _SAMPLE_ROWS)
r = client.post(
"/_import/upload",
files={"file": ("test.xlsx", xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
return r.status_code, r.text
# --------------------------------------------------------------------------- #
# test_mapcoloane_arata_cap_tabel_coloane #
# --------------------------------------------------------------------------- #
def test_mapcoloane_arata_cap_tabel_coloane(client):
"""Pasul de mapare coloane afiseaza un tabel orizontal cu antetul fisierului.
Fiecare coloana din fisier trebuie sa apara ca <th> in capul tabelului.
Tabelul trebuie inconjurat de .tablewrap pentru scroll orizontal.
"""
status, html = _upload_csv_and_get_mapare(client)
assert status == 200
# Tabelul de preview al antetului trebuie sa fie prezent
assert "tablewrap" in html, "Trebuie .tablewrap pentru scroll orizontal"
# Fiecare coloana din fisier trebuie sa apara ca <th> in tabel
for col in _COLOANE:
assert f"<th" in html, "Trebuie elemente <th> in capul tabelului"
assert col in html, f"Coloana '{col}' trebuie sa apara in antetul tabelului"
# Tabelul de preview (cu class sau id distinct pentru antet preview)
assert "preview-antet" in html, "Tabelul de preview trebuie sa aiba class/id 'preview-antet'"
# --------------------------------------------------------------------------- #
# test_mapcoloane_arata_valori_prima_inregistrare #
# --------------------------------------------------------------------------- #
def test_mapcoloane_arata_valori_prima_inregistrare(client):
"""Pasul de mapare coloane afiseaza valorile primei inregistrari din fisier.
Valorile din primul rand de date trebuie sa apara in tabel.
"""
status, html = _upload_csv_and_get_mapare(client, rows=_SAMPLE_ROWS)
assert status == 200
# Valorile primei inregistrari trebuie sa apara
assert "WVWZZZ1KZAW000123" in html, "VIN-ul primei inregistrari trebuie sa apara"
assert "B001TST" in html, "Nr inmatriculare al primei inregistrari trebuie sa apara"
assert "15.06.2026" in html, "Data prestatiei primei inregistrari trebuie sa apara"
assert "123456" in html, "Odometrul primei inregistrari trebuie sa apara"
# Valorile celui de-al doilea rand NU trebuie sa apara ca rand de date (numai primul rand)
# (Nota: pot aparea ca exemple in alt context, dar randul de date e primul)
# --------------------------------------------------------------------------- #
# test_mapcoloane_fara_randuri_degradeaza #
# --------------------------------------------------------------------------- #
def test_mapcoloane_fara_randuri_degradeaza(client):
"""Fisier xlsx cu antet dar fara randuri de date → degradare grijulie.
Trebuie sa:
- Nu crape (status 200)
- Arate capul de tabel cu coloanele
- Arate mesajul explicit 'antet fara randuri de date'
- Dezactiveze butonul 'Salveaza si continua' (disabled)
"""
status, html = _upload_csv_and_get_mapare(client, headers=_COLOANE)
assert status == 200, f"Status neasteptat: {status}"
# Nu trebuie sa crape — formular de mapare afisat
assert "Mapare coloane" in html, "Formularul de mapare trebuie afisat"
# Capul de tabel cu coloanele trebuie afisat
for col in _COLOANE:
assert col in html, f"Coloana '{col}' trebuie sa apara in antet chiar si fara date"
# Mesaj explicit despre lipsa datelor
assert "antet fara randuri de date" in html.lower() or "fara randuri de date" in html.lower(), \
"Trebuie mesaj explicit despre lipsa randurilor de date"
# Butonul Continua trebuie dezactivat
assert "disabled" in html, "Butonul 'Salveaza si continua' trebuie dezactivat cand nu sunt randuri"

View File

@@ -0,0 +1,182 @@
"""Teste US-007 (PRD 5.12): preview compact — scoatere coloana "Verificat?" + VIN compact.
TDD RED: testele sunt scrise inainte de implementare.
Scenarii:
1. Preview NU contine coloana "Verificat?" (col-verificat / antet).
2. VIN nu se sparge pe verticala (white-space controlat in templateul randului).
"""
from __future__ import annotations
import io
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client fara autentificare web obligatorie (cont 1 implicit)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pc.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _seed_op1(account_id: int = 1) -> None:
"""Semeaza nomenclator + mapare OP-1 → R-FRANE."""
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 (?, 'OP-1', 'R-FRANE', 1)",
(account_id,),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
import csv as _csv
buf = io.StringIO()
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
_SAMPLE_ROWS = [
{
"VIN": "WVWZZZ1KZAW000123",
"Nr": "B001TST",
"Data": "2026-06-10",
"KM": "123456",
"Operatie": "OP-1",
},
]
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _get_csrf(client: TestClient) -> str:
r = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text)
return m.group(1) if m else ""
def _upload_and_preview(client: TestClient, rows: list[dict] | None = None,
format_data: str = "YYYY-MM-DD") -> int:
"""Upload CSV + salveaza mapare → preview. Intoarce import_id."""
rows = rows or _SAMPLE_ROWS
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _get_csrf(client)
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": format_data,
"csrf_token": csrf2,
})
assert r2.status_code == 200, r2.text
return iid
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_preview_fara_coloana_verificat(client):
"""Tabelul de preview NU contine coloana 'Verificat?' (col-verificat).
US-007 (PRD 5.12): coloana 'Verificat?' este eliminata din preview;
antetul si celulele scad la 8 coloane (fara col-verificat).
Verifica:
- Antetul tabelului NU contine 'Verificat?' ca text
- NU exista elemente cu clasa 'col-verificat' in HTML-ul de preview
- NU exista input[name='reviewed_rows'] (checkboxele au fost eliminate)
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200, r.text
html = r.text
# Coloana "Verificat?" trebuie eliminata
assert "Verificat?" not in html, \
"Coloana 'Verificat?' nu trebuie sa apara in antetul tabelului de preview"
assert "col-verificat" not in html, \
"Clasa 'col-verificat' nu trebuie sa existe in HTML-ul de preview"
# Checkboxele reviewed_rows trebuie eliminate
assert 'name="reviewed_rows"' not in html, \
"Input[name='reviewed_rows'] (checkbox) nu trebuie sa existe in preview"
def test_preview_vin_nu_se_sparge_pe_verticala(client):
"""VIN-ul din coloana Vehicul nu se mai sparge pe verticala.
US-007 (PRD 5.12): randuri compacte — VIN cu white-space controlat
(white-space:nowrap sau min-width pe coloana), fara overflow orizontal.
Verifica:
- Divul cu VIN scurt are white-space:nowrap (previne ruperea pe linie noua)
- SAU coloana col-vehicul are o latime/min-width adecvata
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200, r.text
html = r.text
# Verifica ca VIN-ul are white-space:nowrap in div-ul de VIN scurt
# Template-ul _preview_rand.html trebuie sa aiba:
# <div class="muted" style="...white-space:nowrap...">{{ row.prez.vin_scurt }}</div>
assert "white-space:nowrap" in html or "white-space: nowrap" in html, \
"VIN-ul din preview trebuie sa aiba white-space:nowrap pentru a preveni ruperea pe verticala"

View File

@@ -0,0 +1,541 @@
"""Teste US-006 (PRD 5.12): editarea unui rand de preview deschide MODALUL global,
nu un rand inline (tr.preview-edit).
TDD RED: testele sunt scrise inainte de implementare.
Scenarii:
1. GET editare-modal → fragment pentru #detaliu-modal-body (NU tr.preview-edit).
2. POST editeaza cu succes → HX-Trigger-After-Settle: inchideModal + OOB rand+contoare.
3. Buton Anuleaza = inchidere modal, fara cerere catre /_import/.../rand/{i} (R5).
4. Scoping 404 cross-account.
5. Guard committed → 409.
6. INVARIANT CRITIC (R2): tabela submissions NEATINSA dupa editare preview.
"""
from __future__ import annotations
import io
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client fara autentificare web obligatorie (conte 1 implicit)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pe.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
@pytest.fixture()
def client_auth(monkeypatch):
"""Client cu autentificare web obligatorie (pentru teste de scoping cross-account)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pe_auth.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
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()
reset_cache()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _seed_op1(account_id: int = 1) -> None:
"""Semeaza nomenclator + mapare OP-1 → R-FRANE (auto_send=1)."""
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 (?, 'OP-1', 'R-FRANE', 1)",
(account_id,),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
import csv as _csv
buf = io.StringIO()
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
_SAMPLE_ROWS = [
{
"VIN": "WVWZZZ1KZAW000123",
"Nr": "B001TST",
"Data": "2026-06-10",
"KM": "123456",
"Operatie": "OP-1",
},
{
"VIN": "WVWZZZ1KZAW000456",
"Nr": "B002TST",
"Data": "2026-06-11",
"KM": "200000",
"Operatie": "OP-1",
},
]
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _get_csrf(client: TestClient) -> str:
"""Obtine CSRF token din sesiunea curenta prin GET pe pagina principala."""
r = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text)
return m.group(1) if m else ""
def _upload_and_preview(client: TestClient, rows: list[dict] | None = None) -> int:
"""Upload CSV + salveaza mapare coloane → preview. Intoarce import_id.
Obtine CSRF inainte de fiecare POST (necesar cand AUTOPASS_WEB_AUTH_REQUIRED=true).
"""
rows = rows or _SAMPLE_ROWS
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _get_csrf(client)
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": "YYYY-MM-DD",
"csrf_token": csrf2,
})
assert r2.status_code == 200, r2.text
return iid
def _login(client: TestClient, email: str, password: str = "parola123secure") -> None:
"""Login in sesiune web."""
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit in pagina 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}"
def _create_user(email: str, password: str = "parola123secure") -> int:
"""Creeaza cont + user. Intoarce account_id."""
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user
conn = get_connection()
try:
acct = create_account(conn, "TestFirma", active=True)
create_user(conn, acct, email, password)
return acct
finally:
conn.close()
def _count_submissions() -> int:
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT COUNT(*) FROM submissions").fetchone()[0]
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_editeaza_preview_serveste_fragment_modal(client):
"""GET /_import/{id}/rand/0/editare-modal randeaza fragment pentru modal,
NU tr.preview-edit (randul inline).
Verifica:
- Raspuns 200
- Contine formular cu hx-post catre /editeaza
- Contine campurile de editare (data_prestatie, vin, etc.)
- NU contine clasa 'preview-edit' (randul inline eliminat)
- Contine id="detaliu-modal-titlu" (heading pentru aria-labelledby)
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r.status_code == 200, r.text
html = r.text
# Formular cu actiune POST catre editeaza
assert f'hx-post="/_import/{iid}/rand/0/editeaza"' in html, \
"Fragmentul modal trebuie sa aiba form cu hx-post catre editeaza"
# Campurile de editare prezente
assert 'name="data_prestatie"' in html, "Camp data_prestatie lipsa"
assert 'name="vin"' in html, "Camp vin lipsa"
assert 'name="nr_inmatriculare"' in html, "Camp nr_inmatriculare lipsa"
# NU randul inline de tip preview-edit
assert "preview-edit" not in html, \
"Clasa 'preview-edit' (randul inline) nu trebuie sa existe in fragmentul modal"
# Heading pentru aria-labelledby al modalului
assert 'id="detaliu-modal-titlu"' in html, \
"Fragmentul modal trebuie sa aiba id='detaliu-modal-titlu'"
# Nu contine #confirm-form (inputurile nu sunt legate de formularul de confirmare)
assert 'id="confirm-form"' not in html, \
"Fragmentul modal nu trebuie sa contina confirm-form"
def test_salvare_preview_inchide_modal_si_oob_rand(client):
"""POST /_import/{id}/rand/0/editeaza cu date valide → HX-Trigger-After-Settle: inchideModal
+ OOB pe rand (#preview-row-0) si contoare (#preview-rezumat).
Verifica:
- Status 200
- Header HX-Trigger-After-Settle contine 'inchideModal'
- Raspuns contine OOB pentru randul actualizat (hx-swap-oob prezent)
- Raspuns contine OOB pentru rezumat (#preview-rezumat)
- NU re-randeaza intreaga sectiune (#import-section absent)
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "2026-06-15",
})
assert r.status_code == 200, r.text
html = r.text
# Header de inchidere modal
trigger = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" in trigger, \
f"Header HX-Trigger-After-Settle trebuie sa contina 'inchideModal', gasit: '{trigger}'"
# OOB pe randul actualizat
assert 'id="preview-row-0"' in html, \
"Raspunsul trebuie sa contina randul actualizat (#preview-row-0)"
assert "hx-swap-oob" in html, \
"Raspunsul trebuie sa contina OOB swap"
# OOB pe rezumatul stari
assert 'id="preview-rezumat"' in html, \
"Raspunsul trebuie sa contina OOB pe #preview-rezumat"
# NU re-randeaza intreaga sectiune de import
assert 'id="import-section"' not in html, \
"Editarea randului NU trebuie sa re-randeze intreaga sectiune #import-section"
def test_anuleaza_nu_lasa_rand_orfan(client):
"""Butonul Anuleaza din fragmentul modal inchide modalul fara cerere catre server.
Reproduce eroarea htmx 'TypeError: Cannot read properties of null
(reading htmx-internal-data)' (R5) — generata de ramura editing inline care
facea un GET pe /_import/.../rand/{i} la Anuleaza, iar dupa stergerea randului
din DOM, htmx nu mai gasea tinta.
Verifica la nivel de markup: butonul Anuleaza NU are hx-get catre rand.
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r.status_code == 200
html = r.text
# Butonul Anuleaza nu trebuie sa aiba hx-get catre ruta randului
# (eroarea htmx aparea exact din aceasta cerere)
# Cauta pattern-ul care ar produce eroarea: hx-get catre rand display (fara sufix)
assert f'hx-get="/_import/{iid}/rand/0"' not in html, \
"Butonul Anuleaza NU trebuie sa faca GET pe /_import/.../rand/0 (produce eroare htmx)"
# Fragmentul modal trebuie sa aiba un mecanism de inchidere fara request
# (data-modal-close sau onclick cu window.inchideDetaliu)
has_modal_close = "data-modal-close" in html or "inchideDetaliu" in html
assert has_modal_close, \
"Butonul Anuleaza trebuie sa inchida modalul local (data-modal-close sau inchideDetaliu)"
def test_editare_preview_scoped_404_alt_cont(client_auth):
"""GET si POST editare-modal pe un rand al altui cont → 404 (scoping JOIN).
Nu confirmam existenta rand-ului cross-account (acelasi mesaj ca inexistent).
"""
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user
# Creeaza doua conturi
conn = get_connection()
try:
acct1 = create_account(conn, "Firma A", active=True)
create_user(conn, acct1, "user_a@test.com", "parola123secure")
acct2 = create_account(conn, "Firma B", active=True)
create_user(conn, acct2, "user_b@test.com", "parola123secure")
finally:
conn.close()
# Seed nomenclator pentru ambele conturi (global)
_seed_op1(acct1)
# Login ca user A si creeaza batch
_login(client_auth, "user_a@test.com")
iid = _upload_and_preview(client_auth)
# Login ca user B si incearca sa acceseze batch-ul lui A
_login(client_auth, "user_b@test.com")
r_get = client_auth.get(f"/_import/{iid}/rand/0/editare-modal")
assert r_get.status_code == 404, \
f"GET editare-modal cross-account trebuie sa returneze 404, got {r_get.status_code}"
r_post = client_auth.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "2026-06-20",
"csrf_token": "dummy",
})
assert r_post.status_code in (403, 404), \
f"POST editeaza cross-account trebuie sa returneze 403/404, got {r_post.status_code}"
def test_editare_batch_committed_409(client):
"""POST editeaza pe un batch deja comis → 409.
Guard committed: batch trimis ireversibil nu mai poate fi editat
(editarea nu mai are efect downstream).
"""
from app.db import get_connection
_seed_op1()
iid = _upload_and_preview(client)
# Marcheaza batch ca committed
conn = get_connection()
try:
conn.execute("UPDATE import_batches SET status='committed' WHERE id=?", (iid,))
conn.commit()
finally:
conn.close()
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "2026-06-20",
})
assert r.status_code == 409, \
f"Editare pe batch committed trebuie sa returneze 409, got {r.status_code}"
def test_submissions_neatins_dupa_editare_preview(client):
"""INVARIANT CRITIC (R2): dupa editarea unui rand de preview, tabela submissions
ramane NEATINSA.
Editarea preview = override-only pe import_rows.override_json.
NU re-queue, NU insereaza, NU modifica submissions.
"""
_seed_op1()
iid = _upload_and_preview(client)
# Numara submissions inainte de editare
n_before = _count_submissions()
# Editeaza randul 0
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "2026-06-15",
})
assert r.status_code == 200, r.text
# Verifica ca submissions nu a fost atinsa
n_after = _count_submissions()
assert n_after == n_before, (
f"Editarea preview a atins tabela submissions! "
f"Inainte: {n_before}, dupa: {n_after}. "
"Editarea trebuie sa fie override-only (import_rows.override_json), NU re-queue."
)
def test_eroare_validare_modalul_ramane_deschis(client):
"""POST editeaza cu data invalida → raspuns 200 cu formularul si erorile per-camp.
La eroare de validare, modalul ramane deschis cu valorile introduse si mesajele
de eroare per-camp. NU emite inchideModal.
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "data-invalida",
})
assert r.status_code == 200, r.text
html = r.text
# Formularul trebuie sa fie prezent (modalul ramane deschis)
assert f'hx-post="/_import/{iid}/rand/0/editeaza"' in html, \
"La eroare de validare, formularul trebuie sa ramana in modal"
# NU emite inchideModal (modalul ramane deschis)
trigger = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" not in trigger, \
"La eroare de validare, NU trebuie emis inchideModal"
# Valoarea invalida pastrata (pentru corectie usoara)
assert "data-invalida" in html, "Valoarea invalida trebuie pastrata in formular"
# Mesaj de eroare per-camp
assert "data" in html.lower(), "Trebuie sa existe mesaj de eroare legat de data"
def test_preview_buton_editeaza_tinteste_detaliu_modal_body(client):
"""Butonul 'Editeaza' din tabelul de preview tinteste #detaliu-modal-body,
nu randul inline (#preview-row-N).
Verifica ca in fragmentul preview, butonul de editare are:
- hx-target="#detaliu-modal-body"
- URL catre endpoint-ul de editare-modal
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200, r.text
html = r.text
# Butonul Editeaza trebuie sa tinteasca detaliu-modal-body
assert 'hx-target="#detaliu-modal-body"' in html, \
"Butonul Editeaza trebuie sa aiba hx-target='#detaliu-modal-body'"
# URL de editare prezent (editare-modal sau editare care redirecteaza la modal)
assert f"/_import/{iid}/rand/0/editare" in html, \
"URL-ul de editare trebuie sa fie prezent in preview"
# NU mai exista clasa preview-edit pe randuri (ramura inline eliminata)
assert 'class="preview-edit"' not in html, \
"Clasa 'preview-edit' (randul inline) nu trebuie sa fie in preview dupa US-006"
# NU mai exista atribut data-editing="1" pe randuri (mutual-exclusion eliminata)
assert 'data-editing="1"' not in html, \
"Atributul data-editing='1' nu trebuie sa fie pe randuri dupa US-006"
# --------------------------------------------------------------------------- #
# Teste markup Bug 2: btn-editeaza deschide modalul prin open() global (B2) #
# --------------------------------------------------------------------------- #
def test_btn_editeaza_nu_are_js_inline_open_modal():
"""Bug B2 (markup): butonul .btn-editeaza din _preview_rand.html NU trebuie
sa mai deschida modalul cu JS inline (removeAttribute('hidden')).
Deschiderea trebuie sa treaca prin open(triggerRow) din base.html, altfel
<main> nu primeste inert/aria-hidden si focus-trap-ul nu e instalat.
"""
import pathlib
template = (
pathlib.Path(__file__).parent.parent
/ "app/web/templates/_preview_rand.html"
)
html = template.read_text(encoding="utf-8")
# Gasim sectiunea butonului .btn-editeaza
idx = html.find("btn-editeaza")
assert idx >= 0, "Butonul .btn-editeaza nu a fost gasit in template"
btn_section = html[idx:]
# NU trebuie sa existe inline JS care face removeAttribute('hidden') pe modal
assert "removeAttribute('hidden')" not in btn_section, (
"Butonul .btn-editeaza NU trebuie sa mai aiba JS inline care face "
"removeAttribute('hidden') — deschiderea trebuie sa treaca prin "
"open(triggerRow) din base.html pentru focus-trap + inert pe <main>."
)
# NU trebuie sa existe hx-on:htmx:before-request inline pe btn-editeaza
# (mecanismul de deschidere trebuie sa fie in handler-ul global din base.html)
assert "hx-on:htmx:before-request" not in btn_section.split("</button>")[0] \
and "hx-on::before-request" not in btn_section.split("</button>")[0], (
"Butonul .btn-editeaza NU trebuie sa aiba hx-on:htmx:before-request inline. "
"Deschiderea modalului trebuie sa fie in handler-ul global din base.html."
)
def test_base_html_deschide_modal_pentru_btn_editeaza():
"""Bug B2 (markup): handler-ul htmx:beforeRequest din base.html trebuie sa
apeleze open() si pentru butonul .btn-editeaza (nu doar pentru .trimitere-row).
Fara aceasta generalizare, <main> nu primeste inert/aria-hidden, focus-trap-ul
nu e instalat si focusul nu e readus pe buton la inchidere (US-006).
"""
import pathlib
base = (
pathlib.Path(__file__).parent.parent
/ "app/web/templates/base.html"
)
html = base.read_text(encoding="utf-8")
# Handler-ul htmx:beforeRequest trebuie sa tina cont de btn-editeaza
# SAU de hx-target="#detaliu-modal-body" (oricare din cele doua abordari e OK)
handler_idx = html.find("htmx:beforeRequest")
assert handler_idx >= 0, "Handler-ul htmx:beforeRequest nu a fost gasit in base.html"
# Cauta in zona handler-ului (urmatoarele 500 de caractere)
handler_zone = html[handler_idx:handler_idx + 500]
handles_btn_editeaza = (
"btn-editeaza" in handler_zone
or 'detaliu-modal-body' in handler_zone
or "editare-modal" in handler_zone
)
assert handles_btn_editeaza, (
"Handler-ul htmx:beforeRequest din base.html trebuie sa trateze si butonul "
".btn-editeaza (sau sa verifice hx-target='#detaliu-modal-body') pentru a "
"apela open() si instala focus-trap-ul (US-006)."
)

View File

@@ -287,4 +287,79 @@ def test_login_signup_full_width_mobil(client):
# Regula mobil: cardul nu depaseste viewport-ul.
mobil = html[html.find("@media (max-width:767px)"):]
assert ".auth-card" in mobil, ruta
assert "max-width:100%" in mobil, ruta
# ============================================================
# PRD 5.12 US-008: responsive tableta+mobil + header fara suprapuneri
# ============================================================
def test_header_are_breakpoint_tableta(client):
"""US-008 RED: exista reguli @media intre 768 si 1024 pentru header.
Desktop: grid 3-coloane + min-height:92px. Pe tableta (768-1024px) nu exista
inca breakpoint — logo+titlu+badge+tema+versiune+hamburger se inghesuie si se suprapun."""
_create_account_user("bt@test.com")
_login(client, "bt@test.com")
html = client.get("/?tab=acasa").text
# Bloc media dedicat tabletei (range min-width:768 si max-width:1024px).
assert "@media (min-width:768px) and (max-width:1024px)" in html, \
"Lipseste blocul @media tableta (min-width:768px) and (max-width:1024px)"
# Blocul tableta contine reguli pentru header sau elemente de header.
idx = html.find("@media (min-width:768px) and (max-width:1024px)")
bloc = html[idx:idx + 800]
assert "header" in bloc or ".brand-logo" in bloc, \
"Blocul tableta nu are reguli pentru header sau .brand-logo"
def test_header_elemente_nu_au_min_height_fix_pe_mobil(client):
"""US-008 RED: header-ul nu forteaza min-height:92px pe tableta si mobil.
Regula de baza (desktop) are min-height:92px; pe tableta (768-1024px)
lipseste resetarea -> inghesuire garantata la ~820px."""
_create_account_user("mh@test.com")
_login(client, "mh@test.com")
html = client.get("/?tab=acasa").text
# Regula de baza desktop are min-height:92px (sa nu dispara).
assert "min-height:92px" in html, "Regula desktop min-height:92px a disparut"
# Blocul tableta (768-1024px) trebuie sa reseteze min-height pe header.
idx_t = html.find("@media (min-width:768px) and (max-width:1024px)")
assert idx_t != -1, "Lipseste blocul @media tableta (768-1024px)"
tableta = html[idx_t:idx_t + 800]
assert "min-height:0" in tableta, \
"Blocul tableta nu reseteaza min-height pentru header"
# Blocul mobil (<768px) reseteaza si el min-height (regresie: nu a disparut).
# Folosim `{` ca sa nu potrivim mentionarile din comentarii CSS.
mobil_idx = html.find("@media (max-width:767px) {")
assert mobil_idx != -1
mobil = html[mobil_idx:mobil_idx + 5000]
assert "min-height:0" in mobil, "Blocul mobil a pierdut resetarea min-height pe header"
def test_modal_full_screen_pe_mobil(client):
"""US-008 D#13 verificare: regula full-screen mobil pentru modal exista in base.html
(@media max-width:767px) si se aplica modalului global prin clasa modal-overlay.
VERIFICA prezenta regulii, NU re-implementa."""
_create_account_user("mfp@test.com")
_login(client, "mfp@test.com")
html = client.get("/?tab=acasa").text
# Regula CSS full-screen exista in blocul @media (max-width:767px) {.
# Folosim varianta cu `{` ca sa NU potrivim mentionarile din comentarii CSS.
mobil_idx = html.find("@media (max-width:767px) {")
assert mobil_idx != -1, "Nu exista bloc @media (max-width:767px) { in CSS"
mobil = html[mobil_idx:mobil_idx + 5000]
assert "100vw" in mobil, "Dialogul nu are latime 100vw pe mobil"
assert "100vh" in mobil, "Dialogul nu are inaltime 100vh pe mobil"
# Butonul de inchidere >=44px (tinta touch) pe mobil.
assert "44px" in mobil, "Butonul modal-close nu are tinta touch 44px pe mobil"
# Modalul global din HTML foloseste clasa modal-overlay -> prinde regula CSS.
assert 'class="modal-overlay"' in html, \
"Modalul global nu are class=modal-overlay (nu prinde regula full-screen)"
# Target swap pentru editare preview (US-006) exista in DOM.
assert 'id="detaliu-modal-body"' in html, \
"Target #detaliu-modal-body lipseste din base.html"

View File

@@ -79,10 +79,12 @@ def test_signup_creeaza_cont_user_si_cheie(client):
def test_signup_email_duplicat_eroare(client):
"""Email duplicat -> ROLLBACK; COUNT(accounts) neschimbat (fara cont orfan)."""
from tests.conftest import make_test_cui
resp = client.get("/signup")
token = _csrf(resp.text)
client.post("/signup", data={
"name": "Service A",
"cui": make_test_cui("dup@example.com"),
"email": "dup@example.com",
"parola": "parolasecreta",
"csrf_token": token,
@@ -97,6 +99,7 @@ def test_signup_email_duplicat_eroare(client):
token = _csrf(resp.text)
resp2 = client.post("/signup", data={
"name": "Service B",
"cui": make_test_cui("dup-b@example.com"),
"email": "dup@example.com",
"parola": "altaparola123",
"csrf_token": token,
@@ -138,11 +141,13 @@ def test_signup_parola_scurta_eroare(client):
def test_cheie_afisata_o_data(client):
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
from tests.conftest import make_test_cui
resp = client.get("/signup")
token = _csrf(resp.text)
resp_post = client.post("/signup", data={
"name": "Service Cheie",
"cui": make_test_cui("cheie@test.com"),
"email": "cheie@test.com",
"parola": "parolasecreta",
"csrf_token": token,

View File

@@ -32,7 +32,7 @@ def _create(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
active = not args.inactive
if not args.with_key:
try:
acct_id = create_account(conn, args.name, args.cui, active=active)
acct_id = create_account(conn, args.name, cui=args.cui, email=args.email, active=active)
except ValueError as exc:
print(f"eroare: {exc}", file=sys.stderr)
return 2
@@ -42,7 +42,7 @@ def _create(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
# --with-key: cont + cheie in aceeasi tranzactie (DB ruleaza autocommit).
conn.execute("BEGIN IMMEDIATE")
try:
acct_id = create_account(conn, args.name, args.cui, active=active)
acct_id = create_account(conn, args.name, cui=args.cui, email=args.email, active=active)
key = create_api_key(conn, acct_id)
conn.execute("COMMIT")
except ValueError as exc:
@@ -101,7 +101,8 @@ def main(argv: list[str] | None = None) -> int:
p_create = sub.add_parser("create", help="creeaza un cont nou")
p_create.add_argument("--name", required=True, help="nume cont (service)")
p_create.add_argument("--cui", default=None, help="CUI (unic cand e prezent)")
p_create.add_argument("--cui", required=True, help="CUI firma (obligatoriu, unic)")
p_create.add_argument("--email", required=True, help="email de contact al firmei (obligatoriu)")
p_create.add_argument("--inactive", action="store_true", help="creeaza cont in asteptare (active=0)")
p_create.add_argument("--with-key", action="store_true", help="emite si prima cheie API (atomic)")