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:
22
TODOS.md
22
TODOS.md
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
|
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)
|
## Din /autoplan PRD 5.11 (2026-06-26)
|
||||||
|
|
||||||
- [ ] **E2E smoke de first-run ca poarta de release** — codifica scriptul de dogfooding
|
- [ ] **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
|
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.
|
„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.)
|
(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.)
|
||||||
|
|||||||
@@ -18,31 +18,49 @@ import sqlite3
|
|||||||
|
|
||||||
|
|
||||||
def _norm_cui(cui: str | None) -> str | None:
|
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:
|
if cui is None:
|
||||||
return None
|
return None
|
||||||
cui = cui.strip().upper()
|
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(
|
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:
|
) -> int:
|
||||||
"""Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1).
|
"""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
|
`name` gol/whitespace -> ValueError. `cui` se normalizeaza (trim+upper); sir gol -> ValueError.
|
||||||
deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial
|
`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.
|
`ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta.
|
||||||
"""
|
"""
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("name gol (un cont are nevoie de nume)")
|
raise ValueError("name gol (un cont are nevoie de nume)")
|
||||||
cui = _norm_cui(cui)
|
cui = _norm_cui(cui)
|
||||||
|
email = _norm_email(email)
|
||||||
try:
|
try:
|
||||||
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO accounts (name, cui, active, status) VALUES (?, ?, ?, ?)",
|
"INSERT INTO accounts (name, cui, email, active, status) VALUES (?, ?, ?, ?, ?)",
|
||||||
(name, cui, 1 if active else 0, "active" if active else "pending"),
|
(name, cui, email, 1 if active else 0, "active" if active else "pending"),
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
|
||||||
@@ -54,6 +72,21 @@ def create_account(
|
|||||||
return int(cur.lastrowid or 0)
|
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:
|
def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
|
||||||
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
|
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
|
||||||
Cont inexistent -> ValueError.
|
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'
|
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
|
||||||
(stergere soft -> invizibile in panou)."""
|
(stergere soft -> invizibile in panou)."""
|
||||||
rows = conn.execute(
|
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"
|
"WHERE status != 'deleted' ORDER BY id"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ def _resolve_row_for_preview(
|
|||||||
override: dict[str, Any] | None = None,
|
override: dict[str, Any] | None = None,
|
||||||
valid_codes: set[str] | None = None,
|
valid_codes: set[str] | None = None,
|
||||||
text_rules: list[dict] | None = None,
|
text_rules: list[dict] | None = None,
|
||||||
|
reviewed: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
|
"""Rezolva un rand din import pentru preview: aplica mapare coloane + validare.
|
||||||
|
|
||||||
@@ -220,8 +221,10 @@ def _resolve_row_for_preview(
|
|||||||
# Validare continut
|
# Validare continut
|
||||||
errors = validate_prezentare(mapped)
|
errors = validate_prezentare(mapped)
|
||||||
|
|
||||||
if all_flags:
|
if all_flags and not reviewed:
|
||||||
# needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala
|
# 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 {
|
return {
|
||||||
"resolved_status": "needs_review",
|
"resolved_status": "needs_review",
|
||||||
"resolved": mapped,
|
"resolved": mapped,
|
||||||
@@ -337,7 +340,10 @@ def apply_row_override(
|
|||||||
|
|
||||||
new_override = _merge_override(current, fields)
|
new_override = _merge_override(current, fields)
|
||||||
enc = encrypt_creds(new_override) if new_override else None
|
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
|
return new_override
|
||||||
|
|
||||||
|
|
||||||
@@ -932,10 +938,30 @@ def commit_import(
|
|||||||
if batch["status"] == "committed":
|
if batch["status"] == "committed":
|
||||||
raise HTTPException(status_code=409, detail="batch deja comis")
|
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(
|
ok_rows_db = conn.execute(
|
||||||
"SELECT row_index, raw_json, override_json, resolved_status FROM import_rows "
|
"SELECT row_index, raw_json, override_json, resolved_status, reviewed "
|
||||||
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index",
|
"FROM import_rows "
|
||||||
|
"WHERE batch_id=? AND (resolved_status='ok' OR "
|
||||||
|
"(resolved_status='needs_review' AND reviewed=1)) "
|
||||||
|
"ORDER BY row_index",
|
||||||
(import_id,),
|
(import_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
@@ -947,8 +973,6 @@ def commit_import(
|
|||||||
|
|
||||||
# Decripteaza randurile ok
|
# Decripteaza randurile ok
|
||||||
ok_rows: list[dict] = []
|
ok_rows: list[dict] = []
|
||||||
ok_indices: list[int] = []
|
|
||||||
review_indices: set[int] = set()
|
|
||||||
|
|
||||||
for r in ok_rows_db:
|
for r in ok_rows_db:
|
||||||
try:
|
try:
|
||||||
@@ -957,28 +981,12 @@ def commit_import(
|
|||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
ok_rows.append({
|
||||||
if r["resolved_status"] == "ok":
|
"row_index": r["row_index"],
|
||||||
ok_rows.append({"row_index": r["row_index"], "data": row_data,
|
"data": row_data,
|
||||||
"override": _override_of(r), "status": "ok"})
|
"override": _override_of(r),
|
||||||
ok_indices.append(r["row_index"])
|
"status": r["resolved_status"],
|
||||||
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
|
|
||||||
|
|
||||||
# Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok
|
# Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok
|
||||||
n_total_ok = len(ok_rows)
|
n_total_ok = len(ok_rows)
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ class Settings(BaseSettings):
|
|||||||
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
||||||
session_https_only: bool = False
|
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 ---
|
# --- Notificare email admin la signup ---
|
||||||
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP).
|
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP).
|
||||||
smtp_host: str | None = None
|
smtp_host: str | None = None
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
|
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
|
||||||
"CHECK (on_unmapped_error_default IN (0, 1))"
|
"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.
|
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
"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()}
|
irows_cols = {r["name"] for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()}
|
||||||
if "override_json" not in irows_cols:
|
if "override_json" not in irows_cols:
|
||||||
conn.execute("ALTER TABLE import_rows ADD COLUMN override_json TEXT")
|
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)
|
# Index batch_id pe submissions (poate lipsi pe DB veche)
|
||||||
existing_idx = {r["name"] for r in conn.execute(
|
existing_idx = {r["name"] for r in conn.execute(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
cui TEXT,
|
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
|
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
|
-- 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.
|
-- 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,
|
row_index INTEGER NOT NULL,
|
||||||
raw_json TEXT NOT NULL, -- PII criptat (Fernet, ca submissions)
|
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
|
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'
|
resolved_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
CHECK (resolved_status IN (
|
CHECK (resolved_status IN (
|
||||||
'pending','ok','needs_mapping','needs_data',
|
'pending','ok','needs_mapping','needs_data',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from .. import __version__
|
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 ..config import get_settings
|
||||||
from ..db import get_connection
|
from ..db import get_connection
|
||||||
from ..web.csrf import get_csrf_token, verify_csrf
|
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)
|
accounts = list_accounts(conn)
|
||||||
emails = _emails_by_account(conn)
|
emails = _emails_by_account(conn)
|
||||||
for acct in accounts:
|
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"])
|
acct["email"] = emails.get(acct["id"])
|
||||||
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
|
# 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.
|
# 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:
|
for aid in ids:
|
||||||
try:
|
try:
|
||||||
if action == "activate":
|
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")
|
set_status(conn, aid, "active")
|
||||||
elif action == "block":
|
elif action == "block":
|
||||||
set_status(conn, aid, "blocked")
|
set_status(conn, aid, "blocked")
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ async def signup_post(
|
|||||||
name=name, cui=cui, email=email,
|
name=name, cui=cui, email=email,
|
||||||
), status_code=422)
|
), 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,
|
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
|
||||||
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
|
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -76,10 +86,43 @@ async def signup_post(
|
|||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
is_first = count_admins(conn) == 0
|
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)
|
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
|
||||||
api_key = create_api_key(conn, account_id)
|
api_key = create_api_key(conn, account_id)
|
||||||
conn.execute("COMMIT")
|
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:
|
except Exception as exc:
|
||||||
conn.execute("ROLLBACK")
|
conn.execute("ROLLBACK")
|
||||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||||
|
|||||||
@@ -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).
|
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
|
||||||
"""
|
"""
|
||||||
from ..mapping import account_or_default
|
from ..mapping import account_or_default
|
||||||
|
from ..accounts import account_is_complete as _acct_is_complete
|
||||||
acct = account_or_default(account_id)
|
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(
|
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()
|
).fetchone()
|
||||||
are_creds = bool(row and row["rar_creds_enc"])
|
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)?
|
# Pas 3: are cel putin un submission (trimis sau in coada)?
|
||||||
row_sub = conn.execute(
|
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),
|
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
|
||||||
# Acasa include caseta de upload -> are nevoie de csrf_token
|
# Acasa include caseta de upload -> are nevoie de csrf_token
|
||||||
"csrf_token": get_csrf_token(request),
|
"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."""
|
"""Randeaza panoul Cont ca string HTML."""
|
||||||
from ..mapping import account_or_default
|
from ..mapping import account_or_default
|
||||||
acct = account_or_default(account_id)
|
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"])
|
are_creds = bool(row and row["rar_creds_enc"])
|
||||||
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
return templates.get_template("_cont.html").render({
|
return templates.get_template("_cont.html").render({
|
||||||
"request": request,
|
"request": request,
|
||||||
"csrf_token": get_csrf_token(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_mesaj": None,
|
||||||
"creds_eroare": None,
|
"creds_eroare": None,
|
||||||
"rot_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."
|
return "Batch de import inexistent sau inaccesibil."
|
||||||
|
|
||||||
raw_rows_db = conn.execute(
|
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,),
|
(import_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
if not raw_rows_db:
|
if not raw_rows_db:
|
||||||
@@ -1874,6 +1886,7 @@ def _web_compute_preview(
|
|||||||
# Decripteaza randurile + override-urile editate
|
# Decripteaza randurile + override-urile editate
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
overrides: list[dict[str, Any]] = []
|
overrides: list[dict[str, Any]] = []
|
||||||
|
reviewed_flags: list[bool] = []
|
||||||
for r in raw_rows_db:
|
for r in raw_rows_db:
|
||||||
try:
|
try:
|
||||||
row_data = decrypt_creds(r["raw_json"]) or {}
|
row_data = decrypt_creds(r["raw_json"]) or {}
|
||||||
@@ -1885,6 +1898,7 @@ def _web_compute_preview(
|
|||||||
except Exception:
|
except Exception:
|
||||||
ov = None
|
ov = None
|
||||||
overrides.append(ov or {})
|
overrides.append(ov or {})
|
||||||
|
reviewed_flags.append(bool(r["reviewed"]))
|
||||||
|
|
||||||
col_names = list(rows[0].keys()) if rows else []
|
col_names = list(rows[0].keys()) if rows else []
|
||||||
sig = _signature(col_names)
|
sig = _signature(col_names)
|
||||||
@@ -1952,6 +1966,7 @@ def _web_compute_preview(
|
|||||||
override=overrides[i] or None,
|
override=overrides[i] or None,
|
||||||
valid_codes=valid_codes,
|
valid_codes=valid_codes,
|
||||||
text_rules=text_rules,
|
text_rules=text_rules,
|
||||||
|
reviewed=reviewed_flags[i],
|
||||||
)
|
)
|
||||||
|
|
||||||
key: str | None = None
|
key: str | None = None
|
||||||
@@ -2161,12 +2176,14 @@ async def web_upload_import(
|
|||||||
if sugg:
|
if sugg:
|
||||||
fuzzy_suggestions[col] = sugg
|
fuzzy_suggestions[col] = sugg
|
||||||
|
|
||||||
|
_sample = parsed.rows[:3]
|
||||||
return templates.TemplateResponse("_mapcoloane.html", {
|
return templates.TemplateResponse("_mapcoloane.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"import_id": batch_id_int,
|
"import_id": batch_id_int,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"columns": parsed.columns,
|
"columns": parsed.columns,
|
||||||
"sample_rows": parsed.rows[:3],
|
"sample_rows": _sample,
|
||||||
|
"prima_inregistrare": _sample[0] if _sample else None,
|
||||||
"fuzzy_suggestions": fuzzy_suggestions,
|
"fuzzy_suggestions": fuzzy_suggestions,
|
||||||
"canonical_fields": _CANONICAL_FIELDS,
|
"canonical_fields": _CANONICAL_FIELDS,
|
||||||
"format_data": None,
|
"format_data": None,
|
||||||
@@ -2379,23 +2396,63 @@ def _render_preview_rand(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
|
@router.get("/_import/{import_id}/rand/{row_index}/editare-modal", response_class=HTMLResponse)
|
||||||
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
def web_rand_editare_modal(request: Request, import_id: int, row_index: int) -> HTMLResponse:
|
||||||
"""Intra in mod editare pe un rand de preview (randul devine FORM propriu)."""
|
"""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)
|
account_id = require_login(request)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
result, row = _preview_one_row(conn, import_id, account_id, row_index)
|
result, row = _preview_one_row(conn, import_id, account_id, row_index)
|
||||||
if row is None or isinstance(result, str):
|
if row is None or isinstance(result, str):
|
||||||
raise HTTPException(status_code=404, detail="rand de import inexistent")
|
raise HTTPException(status_code=404, detail="rand de import inexistent")
|
||||||
return _render_preview_rand(
|
res = row.get("resolved") or {}
|
||||||
request, import_id=import_id, row=row, editing=True,
|
err_map: dict[str, str] = {}
|
||||||
include_oob=False, summary=result["summary"],
|
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:
|
finally:
|
||||||
conn.close()
|
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)
|
@router.get("/_import/{import_id}/rand/{row_index}", response_class=HTMLResponse)
|
||||||
def web_rand_display(request: Request, import_id: int, row_index: int) -> 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."""
|
"""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)
|
@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:
|
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.
|
US-006 (PRD 5.12):
|
||||||
Daca raman erori de continut pe camp, randul ramane in editare cu valorile pastrate
|
- Succes: raspuns cu OOB pe rand (#preview-row-N) + OOB contoare (#preview-rezumat) +
|
||||||
si mesajul pe campul vinovat."""
|
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)
|
account_id = require_login(request)
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
|
# 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(
|
apply_row_override(
|
||||||
conn, import_id=import_id, account_id=account_id,
|
conn, import_id=import_id, account_id=account_id,
|
||||||
row_index=row_index, fields=fields,
|
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 isinstance(e, dict) and e.get("field")
|
||||||
]
|
]
|
||||||
if field_errors:
|
if field_errors:
|
||||||
return _render_preview_rand(
|
# Eroare de validare: re-randeaza formularul in modal cu valorile introduse.
|
||||||
request, import_id=import_id, row=row, editing=True,
|
# Modalul RAMANE DESCHIS (fara HX-Trigger-After-Settle:inchideModal).
|
||||||
include_oob=True, summary=result["summary"],
|
res = row.get("resolved") or {}
|
||||||
message="Mai sunt valori invalide — corecteaza campurile marcate.",
|
err_map: dict[str, str] = {}
|
||||||
)
|
fix_map: dict[str, str] = {}
|
||||||
return _render_preview_rand(
|
for e in field_errors:
|
||||||
request, import_id=import_id, row=row, editing=False,
|
if e.get("field"):
|
||||||
include_oob=True, summary=result["summary"],
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -2499,6 +2667,82 @@ async def web_mapare_operatie(
|
|||||||
conn.close()
|
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)
|
@router.get("/_import/reset", response_class=HTMLResponse)
|
||||||
def web_import_reset(request: Request) -> HTMLResponse:
|
def web_import_reset(request: Request) -> HTMLResponse:
|
||||||
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
||||||
@@ -2532,14 +2776,11 @@ async def web_confirma_import(
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
n_confirmat = 0
|
n_confirmat = 0
|
||||||
|
|
||||||
# Randuri needs_review bifate explicit
|
# US-007: reviewed_rows (checkboxe vechi) NU mai este sursa de adevar pentru gate-ul
|
||||||
reviewed_rows: set[int] = set()
|
# de commit pe canalul web. Gate-ul este derivat din DB import_rows.reviewed (D#8).
|
||||||
for v in form.getlist("reviewed_rows"):
|
# Randurile needs_review confirmate de operator via /confirma-review au resolved_status='ok'
|
||||||
if isinstance(v, str):
|
# in DB (recalculat de _web_compute_preview), asa ca interogarea de mai jos include corect
|
||||||
try:
|
# TOATE randurile gata de trimis.
|
||||||
reviewed_rows.add(int(v))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
confirmed_by = str(form.get("confirmed_by") or "").strip() or None
|
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."
|
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(
|
ok_rows_db = conn.execute(
|
||||||
"SELECT row_index, raw_json, override_json, resolved_status FROM import_rows "
|
"SELECT row_index, raw_json, override_json, resolved_status, reviewed "
|
||||||
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index",
|
"FROM import_rows "
|
||||||
|
"WHERE batch_id=? AND (resolved_status='ok' OR "
|
||||||
|
"(resolved_status='needs_review' AND reviewed=1)) "
|
||||||
|
"ORDER BY row_index",
|
||||||
(import_id,),
|
(import_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
@@ -2584,28 +2834,18 @@ async def web_confirma_import(
|
|||||||
|
|
||||||
# Decripteaza si construieste lista de randuri de trimis
|
# Decripteaza si construieste lista de randuri de trimis
|
||||||
to_enqueue: list[dict[str, Any]] = []
|
to_enqueue: list[dict[str, Any]] = []
|
||||||
review_indices: set[int] = set()
|
|
||||||
|
|
||||||
for r in ok_rows_db:
|
for r in ok_rows_db:
|
||||||
try:
|
try:
|
||||||
row_data = decrypt_creds(r["raw_json"]) or {}
|
row_data = decrypt_creds(r["raw_json"]) or {}
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if r["resolved_status"] == "ok":
|
to_enqueue.append({
|
||||||
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
|
"row_index": r["row_index"],
|
||||||
"override": _override_of(r), "status": "ok"})
|
"data": row_data,
|
||||||
elif r["resolved_status"] == "needs_review":
|
"override": _override_of(r),
|
||||||
review_indices.add(r["row_index"])
|
"status": r["resolved_status"],
|
||||||
|
})
|
||||||
# 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
|
|
||||||
|
|
||||||
n_total_ok = len(to_enqueue)
|
n_total_ok = len(to_enqueue)
|
||||||
|
|
||||||
@@ -2816,6 +3056,9 @@ def _render_cont(
|
|||||||
creds_mesaj: str | None = None,
|
creds_mesaj: str | None = None,
|
||||||
creds_eroare: str | None = None,
|
creds_eroare: str | None = None,
|
||||||
rot_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:
|
) -> HTMLResponse:
|
||||||
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
|
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -2827,22 +3070,41 @@ def _render_cont(
|
|||||||
creds_mesaj=creds_mesaj,
|
creds_mesaj=creds_mesaj,
|
||||||
creds_eroare=creds_eroare,
|
creds_eroare=creds_eroare,
|
||||||
rot_eroare=rot_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)
|
@router.get("/_fragments/cont", response_class=HTMLResponse)
|
||||||
def fragment_cont(request: Request) -> 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)
|
account_id = require_login(request)
|
||||||
acct = account_or_default(account_id)
|
acct = account_or_default(account_id)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
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()
|
).fetchone()
|
||||||
are_creds = bool(row and row["rar_creds_enc"])
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -2863,7 +3125,149 @@ def cont_roteste_cheie(
|
|||||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
are_creds = bool(row and row["rar_creds_enc"])
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -2949,12 +3353,14 @@ def cont_rar_creds(
|
|||||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
are_creds = bool(row and row["rar_creds_enc"])
|
are_creds = bool(row and row["rar_creds_enc"])
|
||||||
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=are_creds,
|
are_creds=are_creds,
|
||||||
creds_eroare="Email si parola sunt obligatorii.",
|
creds_eroare="Email si parola sunt obligatorii.",
|
||||||
|
account_meta=account_meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
enc = encrypt_creds({"email": email, "password": parola})
|
enc = encrypt_creds({"email": email, "password": parola})
|
||||||
@@ -2964,10 +3370,12 @@ def cont_rar_creds(
|
|||||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||||
(enc, acct),
|
(enc, acct),
|
||||||
)
|
)
|
||||||
|
account_meta = _fetch_account_meta(conn, acct)
|
||||||
return _render_cont(
|
return _render_cont(
|
||||||
request,
|
request,
|
||||||
are_creds=True,
|
are_creds=True,
|
||||||
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
|
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
|
||||||
|
account_meta=account_meta,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
<div id="acasa-section">
|
<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 →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# === Container colapsabil: stepper + upload intr-un singur element <details> (US-006).
|
{# === Container colapsabil: stepper + upload intr-un singur element <details> (US-006).
|
||||||
Serverul seteaza atributul `open` din are_trimiteri:
|
Serverul seteaza atributul `open` din are_trimiteri:
|
||||||
are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS)
|
are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS)
|
||||||
|
|||||||
@@ -1,6 +1,47 @@
|
|||||||
<div class="card" id="card-cont">
|
<div class="card" id="card-cont">
|
||||||
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
|
<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 -->
|
<!-- Sectiunea: Cheia mea API -->
|
||||||
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
|
<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>
|
<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>
|
||||||
|
|||||||
78
app/web/templates/_editare_preview_modal.html
Normal file
78
app/web/templates/_editare_preview_modal.html
Normal 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>
|
||||||
41
app/web/templates/_form_editare.html
Normal file
41
app/web/templates/_form_editare.html
Normal 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>
|
||||||
@@ -4,3 +4,49 @@
|
|||||||
Simbolul pastrat (apelat in _mapari.html, _preview_import.html, _trimitere_detaliu.html)
|
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). #}
|
dar intoarce string gol. Coloanele DB raman (default 1, ne-citite pentru hold). #}
|
||||||
{% macro autosend_toggle(form_id='', checked=True, label='') -%}{%- endmacro %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<div id="import-section">
|
<div id="import-section">
|
||||||
{% set pas = 2 %}{% include '_stepper.html' %}
|
{% set pas = 2 %}{% include '_stepper.html' %}
|
||||||
{% from '_eroare.html' import card_erori %}
|
{% 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">
|
<div class="card">
|
||||||
<h2 style="font-size:15px; margin:0 0 12px;">
|
<h2 style="font-size:15px; margin:0 0 12px;">
|
||||||
Mapare coloane —
|
Mapare coloane —
|
||||||
@@ -23,6 +25,44 @@
|
|||||||
Maparea se retine automat pentru fisiere cu acelasi antet.
|
Maparea se retine automat pentru fisiere cu acelasi antet.
|
||||||
</p>
|
</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"
|
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
|
||||||
hx-target="#import-section"
|
hx-target="#import-section"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
@@ -87,12 +127,19 @@
|
|||||||
|
|
||||||
<div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
<div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||||
<button type="submit"
|
<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
|
Salveaza si continua la preview
|
||||||
</button>
|
</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;">
|
<span class="muted" style="font-size:12px;">
|
||||||
maparea se retine pentru fisiere cu acelasi antet
|
maparea se retine pentru fisiere cu acelasi antet
|
||||||
</span>
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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 %}
|
{% if unmapped_ops %}
|
||||||
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
|
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
|
||||||
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
|
<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
|
preselectata) si salveaza — randurile blocate trec automat in
|
||||||
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
|
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
|
||||||
</p>
|
</p>
|
||||||
{% for e in unmapped_ops %}
|
<form hx-post="/_import/{{ import_id }}/mapare-operatii"
|
||||||
{%- set top = e.suggestions[0] if e.suggestions else None -%}
|
hx-target="#import-section" hx-swap="outerHTML">
|
||||||
{%- 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;">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||||
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
|
{% for e in unmapped_ops %}
|
||||||
<div class="mapcol grow">
|
{%- set top = e.suggestions[0] if e.suggestions else None -%}
|
||||||
<div><strong>{{ e.cod_op_service }}</strong>
|
{%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%}
|
||||||
<span class="pill" title="randuri blocate">{{ e.blocked }} randuri</span></div>
|
<div class="maprow" style="align-items:flex-end; margin-bottom:10px;">
|
||||||
{% if e.denumire and e.denumire != e.cod_op_service %}
|
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
|
||||||
<div class="muted">{{ e.denumire }}</div>
|
<div class="mapcol grow">
|
||||||
{% endif %}
|
<div><strong>{{ e.cod_op_service }}</strong>
|
||||||
{% if e.suggestions %}
|
<span class="pill" title="randuri blocate">{{ e.blocked }} randuri</span></div>
|
||||||
<div class="muted" style="font-size:12px; margin-top:4px;">
|
{% if e.denumire and e.denumire != e.cod_op_service %}
|
||||||
sugestii:
|
<div class="muted">{{ e.denumire }}</div>
|
||||||
{% for s in e.suggestions[:3] %}
|
{% endif %}
|
||||||
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
|
{% if e.suggestions %}
|
||||||
{% endfor %}
|
<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>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mapcol">
|
{% endfor %}
|
||||||
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ e.cod_op_service }}">
|
<div style="margin-top:12px;">
|
||||||
<option value="">— alege cod RAR —</option>
|
<button type="submit" style="min-height:44px;">Salveaza maparile</button>
|
||||||
{% 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>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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).
|
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
|
||||||
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
|
US-007: 8 coloane (coloana de verificare eliminata).
|
||||||
altfel Enter intr-un camp ar declansa trimiterea ireversibila). Bifele
|
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
|
||||||
needs_review se asociaza la #confirm-form prin atributul form=. -->
|
|
||||||
<div class="tablewrap tabel-trimiteri">
|
<div class="tablewrap tabel-trimiteri">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -119,7 +137,6 @@
|
|||||||
<th class="col-data">Data</th>
|
<th class="col-data">Data</th>
|
||||||
<th class="col-km">KM final</th>
|
<th class="col-km">KM final</th>
|
||||||
<th class="col-note">Note</th>
|
<th class="col-note">Note</th>
|
||||||
<th class="col-verificat">Verificat?</th>
|
|
||||||
<th class="col-actiuni">Actiuni</th>
|
<th class="col-actiuni">Actiuni</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -164,10 +181,7 @@
|
|||||||
style="max-width:80px;"
|
style="max-width:80px;"
|
||||||
aria-describedby="n-hint">
|
aria-describedby="n-hint">
|
||||||
<span id="n-hint" class="muted" style="font-size:12px; margin-left:6px;">
|
<span id="n-hint" class="muted" style="font-size:12px; margin-left:6px;">
|
||||||
(<span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> ok
|
(<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -226,20 +240,19 @@
|
|||||||
return el ? parseInt(el.dataset.ok || '0', 10) : 0;
|
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() {
|
function updateN() {
|
||||||
var checked = document.querySelectorAll('input[name="reviewed_rows"]:checked').length;
|
var total = getOk();
|
||||||
var total = getOk() + checked;
|
|
||||||
var inp = document.getElementById('n-confirmat');
|
var inp = document.getElementById('n-confirmat');
|
||||||
var disp = document.getElementById('n-display');
|
var disp = document.getElementById('n-display');
|
||||||
var btn = document.getElementById('confirm-btn');
|
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 (inp) inp.value = total;
|
||||||
if (disp) disp.textContent = total;
|
if (disp) disp.textContent = total;
|
||||||
var hintOk = document.getElementById('n-hint-ok');
|
var hintOk = document.getElementById('n-hint-ok');
|
||||||
if (hintOk) hintOk.textContent = getOk();
|
if (hintOk) hintOk.textContent = total;
|
||||||
if (btn) btn.disabled = (total === 0) || editing;
|
if (btn) btn.disabled = (total === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filtrare randuri dupa stare.
|
/* Filtrare randuri dupa stare.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
{#
|
{#
|
||||||
_preview_rand.html — un singur rand de preview import.
|
_preview_rand.html — un singur rand de preview import.
|
||||||
Doua moduri:
|
US-006 (PRD 5.12): editarea inline (tr.preview-edit + mutual-exclusion script)
|
||||||
- display (editing falsy): <tr> normal cu 9 coloane in format .tabel-trimiteri.
|
a fost eliminata. Butonul Editeaza deschide MODALUL global (#detaliu-modal-body).
|
||||||
- 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.
|
Parametri:
|
||||||
Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section.
|
editing — ELIMINAT (ignorat, pastrat pentru compatibilitate apeluri vechi)
|
||||||
La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob).
|
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):
|
Campuri pre-computate de _web_compute_preview (NOT din template raw):
|
||||||
row.prez — prezentare_din_payload(resolved): vehicul_nr, vin_scurt,
|
row.prez — prezentare_din_payload(resolved): vehicul_nr, vin_scurt,
|
||||||
@@ -16,87 +18,10 @@
|
|||||||
#}
|
#}
|
||||||
{%- set res = row.resolved -%}
|
{%- set res = row.resolved -%}
|
||||||
{%- set status = row.resolved_status -%}
|
{%- 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 = {} -%}
|
{%- 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 -%}
|
{%- 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 }}"
|
<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 %}">
|
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-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td>
|
||||||
<td class="col-stare" data-eticheta="Stare">
|
<td class="col-stare" data-eticheta="Stare">
|
||||||
@@ -105,7 +30,7 @@
|
|||||||
<td class="col-vehicul" data-eticheta="Vehicul">
|
<td class="col-vehicul" data-eticheta="Vehicul">
|
||||||
{{ row.prez.vehicul_nr }}
|
{{ row.prez.vehicul_nr }}
|
||||||
{% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %}
|
{% 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 %}
|
{% endif %}
|
||||||
{# Fix-uri de validare pe vehicul #}
|
{# Fix-uri de validare pe vehicul #}
|
||||||
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
|
{% 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 '' }}
|
{{ row.nota_umana or '' }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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;">
|
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
|
||||||
{% if status not in ('already_sent', 'duplicate_in_file') %}
|
{% if status not in ('already_sent', 'duplicate_in_file') %}
|
||||||
<button type="button" class="btn-editeaza"
|
<button type="button" class="btn-editeaza"
|
||||||
style="min-height:44px; padding:6px 14px; font-size:13px;
|
style="min-height:44px; padding:6px 14px; font-size:13px;
|
||||||
background:transparent; border-color:var(--line); color:var(--ink);"
|
background:transparent; border-color:var(--line); color:var(--ink);"
|
||||||
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare"
|
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare-modal"
|
||||||
hx-target="#preview-row-{{ row.row_index }}" hx-swap="outerHTML"
|
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
||||||
aria-label="Editeaza randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
|
aria-label="Editeaza randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
|
||||||
Editeaza
|
Editeaza
|
||||||
</button>
|
</button>
|
||||||
@@ -165,7 +79,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if include_oob %}
|
{% 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 = [
|
{% set status_labels = [
|
||||||
('ok','Gata de trimis'), ('needs_review','Verifica valori'), ('needs_mapping','Cod RAR lipsa'),
|
('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')] %}
|
('needs_data','Date incomplete'), ('already_sent','Deja trimis'), ('duplicate_in_file','Duplicat in fisier')] %}
|
||||||
@@ -177,6 +91,19 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
|
<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>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
/* Editare incheiata: re-activeaza confirm + butoanele Editeaza, recalculeaza N.
|
/* Editare incheiata: re-activeaza confirm + butoanele Editeaza, recalculeaza N.
|
||||||
@@ -192,4 +119,3 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|||||||
@@ -82,6 +82,12 @@
|
|||||||
{% if editabil %}
|
{% if editabil %}
|
||||||
{% set err_map = {} %}
|
{% set err_map = {} %}
|
||||||
{% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %}
|
{% 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 %}
|
{% 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;"
|
<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 —
|
{# Erori fara camp (field None) nu dispar silentios in editare —
|
||||||
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
|
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 %}
|
{% 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>
|
<div class="s-error" style="font-size:13px; margin:0 0 10px;" role="alert">{{ e.problema }}</div>
|
||||||
{% endfor %}
|
{% 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"
|
<form hx-post="/trimitere/{{ id }}/corecteaza"
|
||||||
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
||||||
hx-disabled-elt="find button">
|
hx-disabled-elt="find button">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
{# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
|
{# 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 %}
|
{% if nomenclator_rar %}
|
||||||
<div style="margin:0 0 12px;">
|
<div style="margin:0 0 12px;">
|
||||||
<label for="c-cod-prestatie" class="muted" style="font-size:12px; display:block;">Operatie RAR (cod prestatie)</label>
|
<label for="c-cod-prestatie" class="muted" style="font-size:12px; display:block;">Operatie RAR (cod prestatie)</label>
|
||||||
@@ -139,7 +134,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
|
{# 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 %}
|
{% if prez.op_service_cod %}
|
||||||
<div style="margin:0 0 12px;">
|
<div style="margin:0 0 12px;">
|
||||||
<div class="muted" style="font-size:12px;">Operatie service</div>
|
<div class="muted" style="font-size:12px;">Operatie service</div>
|
||||||
@@ -147,22 +143,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
|
{# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY (US-005). === #}
|
||||||
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
|
{% include "_form_editare.html" %}
|
||||||
{{ 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>
|
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Context read-only pentru randuri ne-editabile (sent/sending/queued/error). #}
|
{# Context read-only pentru randuri ne-editabile (sent/sending/queued/error). #}
|
||||||
|
|||||||
@@ -60,7 +60,13 @@
|
|||||||
{% if v == 'delete' %}onsubmit="return confirm('Stergi acest cont? (stergere soft)');"{% endif %}>
|
{% 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="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
<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>
|
<button type="submit"{% if cls == 'danger' %} class="danger"{% endif %}>{{ label }}</button>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -345,15 +345,28 @@
|
|||||||
@media (max-width:1024px) {
|
@media (max-width:1024px) {
|
||||||
.tabel-trimiteri .col-actualizat { display:none; }
|
.tabel-trimiteri .col-actualizat { display:none; }
|
||||||
}
|
}
|
||||||
|
/* Tableta (768–1024px): 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.
|
/* === Preview import: coloane extra fata de tabelul Trimiteri.
|
||||||
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
|
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
|
||||||
nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat).
|
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) +
|
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.
|
col-km(76) + col-note(176) + col-actiuni(92) = 600px.
|
||||||
Restul (~600px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
|
Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
|
||||||
.tabel-trimiteri .col-km { width:76px; }
|
.tabel-trimiteri .col-km { width:76px; }
|
||||||
.tabel-trimiteri .col-note { width:176px; }
|
.tabel-trimiteri .col-note { width:176px; }
|
||||||
.tabel-trimiteri .col-verificat{ width:80px; }
|
|
||||||
.tabel-trimiteri .col-actiuni { width:92px; }
|
.tabel-trimiteri .col-actiuni { width:92px; }
|
||||||
/* Randul de editare inline iese din grila table-layout:fixed (display:block),
|
/* Randul de editare inline iese din grila table-layout:fixed (display:block),
|
||||||
astfel formularul nu e constrans de latimile coloanelor individuale.
|
astfel formularul nu e constrans de latimile coloanelor individuale.
|
||||||
@@ -814,9 +827,12 @@
|
|||||||
|
|
||||||
// Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder
|
// Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder
|
||||||
// inainte ca raspunsul fragmentului sa fie swap-uit in corp.
|
// 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) {
|
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||||
var elt = evt.detail && evt.detail.elt;
|
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.
|
// Dupa swap-ul fragmentului (sau re-render corectie/mapare): muta focusul in modal.
|
||||||
body.addEventListener('htmx:afterSettle', function() {
|
body.addEventListener('htmx:afterSettle', function() {
|
||||||
|
|||||||
@@ -50,8 +50,8 @@
|
|||||||
<input type="text" name="name" value="{{ name or '' }}" required style="width:100%;">
|
<input type="text" name="name" value="{{ name or '' }}" required style="width:100%;">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label>CUI <span style="color:var(--muted);font-size:12px;">(optional)</span></label><br>
|
<label>CUI <span style="color:var(--err)">*</span></label><br>
|
||||||
<input type="text" name="cui" value="{{ cui or '' }}" style="width:100%;">
|
<input type="text" name="cui" value="{{ cui or '' }}" required style="width:100%;">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label>Email <span style="color:var(--err)">*</span></label><br>
|
<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
232
docs/design.md
Normal 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 `14–16px`. Gap intre controale pe o linie `8–12px`.
|
||||||
|
|
||||||
|
### 2.4 Radius
|
||||||
|
|
||||||
|
| Valoare | Uz |
|
||||||
|
|---------|-----|
|
||||||
|
| `6px` | controale: butoane, input, select |
|
||||||
|
| `7–8px` | 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) |
|
||||||
|
| `768–1024px` | 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 8–10px,
|
||||||
|
stivuite vertical, gap intern 7–8px.
|
||||||
|
|
||||||
|
### 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. 768–1024px: 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 12–13px 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`.
|
||||||
598
docs/prd/prd-5.12-editare-modal-cont-obligatoriu-import.md
Normal file
598
docs/prd/prd-5.12-editare-modal-cont-obligatoriu-import.md
Normal 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 (768–1024px)** — 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 (768–1024px) 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 768–1024 + 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 (768–1024px)**: 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.
|
||||||
334
docs/prd/prd-5.13-responsive-compact.md
Normal file
334
docs/prd/prd-5.13-responsive-compact.md
Normal 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** (768–1024px): 8 coloane `table-layout:fixed` cu latimi
|
||||||
|
fixe storceau vehicul+operatie → text rupt.
|
||||||
|
- **P1 „afiseaza tot ca pe desktop"**: cardurile aratau toate cele 6–8 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 (768–1024px)**: 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 768–1024px**: `.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.
|
||||||
@@ -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__`.
|
(auth pornit, creds <test>) il seteaza punctual prin `monkeypatch`/`object.__setattr__`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
|
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
|
||||||
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "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):
|
def pytest_configure(config):
|
||||||
"""Markeri custom. `live` = teste care ating endpoint-ul real RAR (opt-in,
|
"""Markeri custom. `live` = teste care ating endpoint-ul real RAR (opt-in,
|
||||||
skip implicit; vezi tests/test_live_rar.py). Excludere: `-m 'not live'`."""
|
skip implicit; vezi tests/test_live_rar.py). Excludere: `-m 'not live'`."""
|
||||||
|
|||||||
@@ -112,4 +112,61 @@ def test_list_accounts_ordonat_fara_creds(conn):
|
|||||||
assert ids == sorted(ids)
|
assert ids == sorted(ids)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
assert "rar_creds_enc" not in r
|
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
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ def _csrf(client, url="/admin"):
|
|||||||
|
|
||||||
|
|
||||||
def _signup(client, name, email, password="parola_test_001"):
|
def _signup(client, name, email, password="parola_test_001"):
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
tok = _csrf(client, "/signup")
|
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)
|
"csrf_token": tok}, follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
from app.db import get_connection
|
from app.db import get_connection
|
||||||
|
|||||||
@@ -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:
|
def _signup(client: TestClient, name: str, email: str, password: str = "parola_test_001") -> int:
|
||||||
"""Creeaza cont via POST /signup si intoarce account_id."""
|
"""Creeaza cont via POST /signup si intoarce account_id."""
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
token = _get_csrf(client, "/signup")
|
token = _get_csrf(client, "/signup")
|
||||||
resp = client.post("/signup", data={
|
resp = client.post("/signup", data={
|
||||||
"name": name,
|
"name": name,
|
||||||
|
"cui": make_test_cui(email),
|
||||||
"email": email,
|
"email": email,
|
||||||
"parola": password,
|
"parola": password,
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
@@ -211,3 +213,51 @@ def test_activate_fara_csrf_403(client):
|
|||||||
assert resp.status_code == 403, (
|
assert resp.status_code == 403, (
|
||||||
f"POST fara CSRF trebuia 403, got {resp.status_code}"
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -143,7 +143,8 @@ def test_preview_arata_panoul_de_mapare(client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "Operatii de mapat la cod RAR" in r.text
|
assert "Operatii de mapat la cod RAR" in r.text
|
||||||
assert "OP-REV" 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
618
tests/test_import_review.py
Normal 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
203
tests/test_signup.py
Normal 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"])
|
||||||
@@ -45,9 +45,11 @@ def _csrf(c: TestClient) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecreta") -> object:
|
def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecreta") -> object:
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
token = _csrf(c)
|
token = _csrf(c)
|
||||||
return c.post("/signup", data={
|
return c.post("/signup", data={
|
||||||
"name": name,
|
"name": name,
|
||||||
|
"cui": make_test_cui(email),
|
||||||
"email": email,
|
"email": email,
|
||||||
"parola": parola,
|
"parola": parola,
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def _run(argv):
|
|||||||
|
|
||||||
|
|
||||||
def test_create_afiseaza_id(env, capsys):
|
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
|
out = capsys.readouterr().out
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "id=2" in out
|
assert "id=2" in out
|
||||||
@@ -32,14 +32,14 @@ def test_create_afiseaza_id(env, capsys):
|
|||||||
|
|
||||||
|
|
||||||
def test_create_inactive_in_asteptare(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
|
out = capsys.readouterr().out
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "activ=nu" in out
|
assert "activ=nu" in out
|
||||||
|
|
||||||
|
|
||||||
def test_create_with_key_emite_cheie(env, capsys):
|
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
|
out = capsys.readouterr().out
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert "rfak_" in out
|
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):
|
def test_create_cui_duplicat_exit_2(env, capsys):
|
||||||
assert _run(["create", "--name", "Service A", "--cui", "RO123"]) == 0
|
assert _run(["create", "--name", "Service A", "--cui", "RO123", "--email", "a@test.com"]) == 0
|
||||||
rc = _run(["create", "--name", "Service B", "--cui", "RO123"])
|
rc = _run(["create", "--name", "Service B", "--cui", "RO123", "--email", "b@test.com"])
|
||||||
err = capsys.readouterr().err
|
err = capsys.readouterr().err
|
||||||
assert rc == 2
|
assert rc == 2
|
||||||
assert "RO123" in err
|
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):
|
def test_with_key_atomic_pe_cui_duplicat(env, capsys):
|
||||||
# cont initial care ocupa CUI
|
# 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()
|
capsys.readouterr()
|
||||||
# --with-key pe CUI duplicat: rollback -> niciun cont B, nicio cheie orfana
|
# --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
|
assert rc == 2
|
||||||
from app.db import get_connection
|
from app.db import get_connection
|
||||||
conn = 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):
|
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()
|
capsys.readouterr()
|
||||||
assert _run(["deactivate", "--account", "2"]) == 0
|
assert _run(["deactivate", "--account", "2"]) == 0
|
||||||
assert _run(["activate", "--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):
|
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()
|
capsys.readouterr()
|
||||||
rc = _run(["list"])
|
rc = _run(["list"])
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
@@ -114,8 +114,8 @@ def test_list_afiseaza_activ(env, capsys):
|
|||||||
|
|
||||||
|
|
||||||
def test_list_pending_filtreaza(env, capsys):
|
def test_list_pending_filtreaza(env, capsys):
|
||||||
_run(["create", "--name", "Activ SRL"])
|
_run(["create", "--name", "Activ SRL", "--cui", "RO006", "--email", "activ@test.com"])
|
||||||
_run(["create", "--name", "Asteptare SRL", "--inactive"])
|
_run(["create", "--name", "Asteptare SRL", "--cui", "RO007", "--email", "ast@test.com", "--inactive"])
|
||||||
capsys.readouterr()
|
capsys.readouterr()
|
||||||
rc = _run(["list", "--pending"])
|
rc = _run(["list", "--pending"])
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ def _csrf(client, url):
|
|||||||
|
|
||||||
|
|
||||||
def _signup(client, name, email, password="parola_test_001"):
|
def _signup(client, name, email, password="parola_test_001"):
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
tok = _csrf(client, "/signup")
|
tok = _csrf(client, "/signup")
|
||||||
client.post("/signup", data={"name": name, "email": email, "parola": password,
|
client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email,
|
||||||
"csrf_token": tok}, follow_redirects=True)
|
"parola": password, "csrf_token": tok}, follow_redirects=True)
|
||||||
from app.db import get_connection
|
from app.db import get_connection
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -230,3 +230,126 @@ def test_fragment_cont_nelogat_redirect(monkeypatch):
|
|||||||
assert resp.status_code == 303
|
assert resp.status_code == 303
|
||||||
assert "/login" in resp.headers.get("location", "")
|
assert "/login" in resp.headers.get("location", "")
|
||||||
get_settings.cache_clear()
|
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]}"
|
||||||
|
|||||||
174
tests/test_web_form_editare.py
Normal file
174
tests/test_web_form_editare.py
Normal 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
421
tests/test_web_mapare_op.py
Normal 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()
|
||||||
165
tests/test_web_mapcoloane.py
Normal file
165
tests/test_web_mapcoloane.py
Normal 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"
|
||||||
182
tests/test_web_preview_compact.py
Normal file
182
tests/test_web_preview_compact.py
Normal 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"
|
||||||
541
tests/test_web_preview_edit.py
Normal file
541
tests/test_web_preview_edit.py
Normal 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)."
|
||||||
|
)
|
||||||
@@ -287,4 +287,79 @@ def test_login_signup_full_width_mobil(client):
|
|||||||
# Regula mobil: cardul nu depaseste viewport-ul.
|
# Regula mobil: cardul nu depaseste viewport-ul.
|
||||||
mobil = html[html.find("@media (max-width:767px)"):]
|
mobil = html[html.find("@media (max-width:767px)"):]
|
||||||
assert ".auth-card" in mobil, ruta
|
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"
|
||||||
|
|||||||
@@ -79,10 +79,12 @@ def test_signup_creeaza_cont_user_si_cheie(client):
|
|||||||
|
|
||||||
def test_signup_email_duplicat_eroare(client):
|
def test_signup_email_duplicat_eroare(client):
|
||||||
"""Email duplicat -> ROLLBACK; COUNT(accounts) neschimbat (fara cont orfan)."""
|
"""Email duplicat -> ROLLBACK; COUNT(accounts) neschimbat (fara cont orfan)."""
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
resp = client.get("/signup")
|
resp = client.get("/signup")
|
||||||
token = _csrf(resp.text)
|
token = _csrf(resp.text)
|
||||||
client.post("/signup", data={
|
client.post("/signup", data={
|
||||||
"name": "Service A",
|
"name": "Service A",
|
||||||
|
"cui": make_test_cui("dup@example.com"),
|
||||||
"email": "dup@example.com",
|
"email": "dup@example.com",
|
||||||
"parola": "parolasecreta",
|
"parola": "parolasecreta",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
@@ -97,6 +99,7 @@ def test_signup_email_duplicat_eroare(client):
|
|||||||
token = _csrf(resp.text)
|
token = _csrf(resp.text)
|
||||||
resp2 = client.post("/signup", data={
|
resp2 = client.post("/signup", data={
|
||||||
"name": "Service B",
|
"name": "Service B",
|
||||||
|
"cui": make_test_cui("dup-b@example.com"),
|
||||||
"email": "dup@example.com",
|
"email": "dup@example.com",
|
||||||
"parola": "altaparola123",
|
"parola": "altaparola123",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
@@ -138,11 +141,13 @@ def test_signup_parola_scurta_eroare(client):
|
|||||||
|
|
||||||
def test_cheie_afisata_o_data(client):
|
def test_cheie_afisata_o_data(client):
|
||||||
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
|
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
|
||||||
|
from tests.conftest import make_test_cui
|
||||||
resp = client.get("/signup")
|
resp = client.get("/signup")
|
||||||
token = _csrf(resp.text)
|
token = _csrf(resp.text)
|
||||||
|
|
||||||
resp_post = client.post("/signup", data={
|
resp_post = client.post("/signup", data={
|
||||||
"name": "Service Cheie",
|
"name": "Service Cheie",
|
||||||
|
"cui": make_test_cui("cheie@test.com"),
|
||||||
"email": "cheie@test.com",
|
"email": "cheie@test.com",
|
||||||
"parola": "parolasecreta",
|
"parola": "parolasecreta",
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def _create(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
|
|||||||
active = not args.inactive
|
active = not args.inactive
|
||||||
if not args.with_key:
|
if not args.with_key:
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
print(f"eroare: {exc}", file=sys.stderr)
|
print(f"eroare: {exc}", file=sys.stderr)
|
||||||
return 2
|
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).
|
# --with-key: cont + cheie in aceeasi tranzactie (DB ruleaza autocommit).
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
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)
|
key = create_api_key(conn, acct_id)
|
||||||
conn.execute("COMMIT")
|
conn.execute("COMMIT")
|
||||||
except ValueError as exc:
|
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 = sub.add_parser("create", help="creeaza un cont nou")
|
||||||
p_create.add_argument("--name", required=True, help="nume cont (service)")
|
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("--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)")
|
p_create.add_argument("--with-key", action="store_true", help="emite si prima cheie API (atomic)")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user